feat(usage): usage_daily 聚合 + 广告解锁 + free 额度校验 (tsk_1taxhtV2k3RP)
server/internal/usage 模块实现(仅字节/分钟,无目的地,符合无日志口径): - aggregator.go:实现 #5 的 ReportUsage 回调接口(UsageReporter), dp_uuid→user_id 走内存+Redis 短缓存,按上报批次 id Redis 去重防重放, 按 At 的 UTC 日期 INSERT ... ON DUPLICATE KEY UPDATE 累加,并发安全。 - store.go:usage_daily 累加/区间查询/当日查询、MarkAdUnlocked(事务+FOR UPDATE 幂等)、EffectivePlan(活跃订阅取最高 tier,无则回落 free)。 - ads.go:AdVerifier 接口 + AdMob SSV 实现(拉取并缓存 Google ECDSA 公钥, ECDSA-SHA256 验签 + custom_data/ad_unit 匹配),Unity 留接口占位。 - service.go:UsageCurve(空日补零、仅当前用户)、TodaySummary(/v1/me)、 UnlockAd(验签→ad_token 一次性 nonce 去重→写 ad_unlocked_at,当日二次幂等)。 - quota.go:CheckFreeConnect 供 #5 connect 使用:ad_gate=true 校验当日 ad_unlocked_at + minutes_used<daily_minutes(free=10),返回剩余分钟; pro/team 不受 ad_gate 限制(返回 Unlimited);带双语 code 错误。 - handler.go:GET /v1/usage?days=7、POST /v1/ads/unlock(204)。 - apierr:新增 AD_NOT_UNLOCKED/QUOTA_EXHAUSTED/AD_VERIFY_FAILED/AD_TOKEN_REPLAY。 - openapi:/ads/unlock 补 409 Conflict(重放)。 测试:ads_test.go 单测覆盖 AdMob 验签全链路(合法/伪造/custom_data 不符/ ad_unit 不允许/畸形 token)+ Unity 未实现;usage_integration_test.go (testcontainers, build tag integration) 覆盖并发多节点累加、批次重放去重、 跨 UTC 日界分桶、未知 dp_uuid 丢弃、free 额度四态、ads 解锁伪造/重放/幂等、 曲线补零/隔离、/me 摘要。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
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
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user