ece8ea3b57
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>
139 lines
4.4 KiB
Go
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×tamp=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")
|
|
}
|
|
}
|