Files
pangolin/server/internal/httpapi/contract_test.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

62 lines
1.7 KiB
Go

package httpapi
// contract_test.go — HTTP 契约快照(支柱 2:契约单源)。
//
// 冻结 GET /v1/me 响应的字段面(JSON key)。后端加/删/改字段会让本测试失败,逼开发者
// 同步客户端 model + 文档,避免控制面与客户端悄悄分叉。
//
// ⚠️ 与客户端冻结集对账:client/test/contract/api_contract_test.dart 的 _frozenMeKeys
// (9 个)。后端这里多发 uuid / dp_uuid(客户端故意忽略);expire_at 已废弃移除——
// 无 web 用户中心后,两端统一只用 expires_at。改任一端都要回看另一端。
import (
"reflect"
"strings"
"testing"
)
func jsonTagSet(v any) map[string]struct{} {
t := reflect.TypeOf(v)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
set := map[string]struct{}{}
for i := 0; i < t.NumField(); i++ {
tag := t.Field(i).Tag.Get("json")
if tag == "" || tag == "-" {
continue
}
name := strings.SplitN(tag, ",", 2)[0]
if name != "" {
set[name] = struct{}{}
}
}
return set
}
func assertFrozenKeys(t *testing.T, name string, got map[string]struct{}, want ...string) {
t.Helper()
wantSet := make(map[string]struct{}, len(want))
for _, k := range want {
wantSet[k] = struct{}{}
}
for k := range got {
if _, ok := wantSet[k]; !ok {
t.Errorf("%s: 出现未冻结字段 %q —— 改契约需同步另一端(客户端 model)+ 文档", name, k)
}
}
for _, k := range want {
if _, ok := got[k]; !ok {
t.Errorf("%s: 缺失冻结字段 %q", name, k)
}
}
}
func TestContractMeResponse(t *testing.T) {
assertFrozenKeys(t, "/v1/me", jsonTagSet(meResponse{}),
"uuid", "email", "dp_uuid", "plan", "expires_at",
"devices_used", "devices_max",
"quota_today_min", "quota_cap_min", "data_today_gb", "weekly_gb", "totp_enabled",
)
}