Files
wangjia ece8ea3b57 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>
2026-06-13 12:16:50 +08:00

139 lines
4.4 KiB
Go

package usage
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// adMobTestKeyServer spins up an httptest server that serves a single ECDSA
// public key in the AdMob verifier-keys.json format, and returns the server,
// the private key, and the key id.
func adMobTestKeyServer(t *testing.T) (*httptest.Server, *ecdsa.PrivateKey, string) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
der, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
t.Fatalf("marshal pub: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})
const keyID = "3335741209"
body := map[string]any{
"keys": []map[string]any{
{"keyId": 3335741209, "pem": string(pemBytes), "base64": base64.StdEncoding.EncodeToString(der)},
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(body)
}))
t.Cleanup(srv.Close)
return srv, priv, keyID
}
// signAdMob builds a signed AdMob SSV callback query string.
func signAdMob(t *testing.T, priv *ecdsa.PrivateKey, keyID, content string) string {
t.Helper()
digest := sha256.Sum256([]byte(content))
sig, err := ecdsa.SignASN1(rand.Reader, priv, digest[:])
if err != nil {
t.Fatalf("sign: %v", err)
}
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
return fmt.Sprintf("%s&signature=%s&key_id=%s", content, sigB64, keyID)
}
func TestAdMobVerifyValid(t *testing.T) {
srv, priv, keyID := adMobTestKeyServer(t)
v := NewAdMobVerifier(srv.URL, srv.Client(), time.Hour, []string{"ca-app-pub-123/456"})
content := "ad_network=admob&ad_unit=ca-app-pub-123/456&custom_data=user-uuid-42&reward_amount=1&reward_item=minutes&timestamp=1700000000&transaction_id=abc123&user_id=device-1"
token := signAdMob(t, priv, keyID, content)
err := v.Verify(context.Background(), AdVerifyRequest{
UserID: 42,
DeviceID: "device-1",
AdToken: token,
ExpectedCustomData: "user-uuid-42",
})
if err != nil {
t.Fatalf("expected valid receipt, got %v", err)
}
}
func TestAdMobVerifyForgedSignature(t *testing.T) {
srv, priv, keyID := adMobTestKeyServer(t)
v := NewAdMobVerifier(srv.URL, srv.Client(), time.Hour, nil)
content := "ad_unit=ca-app-pub-123/456&custom_data=u1&transaction_id=abc"
token := signAdMob(t, priv, keyID, content)
// Tamper with the signed content after signing → signature no longer matches.
tampered := "ad_unit=ca-app-pub-123/456&custom_data=u1&transaction_id=EVIL" + token[len(content):]
if err := v.Verify(context.Background(), AdVerifyRequest{AdToken: tampered}); err == nil {
t.Fatal("expected forged signature to be rejected")
}
}
func TestAdMobVerifyCustomDataMismatch(t *testing.T) {
srv, priv, keyID := adMobTestKeyServer(t)
v := NewAdMobVerifier(srv.URL, srv.Client(), time.Hour, nil)
content := "ad_unit=ca-app-pub-123/456&custom_data=u1&transaction_id=abc"
token := signAdMob(t, priv, keyID, content)
if err := v.Verify(context.Background(), AdVerifyRequest{
AdToken: token,
ExpectedCustomData: "someone-else",
}); err == nil {
t.Fatal("expected custom_data mismatch to be rejected")
}
}
func TestAdMobVerifyDisallowedAdUnit(t *testing.T) {
srv, priv, keyID := adMobTestKeyServer(t)
v := NewAdMobVerifier(srv.URL, srv.Client(), time.Hour, []string{"ca-app-pub-allowed/1"})
content := "ad_unit=ca-app-pub-OTHER/9&custom_data=u1&transaction_id=abc"
token := signAdMob(t, priv, keyID, content)
if err := v.Verify(context.Background(), AdVerifyRequest{AdToken: token}); err == nil {
t.Fatal("expected disallowed ad_unit to be rejected")
}
}
func TestAdMobVerifyMalformedToken(t *testing.T) {
srv, _, _ := adMobTestKeyServer(t)
v := NewAdMobVerifier(srv.URL, srv.Client(), time.Hour, nil)
for _, tok := range []string{"", "no-signature-here", "?garbage"} {
if err := v.Verify(context.Background(), AdVerifyRequest{AdToken: tok}); err == nil {
t.Errorf("expected malformed token %q to be rejected", tok)
}
}
}
func TestUnityVerifierUnimplemented(t *testing.T) {
var v AdVerifier = UnityVerifier{}
if v.Provider() != "unity" {
t.Errorf("provider = %q, want unity", v.Provider())
}
if err := v.Verify(context.Background(), AdVerifyRequest{}); err == nil {
t.Error("expected Unity verifier to report unimplemented")
}
}