Files
pangolin/server/internal/usage/ads.go
T
wangjia e023fb6579 feat(server): 免费版账户级分钟卡控 + 累加式看广告加时(#21 后端)
免费版此前形同虚设:ConnectNode 每次连接发固定 daily_minutes×1min TTL、
从不扣减已用,重连即崭新 10 分钟,日额度从未强制。改为账户级(全设备共享)
真卡控 + 累加式看广告加时:

- migration 000020: usage_daily 加 ad_bonus_minutes(sqlite+mysql)。
  当日额度 = plans.daily_minutes + ad_bonus_minutes;剩余 = 额度 − minutes_used。
- usage.store.AddAdBonusMinutes: 事务内累加封顶(替代布尔 MarkAdUnlocked),
  返回新总额+本次实际加时;GetDay/GetUsageRange 补读 ad_bonus_minutes。
- usage.service.UnlockAd: verify+nonce 后 +adBonusPerAd(10) 封顶 adDailyBonusCeiling(120),
  返回 granted+remaining;TodaySummary 加 MinutesCap/AdBonusMinutes。
- usage.ads DevVerifier: 放行式占位校验(nonce 防重放仍生效),接真 AdMob 前
  让看广告加时流程可端到端跑通;main.go 按 ADS_DEV_MODE/默认装配。
- ads/unlock: 204 → 200 返回 {granted_minutes, minutes_remaining}。
- ConnectNode 免费门: 读 AccountDayMinutes(used,bonus) → remaining≤0 拒 QUOTA_EXHAUSTED,
  否则 TTL=remaining(凭证到点硬切断兜底)。nodes.store 加 AccountDayMinutes。
- /me: 补 ad_bonus_minutes,quota_today_min 分母改 daily+bonus,加 quota_cap_min。
- quota.CheckFreeConnect 同步累加语义(免费基础 10 分钟不再需先看广告)。
- openapi + 契约/迁移版本/集成测试同步;新增 SQLite AddAdBonusMinutes 单测。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:44:10 +08:00

281 lines
8.4 KiB
Go

package usage
import (
"context"
"crypto/ecdsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// AdVerifyRequest carries the inputs an AdVerifier needs to authenticate a
// rewarded-ad receipt.
type AdVerifyRequest struct {
// UserID is the authenticated user requesting the unlock.
UserID int64
// DeviceID is the device UUID that played the ad.
DeviceID string
// AdToken is the provider's server-side verification receipt. For AdMob
// SSV this is the full callback query string (everything after the '?').
AdToken string
// ExpectedCustomData, when non-empty, must match the receipt's custom_data
// field (binds the receipt to this user).
ExpectedCustomData string
}
// AdVerifier authenticates a rewarded-ad receipt with the ad network.
// Implementations must be safe for concurrent use.
type AdVerifier interface {
// Verify returns nil if the receipt is genuine and bound to the request.
Verify(ctx context.Context, req AdVerifyRequest) error
// Provider names the ad network (e.g. "admob", "unity").
Provider() string
}
// DevVerifier is a placeholder AdVerifier that accepts any receipt. It exists
// so the免费版看广告加时 flow can be exercised end-to-end with the client's
// placeholder ad dialog before a real ad SDK (AdMob SSV) is wired in. Nonce
// replay protection still applies at the service layer, so a token can only be
// redeemed once. MUST NOT be used in production with real ad revenue.
type DevVerifier struct{}
// Verify always succeeds.
func (DevVerifier) Verify(context.Context, AdVerifyRequest) error { return nil }
// Provider names this placeholder verifier.
func (DevVerifier) Provider() string { return "dev" }
// --------------------------------------------------------------------------
// AdMob Server-Side Verification (SSV)
// --------------------------------------------------------------------------
// DefaultAdMobKeyServerURL is Google's public ECDSA key endpoint for AdMob SSV.
const DefaultAdMobKeyServerURL = "https://www.gstatic.com/admob/reward/verifier-keys.json"
// AdMobVerifier verifies AdMob SSV callbacks. It fetches and caches Google's
// ECDSA public keys, then checks the ECDSA-SHA256 signature over the callback
// content, that the receipt's custom_data matches the user, and (optionally)
// that the ad_unit is one we recognise.
type AdMobVerifier struct {
keyServerURL string
httpClient *http.Client
allowedAdUnits map[string]struct{}
keyTTL time.Duration
mu sync.RWMutex
keys map[string]*ecdsa.PublicKey
fetchedAt time.Time
}
// NewAdMobVerifier creates an AdMobVerifier. keyServerURL empty → the default
// Google endpoint. keyTTL <= 0 → 12h. allowedAdUnits empty → any ad unit is
// accepted (only the signature and custom_data are enforced).
func NewAdMobVerifier(keyServerURL string, httpClient *http.Client, keyTTL time.Duration, allowedAdUnits []string) *AdMobVerifier {
if keyServerURL == "" {
keyServerURL = DefaultAdMobKeyServerURL
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 5 * time.Second}
}
if keyTTL <= 0 {
keyTTL = 12 * time.Hour
}
allowed := make(map[string]struct{}, len(allowedAdUnits))
for _, u := range allowedAdUnits {
allowed[u] = struct{}{}
}
return &AdMobVerifier{
keyServerURL: keyServerURL,
httpClient: httpClient,
allowedAdUnits: allowed,
keyTTL: keyTTL,
}
}
// Provider implements AdVerifier.
func (v *AdMobVerifier) Provider() string { return "admob" }
// errAdMobVerify is returned for any receipt that fails authentication.
var errAdMobVerify = errors.New("admob: receipt verification failed")
// Verify implements AdVerifier for AdMob SSV.
//
// AdMob signs the callback by computing ECDSA-SHA256 over the query string up
// to (but excluding) "&signature="; the signature (web-safe base64, ASN.1 DER)
// and key_id follow. See Google's SSV documentation.
func (v *AdMobVerifier) Verify(ctx context.Context, req AdVerifyRequest) error {
raw := strings.TrimPrefix(req.AdToken, "?")
if raw == "" {
return errAdMobVerify
}
// Content to verify = everything before "&signature=".
sigMarker := strings.Index(raw, "&signature=")
if sigMarker < 0 {
return errAdMobVerify
}
content := raw[:sigMarker]
params, err := url.ParseQuery(raw)
if err != nil {
return errAdMobVerify
}
sigB64 := params.Get("signature")
keyID := params.Get("key_id")
if sigB64 == "" || keyID == "" {
return errAdMobVerify
}
// Bind the receipt to the user / ad unit.
if req.ExpectedCustomData != "" && params.Get("custom_data") != req.ExpectedCustomData {
return errAdMobVerify
}
if len(v.allowedAdUnits) > 0 {
if _, ok := v.allowedAdUnits[params.Get("ad_unit")]; !ok {
return errAdMobVerify
}
}
sig, err := base64.RawURLEncoding.DecodeString(sigB64)
if err != nil {
// Some senders include padding; fall back to standard URL encoding.
sig, err = base64.URLEncoding.DecodeString(sigB64)
if err != nil {
return errAdMobVerify
}
}
pub, err := v.publicKey(ctx, keyID)
if err != nil {
return err
}
digest := sha256.Sum256([]byte(content))
if !ecdsa.VerifyASN1(pub, digest[:], sig) {
return errAdMobVerify
}
return nil
}
// publicKey returns the cached ECDSA public key for keyID, refreshing the key
// set from the server when the cache is empty, stale, or missing the key.
func (v *AdMobVerifier) publicKey(ctx context.Context, keyID string) (*ecdsa.PublicKey, error) {
v.mu.RLock()
pub, ok := v.keys[keyID]
fresh := time.Since(v.fetchedAt) < v.keyTTL
v.mu.RUnlock()
if ok && fresh {
return pub, nil
}
if err := v.refreshKeys(ctx); err != nil {
// Serve a stale cached key rather than fail outright on a transient
// fetch error.
if ok {
return pub, nil
}
return nil, err
}
v.mu.RLock()
pub, ok = v.keys[keyID]
v.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("admob: unknown key_id %q", keyID)
}
return pub, nil
}
// admobKeySet mirrors the JSON returned by the AdMob verifier-keys endpoint.
type admobKeySet struct {
Keys []struct {
KeyID json.Number `json:"keyId"`
PEM string `json:"pem"`
Base64 string `json:"base64"`
} `json:"keys"`
}
// refreshKeys fetches and parses the public key set from the key server.
func (v *AdMobVerifier) refreshKeys(ctx context.Context) error {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, v.keyServerURL, nil)
if err != nil {
return fmt.Errorf("admob refreshKeys: %w", err)
}
resp, err := v.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("admob refreshKeys: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("admob refreshKeys: status %d", resp.StatusCode)
}
var ks admobKeySet
if err := json.NewDecoder(resp.Body).Decode(&ks); err != nil {
return fmt.Errorf("admob refreshKeys decode: %w", err)
}
parsed := make(map[string]*ecdsa.PublicKey, len(ks.Keys))
for _, k := range ks.Keys {
pub, err := parseECDSAPublicKey(k.PEM)
if err != nil {
continue // skip unparseable keys rather than failing the whole set
}
parsed[k.KeyID.String()] = pub
}
if len(parsed) == 0 {
return errors.New("admob refreshKeys: no usable keys")
}
v.mu.Lock()
v.keys = parsed
v.fetchedAt = time.Now()
v.mu.Unlock()
return nil
}
// parseECDSAPublicKey parses a PEM-encoded PKIX ECDSA public key.
func parseECDSAPublicKey(pemStr string) (*ecdsa.PublicKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, errors.New("admob: invalid PEM")
}
anyKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("admob: parse pkix: %w", err)
}
pub, ok := anyKey.(*ecdsa.PublicKey)
if !ok {
return nil, errors.New("admob: not an ECDSA key")
}
return pub, nil
}
// --------------------------------------------------------------------------
// Unity Ads (interface placeholder)
// --------------------------------------------------------------------------
// UnityVerifier is a placeholder for Unity Ads SSV; the interface is wired now
// and the verification logic will be implemented when Unity is integrated.
type UnityVerifier struct{}
// Provider implements AdVerifier.
func (UnityVerifier) Provider() string { return "unity" }
// errUnityUnimplemented signals that Unity SSV is not yet available.
var errUnityUnimplemented = errors.New("unity: SSV verification not implemented")
// Verify implements AdVerifier.
func (UnityVerifier) Verify(context.Context, AdVerifyRequest) error {
return errUnityUnimplemented
}