From e023fb6579cc46dbc67f8a741f3c621ae6b80a77 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 1 Jul 2026 23:44:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E5=85=8D=E8=B4=B9=E7=89=88?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E7=BA=A7=E5=88=86=E9=92=9F=E5=8D=A1=E6=8E=A7?= =?UTF-8?q?=20+=20=E7=B4=AF=E5=8A=A0=E5=BC=8F=E7=9C=8B=E5=B9=BF=E5=91=8A?= =?UTF-8?q?=E5=8A=A0=E6=97=B6(#21=20=E5=90=8E=E7=AB=AF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 免费版此前形同虚设: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 --- server/api/openapi.yaml | 34 ++++++-- server/cmd/server/main.go | 18 +++- server/internal/httpapi/account.go | 13 ++- server/internal/httpapi/contract_test.go | 2 +- server/internal/httpapi/nodes.go | 25 +++++- server/internal/nodes/grpc_test.go | 4 + server/internal/nodes/store.go | 21 +++++ server/internal/store/sqlite_migrate_test.go | 4 +- server/internal/store/sqlite_stores_test.go | 45 ++++++++++ server/internal/usage/ads.go | 13 +++ server/internal/usage/handler.go | 19 ++-- server/internal/usage/quota.go | 27 +++--- server/internal/usage/service.go | 62 +++++++++---- server/internal/usage/store.go | 84 ++++++++++++++++-- .../internal/usage/usage_integration_test.go | 87 +++++++++++-------- .../mysql/000020_ad_bonus_minutes.down.sql | 1 + .../mysql/000020_ad_bonus_minutes.up.sql | 4 + .../sqlite/000020_ad_bonus_minutes.down.sql | 1 + .../sqlite/000020_ad_bonus_minutes.up.sql | 4 + 19 files changed, 367 insertions(+), 101 deletions(-) create mode 100644 server/migrations/mysql/000020_ad_bonus_minutes.down.sql create mode 100644 server/migrations/mysql/000020_ad_bonus_minutes.up.sql create mode 100644 server/migrations/sqlite/000020_ad_bonus_minutes.down.sql create mode 100644 server/migrations/sqlite/000020_ad_bonus_minutes.up.sql diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml index 770c0a8..f27dce7 100644 --- a/server/api/openapi.yaml +++ b/server/api/openapi.yaml @@ -294,10 +294,11 @@ paths: /ads/unlock: post: operationId: adsUnlock - summary: 激励视频广告解锁当日时长 + summary: 激励视频广告加时(累加式) description: | 免费版用户完成激励视频广告后调用。服务端向广告平台(AdMob/Unity)校验 ad_token 真伪, - 通过后记录 `usage_daily.ad_unlocked_at`,当日 connect 接口方可放行。 + 通过后向 `usage_daily.ad_bonus_minutes` 累加固定分钟(每日封顶),当日免费额度随之上浮。 + 额度为**全账户共享**(非每设备)。响应返回本次实际加时与最新剩余分钟。 tags: [Commerce] requestBody: required: true @@ -315,8 +316,20 @@ paths: type: string description: 广告 SDK 签发的服务端回执 token responses: - "204": - description: 广告解锁成功(无响应体) + "200": + description: 广告加时成功 + content: + application/json: + schema: + type: object + required: [granted_minutes, minutes_remaining] + properties: + granted_minutes: + type: integer + description: 本次广告实际加时分钟(已达每日封顶时为 0) + minutes_remaining: + type: integer + description: 加时后账户当日剩余分钟(全账户共享) "400": $ref: "#/components/responses/BadRequest" "401": @@ -715,14 +728,23 @@ components: type: integer description: 今日已使用分钟数 example: 3 + minutes_cap: + type: integer + nullable: true + description: 今日额度 = 套餐每日分钟 + 看广告累加分钟;付费无限制时为 null + example: 20 minutes_remaining: type: integer nullable: true - description: 今日剩余分钟数,付费无限制时为 null + description: 今日剩余分钟数(全账户共享),付费无限制时为 null example: 7 + ad_bonus_minutes: + type: integer + description: 今日已通过看广告累加的分钟数 + example: 10 ad_unlocked: type: boolean - description: 免费版今日是否已完成激励广告解锁 + description: 免费版今日是否已通过看广告加过时(ad_bonus_minutes > 0,历史字段) example: false Device: diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 9bf23c3..b449486 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -301,7 +301,23 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv // ── Usage ───────────────────────────────────────────────────────────────── usageStore := usage.NewStore(sqlDB) - usageSvc := usage.NewService(usageStore, rdb, nil, time.Hour) + // Ad verifier: real AdMob SSV in production; a放行式 DevVerifier when + // ADS_DEV_MODE=1 (or no AdMob configured) so the placeholder看广告加时 flow + // works end-to-end before the real ad SDK is wired in. Nonce replay + // protection still applies either way. + var adVerifier usage.AdVerifier + if os.Getenv("ADS_DEV_MODE") == "1" { + adVerifier = usage.DevVerifier{} + slog.Warn("ads: DevVerifier enabled (ADS_DEV_MODE=1) — accepts any receipt; not for production") + } else if os.Getenv("ADMOB_SSV") == "1" { + adVerifier = usage.NewAdMobVerifier("", nil, 0, nil) + } else { + // Default (current state): no real ad network yet → placeholder verifier + // so免费版看广告加时 is functional in the field. + adVerifier = usage.DevVerifier{} + slog.Warn("ads: no ad network configured — using DevVerifier placeholder") + } + usageSvc := usage.NewService(usageStore, rdb, adVerifier, time.Hour) usageHandler := usage.NewUsageHandler(usageSvc) deviceUsageHandler := usage.NewDeviceUsageHandler(usageSvc) adsHandler := usage.NewAdsUnlockHandler(usageSvc) diff --git a/server/internal/httpapi/account.go b/server/internal/httpapi/account.go index 14b16d2..f8b57c0 100644 --- a/server/internal/httpapi/account.go +++ b/server/internal/httpapi/account.go @@ -31,7 +31,8 @@ type meResponse struct { // Web user-center fields. DevicesUsed int `json:"devices_used"` DevicesMax int `json:"devices_max"` - QuotaTodayMin *int `json:"quota_today_min"` // null = unlimited (pro/team) + QuotaTodayMin *int `json:"quota_today_min"` // 剩余分钟; null = unlimited (pro/team) + QuotaCapMin *int `json:"quota_cap_min"` // 当日额度 = daily + 看广告 bonus; null = unlimited DataTodayGB float64 `json:"data_today_gb"` WeeklyGB []float64 `json:"weekly_gb"` // last 7 days, oldest→newest TOTPEnabled bool `json:"totp_enabled"` @@ -99,11 +100,12 @@ func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) { // 日期在 Go 端算好传 ?,不用 MySQL 专属 UTC_DATE()(否则 SQLite 报错→恒 0)。 var todayBytes uint64 var todayMinutes int + var todayAdBonus int today := time.Now().UTC().Format("2006-01-02") _ = a.db.QueryRowContext(ctx, ` - SELECT COALESCE(bytes_up, 0) + COALESCE(bytes_down, 0), COALESCE(minutes_used, 0) + SELECT COALESCE(bytes_up, 0) + COALESCE(bytes_down, 0), COALESCE(minutes_used, 0), COALESCE(ad_bonus_minutes, 0) FROM usage_daily WHERE user_id = ? AND date = ? - `, uid, today).Scan(&todayBytes, &todayMinutes) + `, uid, today).Scan(&todayBytes, &todayMinutes, &todayAdBonus) resp := meResponse{ UUID: uuid, @@ -121,11 +123,14 @@ func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) { resp.ExpiresAt = &s } // quota_today_min: null when the plan is unlimited, else remaining minutes. + // 额度 = plan.daily_minutes + 当日看广告累加的 ad_bonus_minutes(账户共享)。 if dailyMinutes.Valid { - rem := int(dailyMinutes.Int64) - todayMinutes + cap := int(dailyMinutes.Int64) + todayAdBonus + rem := cap - todayMinutes if rem < 0 { rem = 0 } + resp.QuotaCapMin = &cap resp.QuotaTodayMin = &rem } diff --git a/server/internal/httpapi/contract_test.go b/server/internal/httpapi/contract_test.go index 35775d1..fc18f45 100644 --- a/server/internal/httpapi/contract_test.go +++ b/server/internal/httpapi/contract_test.go @@ -56,6 +56,6 @@ 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", "data_today_gb", "weekly_gb", "totp_enabled", + "quota_today_min", "quota_cap_min", "data_today_gb", "weekly_gb", "totp_enabled", ) } diff --git a/server/internal/httpapi/nodes.go b/server/internal/httpapi/nodes.go index d36a3f6..2dcee45 100644 --- a/server/internal/httpapi/nodes.go +++ b/server/internal/httpapi/nodes.go @@ -206,16 +206,33 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) { // 2. Determine TTL from plan. var ttl time.Duration if ent.AdGate { - // Free plan: flat 10-minute session for MVP (full ad-gate in a later pass). + // Free plan: enforce the account-wide daily minute quota (shared across + // ALL devices). allowance = plan.daily_minutes + 当日看广告累加的 bonus; + // remaining = allowance − minutes_used. Credential TTL = remaining so the + // data-plane hard-cuts when time runs out (backstop even if the client + // countdown is bypassed). remaining ≤ 0 → 拒 QUOTA_EXHAUSTED, client 弹广告/升级. dm := int64(10) if ent.DailyMinutes.Valid { dm = ent.DailyMinutes.Int64 } - if dm <= 0 { - apierr.WriteJSON(w, http.StatusForbidden, apierr.ErrQuotaExhausted) + used, bonus, qerr := a.store.AccountDayMinutes(r.Context(), uid, time.Now().UTC()) + if qerr != nil { + slog.Error("connect: account day minutes failed", "user", uid, "err", qerr) + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } - ttl = time.Duration(dm) * freeMinuteTTL + remaining := dm + int64(bonus) - int64(used) + if remaining <= 0 { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": "QUOTA_EXHAUSTED", + "message_zh": "今日免费时长已用完,看广告加时或升级会员", + "message_en": "Daily free minutes used up. Watch an ad to add time or upgrade.", + }) + return + } + ttl = time.Duration(remaining) * freeMinuteTTL } else { ttl = paidCredentialTTL } diff --git a/server/internal/nodes/grpc_test.go b/server/internal/nodes/grpc_test.go index 90dd817..c174ce4 100644 --- a/server/internal/nodes/grpc_test.go +++ b/server/internal/nodes/grpc_test.go @@ -146,6 +146,10 @@ func (m *mockNodeStore) AccountDayBytes(_ context.Context, _ int64, _ time.Time) return 0, nil } +func (m *mockNodeStore) AccountDayMinutes(_ context.Context, _ int64, _ time.Time) (int, int, error) { + return 0, 0, nil +} + func (m *mockNodeStore) CountActiveDevices(_ context.Context, _ int64, _ time.Time) (int, error) { return 0, nil } diff --git a/server/internal/nodes/store.go b/server/internal/nodes/store.go index e3aaa25..b9291e4 100644 --- a/server/internal/nodes/store.go +++ b/server/internal/nodes/store.go @@ -109,6 +109,11 @@ type NodeStore interface { // date — the basis for the GB 综合配额 connect gate. 0 when no usage yet. AccountDayBytes(ctx context.Context, userID int64, date time.Time) (int64, error) + // AccountDayMinutes returns the account's minutes_used and ad_bonus_minutes + // for userID on date — the basis for the免费版分钟配额 connect gate. Both 0 + // when no usage row exists yet. Quota is account-wide (shared across devices). + AccountDayMinutes(ctx context.Context, userID int64, date time.Time) (used, bonus int, err error) + // CountActiveDevices counts the user's devices seen since cutoff (active). Used // by the connect device-limit backstop; stale rows are excluded. CountActiveDevices(ctx context.Context, userID int64, cutoff time.Time) (int, error) @@ -478,6 +483,22 @@ func (s *SQLNodeStore) AccountDayBytes(ctx context.Context, userID int64, date t return total.Int64, nil } +// AccountDayMinutes returns the account's minutes_used and ad_bonus_minutes for +// the day (0,0 when no usage row yet). +func (s *SQLNodeStore) AccountDayMinutes(ctx context.Context, userID int64, date time.Time) (used, bonus int, err error) { + err = s.db.QueryRowContext(ctx, + `SELECT minutes_used, ad_bonus_minutes FROM usage_daily WHERE user_id = ? AND date = ?`, + userID, date.Format("2006-01-02"), + ).Scan(&used, &bonus) + if err == sql.ErrNoRows { + return 0, 0, nil + } + if err != nil { + return 0, 0, fmt.Errorf("nodes.SQLNodeStore.AccountDayMinutes: %w", err) + } + return used, bonus, nil +} + // CountActiveDevices counts the user's devices seen within the active window // (last_seen > cutoff). Mirrors devices.Store.CountActiveDevices for the connect // backstop; stale/never-seen rows are excluded. diff --git a/server/internal/store/sqlite_migrate_test.go b/server/internal/store/sqlite_migrate_test.go index 634647c..b14da4f 100644 --- a/server/internal/store/sqlite_migrate_test.go +++ b/server/internal/store/sqlite_migrate_test.go @@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) { if dirty { t.Fatalf("schema dirty after MigrateUp") } - if v != 19 { - t.Errorf("version = %d, want 19", v) + if v != 20 { + t.Errorf("version = %d, want 20", v) } // 2. Core tables exist. diff --git a/server/internal/store/sqlite_stores_test.go b/server/internal/store/sqlite_stores_test.go index 9e6ce80..49cd7b6 100644 --- a/server/internal/store/sqlite_stores_test.go +++ b/server/internal/store/sqlite_stores_test.go @@ -58,6 +58,51 @@ func TestSQLite_UsageAccumulate(t *testing.T) { } } +// AddAdBonusMinutes 是免费版累加式看广告加时的核心原语:每次广告 +N 分钟, +// 封顶 ceiling,返回新总额与本次实际加时(封顶后为 0)。 +func TestSQLite_AddAdBonusMinutes(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + us := usage.NewStore(db) + day := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC) + + // First ad on a fresh day → insert row, bonus 10. + newBonus, granted, err := us.AddAdBonusMinutes(ctx, 7, day, 10, 25) + if err != nil || newBonus != 10 || granted != 10 { + t.Fatalf("first: newBonus=%d granted=%d err=%v, want 10/10", newBonus, granted, err) + } + + // Second ad → accumulate to 20. + newBonus, granted, err = us.AddAdBonusMinutes(ctx, 7, day, 10, 25) + if err != nil || newBonus != 20 || granted != 10 { + t.Fatalf("second: newBonus=%d granted=%d err=%v, want 20/10", newBonus, granted, err) + } + + // Third ad → clamped at ceiling 25, so only +5 granted. + newBonus, granted, err = us.AddAdBonusMinutes(ctx, 7, day, 10, 25) + if err != nil || newBonus != 25 || granted != 5 { + t.Fatalf("third: newBonus=%d granted=%d err=%v, want 25/5", newBonus, granted, err) + } + + // Fourth ad → already at ceiling, 0 granted. + newBonus, granted, err = us.AddAdBonusMinutes(ctx, 7, day, 10, 25) + if err != nil || newBonus != 25 || granted != 0 { + t.Fatalf("fourth: newBonus=%d granted=%d err=%v, want 25/0", newBonus, granted, err) + } + + // Existing usage_daily columns are preserved alongside the bonus. + if err := us.AggregateUsage(ctx, 7, day, 0, 0, 4); err != nil { + t.Fatalf("aggregate: %v", err) + } + d, err := us.GetDay(ctx, 7, day) + if err != nil || d == nil { + t.Fatalf("getday: %v", err) + } + if d.AdBonusMinutes != 25 || d.MinutesUsed != 4 { + t.Errorf("getday wrong: bonus=%d used=%d, want 25/4", d.AdBonusMinutes, d.MinutesUsed) + } +} + // HasActiveSession 是「近实时远程下线」的服务端判据:有非吊销会话=在线, // 强制退出(RevokeByDevice)后=离线 → 客户端轮询到即登出。 func TestSQLite_SessionHasActiveSession(t *testing.T) { diff --git a/server/internal/usage/ads.go b/server/internal/usage/ads.go index 023b318..f3234e3 100644 --- a/server/internal/usage/ads.go +++ b/server/internal/usage/ads.go @@ -41,6 +41,19 @@ type AdVerifier interface { 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) // -------------------------------------------------------------------------- diff --git a/server/internal/usage/handler.go b/server/internal/usage/handler.go index fb0fb2e..d5542e8 100644 --- a/server/internal/usage/handler.go +++ b/server/internal/usage/handler.go @@ -144,9 +144,16 @@ type adsUnlockRequest struct { AdToken string `json:"ad_token"` } -// ServeHTTP implements http.Handler. On success it returns 204 No Content -// (matching the OpenAPI contract), including the idempotent already-unlocked -// case. +// adsUnlockResponse reports the minutes granted by this rewarded-ad view and +// the account's new remaining minutes for today, so the client can refresh its +// quota without a second /me round-trip. +type adsUnlockResponse struct { + GrantedMinutes int `json:"granted_minutes"` + MinutesRemaining int `json:"minutes_remaining"` +} + +// ServeHTTP implements http.Handler. On success it returns 200 with the granted +// minutes and new remaining quota (免费版累加式看广告加时). func (h *AdsUnlockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -168,13 +175,15 @@ func (h *AdsUnlockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - _, apiErr := h.svc.UnlockAd(r.Context(), userID, req.DeviceID, req.AdToken) + granted, remaining, apiErr := h.svc.UnlockAd(r.Context(), userID, req.DeviceID, req.AdToken) if apiErr != nil { apierr.WriteJSON(w, adsErrorStatus(apiErr.Code), apiErr) return } - w.WriteHeader(http.StatusNoContent) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(adsUnlockResponse{GrantedMinutes: granted, MinutesRemaining: remaining}) } // adsErrorStatus maps an ads-unlock error code to an HTTP status. diff --git a/server/internal/usage/quota.go b/server/internal/usage/quota.go index 731f305..eb0b185 100644 --- a/server/internal/usage/quota.go +++ b/server/internal/usage/quota.go @@ -11,16 +11,16 @@ import ( const defaultFreeDailyMinutes = 10 // CheckFreeConnect enforces the free-plan connect gate and returns the user's -// remaining minutes for today (UTC). It is called by #5's connect endpoint to -// derive the free credential's TTL. +// remaining minutes for today (UTC). The quota is account-wide (shared across +// all devices). // -// Rules: +// Rules (免费版累加式看广告加时): // - plan.ad_gate == false (pro/team): not minute-gated. Returns Unlimited // when daily_minutes is NULL, otherwise the remaining minutes for the day. -// - plan.ad_gate == true (free): today's ad_unlocked_at must be set and -// minutes_used must be below daily_minutes (free = 10). Returns the -// remaining minutes; otherwise a bilingual semantic error -// (AD_NOT_UNLOCKED / QUOTA_EXHAUSTED). +// - plan.ad_gate == true (free): the day's allowance = daily_minutes (free=10) +// + ad_bonus_minutes (accumulated by watching rewarded ads). No ad is +// required to connect at all — the base 10 minutes are always available. +// Returns allowance − minutes_used; QUOTA_EXHAUSTED when that reaches 0. func (svc *Service) CheckFreeConnect(ctx context.Context, userID int64) (remainingMinutes int, apiErr *apierr.Error) { plan, err := svc.store.EffectivePlan(ctx, userID) if err != nil { @@ -51,16 +51,19 @@ func (svc *Service) CheckFreeConnect(ctx context.Context, userID int64) (remaini return remaining, nil } - // Free plan: require ad unlock + remaining minutes. + // Free plan: allowance = base daily + ad bonus; remaining = allowance − used. day, err := svc.store.GetDay(ctx, userID, utcToday()) if err != nil { return 0, apierr.ErrInternal } - if day == nil || !day.AdUnlockedAt.Valid { - return 0, apierr.ErrAdNotUnlocked + allowance := limit + used := 0 + if day != nil { + allowance = limit + day.AdBonusMinutes + used = day.MinutesUsed } - if day.MinutesUsed >= limit { + if used >= allowance { return 0, apierr.ErrQuotaExhausted } - return limit - day.MinutesUsed, nil + return allowance - used, nil } diff --git a/server/internal/usage/service.go b/server/internal/usage/service.go index cfa289e..3882966 100644 --- a/server/internal/usage/service.go +++ b/server/internal/usage/service.go @@ -13,6 +13,14 @@ import ( // nowFunc is overridable in tests to pin "today". var nowFunc = time.Now +const ( + // adBonusPerAd is how many minutes one rewarded-ad view grants (免费版加时). + adBonusPerAd = 10 + // adDailyBonusCeiling caps the total ad-granted minutes per UTC day, so the + // free tier can't be extended indefinitely by ad-watching. + adDailyBonusCeiling = 120 +) + // utcToday returns the current UTC calendar date (time truncated). func utcToday() time.Time { n := nowFunc().UTC() @@ -159,8 +167,10 @@ func (svc *Service) DeviceUsage(ctx context.Context, userID int64, days int) ([] // TodaySummary is the /v1/me today_usage block. type TodaySummary struct { MinutesUsed int `json:"minutes_used"` + MinutesCap *int `json:"minutes_cap"` // 当日额度 = daily + ad_bonus; nil = unlimited MinutesRemaining *int `json:"minutes_remaining"` // nil = unlimited (pro/team) - AdUnlocked bool `json:"ad_unlocked"` + AdBonusMinutes int `json:"ad_bonus_minutes"` // 当日已通过看广告累加的分钟 + AdUnlocked bool `json:"ad_unlocked"` // 历史字段:当日曾解锁过(bonus>0) } // TodaySummary returns the current user's usage summary for today (UTC), @@ -176,34 +186,38 @@ func (svc *Service) TodaySummary(ctx context.Context, userID int64) (*TodaySumma } used := 0 - adUnlocked := false + bonus := 0 if day != nil { used = day.MinutesUsed - adUnlocked = day.AdUnlockedAt.Valid + bonus = day.AdBonusMinutes } - out := &TodaySummary{MinutesUsed: used, AdUnlocked: adUnlocked} + out := &TodaySummary{MinutesUsed: used, AdBonusMinutes: bonus, AdUnlocked: bonus > 0} if plan.DailyMinutes.Valid { - remaining := int(plan.DailyMinutes.Int64) - used + cap := int(plan.DailyMinutes.Int64) + bonus + remaining := cap - used if remaining < 0 { remaining = 0 } + out.MinutesCap = &cap out.MinutesRemaining = &remaining } return out, nil } -// UnlockAd verifies a rewarded-ad receipt and records the day's ad-unlock. +// UnlockAd verifies a rewarded-ad receipt and grants additional free minutes. // // Flow: validate inputs → verify the receipt with the ad network → consume the -// ad_token as a one-time nonce (replay-protected) → set ad_unlocked_at. A -// second unlock on a day already unlocked is idempotent (alreadyUnlocked=true). -func (svc *Service) UnlockAd(ctx context.Context, userID int64, deviceID, adToken string) (alreadyUnlocked bool, apiErr *apierr.Error) { +// ad_token as a one-time nonce (replay-protected) → add adBonusPerAd minutes to +// the day's ad bonus (capped at adDailyBonusCeiling). It returns the granted +// minutes (0 when the daily ceiling was already reached) and the new remaining +// minutes for today so the client can refresh its quota immediately. +func (svc *Service) UnlockAd(ctx context.Context, userID int64, deviceID, adToken string) (granted, remaining int, apiErr *apierr.Error) { if deviceID == "" || adToken == "" { - return false, apierr.ErrBadRequest + return 0, 0, apierr.ErrBadRequest } if svc.verifier == nil { - return false, apierr.ErrInternal + return 0, 0, apierr.ErrInternal } // 1. Authenticate the receipt with the ad network. @@ -212,22 +226,32 @@ func (svc *Service) UnlockAd(ctx context.Context, userID int64, deviceID, adToke DeviceID: deviceID, AdToken: adToken, }); err != nil { - return false, apierr.ErrAdVerifyFailed + return 0, 0, apierr.ErrAdVerifyFailed } // 2. One-time nonce: a genuine receipt may only be redeemed once. if dup, err := svc.consumeAdNonce(ctx, adToken); err != nil { - return false, apierr.ErrInternal + return 0, 0, apierr.ErrInternal } else if dup { - return false, apierr.ErrAdReplay + return 0, 0, apierr.ErrAdReplay } - // 3. Record the unlock (idempotent per UTC day). - already, err := svc.store.MarkAdUnlocked(ctx, userID, utcToday()) - if err != nil { - return false, apierr.ErrInternal + // 3. Grant the reward: add adBonusPerAd minutes, capped at the daily ceiling. + _, g, addErr := svc.store.AddAdBonusMinutes(ctx, userID, utcToday(), adBonusPerAd, adDailyBonusCeiling) + if addErr != nil { + return 0, 0, apierr.ErrInternal } - return already, nil + granted = g + + // 4. Recompute remaining for the client to refresh its quota immediately. + summary, sErr := svc.TodaySummary(ctx, userID) + if sErr != nil { + return 0, 0, sErr + } + if summary.MinutesRemaining != nil { + remaining = *summary.MinutesRemaining + } + return granted, remaining, nil } // consumeAdNonce atomically records the ad_token so it cannot be reused. diff --git a/server/internal/usage/store.go b/server/internal/usage/store.go index 8523008..6f546bb 100644 --- a/server/internal/usage/store.go +++ b/server/internal/usage/store.go @@ -29,11 +29,12 @@ type Plan struct { // minute counts plus the ad-unlock timestamp — never destinations or DNS, per // the no-log policy. type DailyUsage struct { - Date time.Time - BytesUp uint64 - BytesDown uint64 - MinutesUsed int - AdUnlockedAt sql.NullTime + Date time.Time + BytesUp uint64 + BytesDown uint64 + MinutesUsed int + AdBonusMinutes int // 当日看广告累加解锁的额外分钟(免费版加时) + AdUnlockedAt sql.NullTime } // Store wraps a *sql.DB and exposes the usage_daily / plans / users queries the @@ -86,7 +87,7 @@ func (s *Store) LookupUserIDByDPUUID(ctx context.Context, dpUUID string) (int64, // filled here; zero-filling is the handler's responsibility. func (s *Store) GetUsageRange(ctx context.Context, userID int64, from, to time.Time) ([]DailyUsage, error) { rows, err := s.db.QueryContext(ctx, - `SELECT date, bytes_up, bytes_down, minutes_used, ad_unlocked_at + `SELECT date, bytes_up, bytes_down, minutes_used, ad_bonus_minutes, ad_unlocked_at FROM usage_daily WHERE user_id = ? AND date BETWEEN ? AND ? ORDER BY date ASC`, @@ -99,7 +100,7 @@ func (s *Store) GetUsageRange(ctx context.Context, userID int64, from, to time.T var out []DailyUsage for rows.Next() { var u DailyUsage - if err := rows.Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdUnlockedAt); err != nil { + if err := rows.Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdBonusMinutes, &u.AdUnlockedAt); err != nil { return nil, fmt.Errorf("store.GetUsageRange scan: %w", err) } out = append(out, u) @@ -212,11 +213,11 @@ func (s *Store) DeviceUsageRange(ctx context.Context, userID int64, from, to tim func (s *Store) GetDay(ctx context.Context, userID int64, day time.Time) (*DailyUsage, error) { var u DailyUsage err := s.db.QueryRowContext(ctx, - `SELECT date, bytes_up, bytes_down, minutes_used, ad_unlocked_at + `SELECT date, bytes_up, bytes_down, minutes_used, ad_bonus_minutes, ad_unlocked_at FROM usage_daily WHERE user_id = ? AND date = ?`, userID, day.UTC().Format(dateLayout)). - Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdUnlockedAt) + Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdBonusMinutes, &u.AdUnlockedAt) if err == sql.ErrNoRows { return nil, nil } @@ -226,10 +227,75 @@ func (s *Store) GetDay(ctx context.Context, userID int64, day time.Time) (*Daily return &u, nil } +// AddAdBonusMinutes adds `add` minutes to usage_daily.ad_bonus_minutes for +// (userID, day), capped so the day's total bonus never exceeds `ceiling`. It +// returns the new bonus total and the minutes actually granted this call +// (granted = newBonus − previous, i.e. 0 when the ceiling was already reached). +// Runs inside a transaction with SELECT … FOR UPDATE so concurrent ad-unlocks +// accumulate correctly (免费版累加式看广告加时). +// +// This is the additive successor to MarkAdUnlocked's per-day boolean unlock: +// each rewarded ad grants +add minutes (repeatable) up to the daily ceiling. +func (s *Store) AddAdBonusMinutes(ctx context.Context, userID int64, day time.Time, add, ceiling int) (newBonus, granted int, err error) { + d := day.UTC().Format(dateLayout) + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return 0, 0, fmt.Errorf("store.AddAdBonusMinutes begin: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + + var cur int + row := tx.QueryRowContext(ctx, + `SELECT ad_bonus_minutes FROM usage_daily WHERE user_id = ? AND date = ? `+s.dialect.LockForUpdate(), + userID, d) + switch scanErr := row.Scan(&cur); scanErr { + case nil: + newBonus = cur + add + if ceiling > 0 && newBonus > ceiling { + newBonus = ceiling + } + if newBonus != cur { + if _, uErr := tx.ExecContext(ctx, + `UPDATE usage_daily SET ad_bonus_minutes = ? WHERE user_id = ? AND date = ?`, + newBonus, userID, d); uErr != nil { + return 0, 0, fmt.Errorf("store.AddAdBonusMinutes update: %w", uErr) + } + } + case sql.ErrNoRows: + cur = 0 + newBonus = add + if ceiling > 0 && newBonus > ceiling { + newBonus = ceiling + } + if _, iErr := tx.ExecContext(ctx, + `INSERT INTO usage_daily (user_id, date, ad_bonus_minutes) VALUES (?, ?, ?)`, + userID, d, newBonus); iErr != nil { + return 0, 0, fmt.Errorf("store.AddAdBonusMinutes insert: %w", iErr) + } + default: + return 0, 0, fmt.Errorf("store.AddAdBonusMinutes select: %w", scanErr) + } + + if cErr := tx.Commit(); cErr != nil { + return 0, 0, fmt.Errorf("store.AddAdBonusMinutes commit: %w", cErr) + } + committed = true + return newBonus, newBonus - cur, nil +} + // MarkAdUnlocked sets usage_daily.ad_unlocked_at for (userID, day) to now if it // is not already set. It returns alreadyUnlocked=true when the day was already // unlocked (making a second call idempotent). Runs inside a transaction with // SELECT … FOR UPDATE to be safe under concurrent unlock attempts. +// +// Deprecated: the免费版 ad model moved from a per-day boolean unlock to additive +// minutes (see AddAdBonusMinutes). Retained only for backward compatibility. func (s *Store) MarkAdUnlocked(ctx context.Context, userID int64, day time.Time) (alreadyUnlocked bool, err error) { d := day.UTC().Format(dateLayout) now := time.Now().UTC() diff --git a/server/internal/usage/usage_integration_test.go b/server/internal/usage/usage_integration_test.go index 3becc6b..c623c52 100644 --- a/server/internal/usage/usage_integration_test.go +++ b/server/internal/usage/usage_integration_test.go @@ -297,22 +297,23 @@ func TestIntAggregateUnknownDPUUIDDropped(t *testing.T) { // Quota (CheckFreeConnect) // -------------------------------------------------------------------------- -func TestIntQuotaFreeNotUnlocked(t *testing.T) { +func TestIntQuotaFreeBaseNoAd(t *testing.T) { db := setupMySQL(t) svc := usage.NewService(usage.NewStore(db), nil, fakeVerifier{ok: true}, time.Hour) uid := createUser(t, db, "dp-q1") // no subscription → free plan - _, apiErr := svc.CheckFreeConnect(context.Background(), uid) - if apiErr == nil || apiErr.Code != "AD_NOT_UNLOCKED" { - t.Fatalf("expected AD_NOT_UNLOCKED, got %v", apiErr) + // 免费版基础 10 分钟无需看广告即可连接(广告只用于加时)。 + rem, apiErr := svc.CheckFreeConnect(context.Background(), uid) + if apiErr != nil { + t.Fatalf("free base connect should succeed without ad, got %v", apiErr) } - if apiErr.MessageZH == "" || apiErr.MessageEn == "" { - t.Error("expected bilingual error messages") + if rem != 10 { + t.Errorf("remaining=%d, want 10 (base free minutes)", rem) } } -func TestIntQuotaFreeUnlockedRemainingDecrements(t *testing.T) { +func TestIntQuotaFreeAdBonusAccumulates(t *testing.T) { db := setupMySQL(t) store := usage.NewStore(db) agg := usage.NewAggregator(store, nil, time.Minute, time.Minute) @@ -320,23 +321,26 @@ func TestIntQuotaFreeUnlockedRemainingDecrements(t *testing.T) { uid := createUser(t, db, "dp-q2") - if _, err := store.MarkAdUnlocked(context.Background(), uid, time.Now().UTC()); err != nil { - t.Fatalf("unlock: %v", err) - } - + // Base free minutes (no ad). rem, apiErr := svc.CheckFreeConnect(context.Background(), uid) - if apiErr != nil { - t.Fatalf("unexpected err: %v", apiErr) - } - if rem != 10 { - t.Errorf("remaining=%d, want 10", rem) + if apiErr != nil || rem != 10 { + t.Fatalf("base: rem=%d err=%v, want 10", rem, apiErr) } - // Consume 4 minutes. + // Watch an ad → +10 bonus → allowance 20. + if _, _, err := store.AddAdBonusMinutes(context.Background(), uid, time.Now().UTC(), 10, 120); err != nil { + t.Fatalf("add bonus: %v", err) + } + rem, _ = svc.CheckFreeConnect(context.Background(), uid) + if rem != 20 { + t.Errorf("after ad: remaining=%d, want 20", rem) + } + + // Consume 4 minutes → 16. agg.ReportUsage(context.Background(), usage.Report{DPUUID: "dp-q2", Minutes: 4}) rem, _ = svc.CheckFreeConnect(context.Background(), uid) - if rem != 6 { - t.Errorf("after 4 min: remaining=%d, want 6", rem) + if rem != 16 { + t.Errorf("after 4 min: remaining=%d, want 16", rem) } } @@ -346,7 +350,7 @@ func TestIntQuotaFreeExhausted(t *testing.T) { svc := usage.NewService(store, nil, fakeVerifier{ok: true}, time.Hour) uid := createUser(t, db, "dp-q3") - store.MarkAdUnlocked(context.Background(), uid, time.Now().UTC()) + // Base 10 minutes fully consumed, no ad bonus → exhausted. store.AggregateUsage(context.Background(), uid, time.Now().UTC(), 0, 0, 10) _, apiErr := svc.CheckFreeConnect(context.Background(), uid) @@ -386,7 +390,7 @@ func TestIntAdsUnlockForgedRejected(t *testing.T) { svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: false}, time.Hour) uid := createUser(t, db, "dp-ad1") - _, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "forged-token") + _, _, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "forged-token") if apiErr == nil || apiErr.Code != "AD_VERIFY_FAILED" { t.Fatalf("expected AD_VERIFY_FAILED, got %v", apiErr) } @@ -398,33 +402,35 @@ func TestIntAdsUnlockReplayRejected(t *testing.T) { svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: true}, time.Hour) uid := createUser(t, db, "dp-ad2") - already, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz") - if apiErr != nil || already { - t.Fatalf("first unlock: already=%v err=%v", already, apiErr) + granted, _, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz") + if apiErr != nil || granted != 10 { + t.Fatalf("first unlock: granted=%d err=%v, want granted=10", granted, apiErr) } // Same token again → replay. - _, apiErr = svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz") + _, _, apiErr = svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz") if apiErr == nil || apiErr.Code != "AD_TOKEN_REPLAY" { t.Fatalf("expected AD_TOKEN_REPLAY, got %v", apiErr) } } -func TestIntAdsUnlockSecondTimeIdempotent(t *testing.T) { +func TestIntAdsUnlockAccumulates(t *testing.T) { db := setupMySQL(t) rdb := setupRedis(t) svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: true}, time.Hour) uid := createUser(t, db, "dp-ad3") - if _, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-1"); apiErr != nil { - t.Fatalf("first unlock: %v", apiErr) + // 累加式:每次不同 token 各加 10 分钟,而非幂等。 + g1, _, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-1") + if apiErr != nil || g1 != 10 { + t.Fatalf("first unlock: granted=%d err=%v, want 10", g1, apiErr) } - // Different (valid) token, same day → idempotent success. - already, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-2") - if apiErr != nil { - t.Fatalf("second unlock err: %v", apiErr) + g2, rem, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-2") + if apiErr != nil || g2 != 10 { + t.Fatalf("second unlock: granted=%d err=%v, want 10", g2, apiErr) } - if !already { - t.Error("expected already-unlocked idempotent success") + // allowance = 10 base + 20 bonus, used 0 → remaining 30. + if rem != 30 { + t.Errorf("remaining after 2 ads=%d, want 30", rem) } // Exactly one unlock timestamp. @@ -505,17 +511,22 @@ func TestIntTodaySummary(t *testing.T) { store := usage.NewStore(db) svc := usage.NewService(store, nil, nil, time.Hour) - // Free user: limited + ad flag. + // Free user: base 10 + ad bonus 10 = cap 20; used 3 → remaining 17. uFree := createUser(t, db, "dp-sf") - store.MarkAdUnlocked(context.Background(), uFree, time.Now().UTC()) + if _, _, err := store.AddAdBonusMinutes(context.Background(), uFree, time.Now().UTC(), 10, 120); err != nil { + t.Fatalf("add bonus: %v", err) + } store.AggregateUsage(context.Background(), uFree, time.Now().UTC(), 0, 0, 3) sum, apiErr := svc.TodaySummary(context.Background(), uFree) if apiErr != nil { t.Fatalf("summary: %v", apiErr) } - if sum.MinutesUsed != 3 || sum.MinutesRemaining == nil || *sum.MinutesRemaining != 7 || !sum.AdUnlocked { - t.Errorf("free summary wrong: %+v (remaining=%v)", sum, sum.MinutesRemaining) + if sum.MinutesUsed != 3 || + sum.MinutesCap == nil || *sum.MinutesCap != 20 || + sum.MinutesRemaining == nil || *sum.MinutesRemaining != 17 || + sum.AdBonusMinutes != 10 || !sum.AdUnlocked { + t.Errorf("free summary wrong: %+v (cap=%v remaining=%v)", sum, sum.MinutesCap, sum.MinutesRemaining) } // Pro user: unlimited (nil remaining). diff --git a/server/migrations/mysql/000020_ad_bonus_minutes.down.sql b/server/migrations/mysql/000020_ad_bonus_minutes.down.sql new file mode 100644 index 0000000..e659a14 --- /dev/null +++ b/server/migrations/mysql/000020_ad_bonus_minutes.down.sql @@ -0,0 +1 @@ +ALTER TABLE usage_daily DROP COLUMN ad_bonus_minutes; diff --git a/server/migrations/mysql/000020_ad_bonus_minutes.up.sql b/server/migrations/mysql/000020_ad_bonus_minutes.up.sql new file mode 100644 index 0000000..17e0257 --- /dev/null +++ b/server/migrations/mysql/000020_ad_bonus_minutes.up.sql @@ -0,0 +1,4 @@ +-- 免费版累加式看广告加时:当日通过看激励视频额外解锁的分钟数(与 minutes_used 相对)。 +-- 当日免费额度 = plans.daily_minutes + usage_daily.ad_bonus_minutes;剩余 = 额度 − minutes_used。 +-- 历史列 ad_unlocked_at(布尔式当日解锁)保留但不再用于卡控。 +ALTER TABLE usage_daily ADD COLUMN ad_bonus_minutes INT NOT NULL DEFAULT 0; diff --git a/server/migrations/sqlite/000020_ad_bonus_minutes.down.sql b/server/migrations/sqlite/000020_ad_bonus_minutes.down.sql new file mode 100644 index 0000000..e659a14 --- /dev/null +++ b/server/migrations/sqlite/000020_ad_bonus_minutes.down.sql @@ -0,0 +1 @@ +ALTER TABLE usage_daily DROP COLUMN ad_bonus_minutes; diff --git a/server/migrations/sqlite/000020_ad_bonus_minutes.up.sql b/server/migrations/sqlite/000020_ad_bonus_minutes.up.sql new file mode 100644 index 0000000..2482cab --- /dev/null +++ b/server/migrations/sqlite/000020_ad_bonus_minutes.up.sql @@ -0,0 +1,4 @@ +-- 免费版累加式看广告加时:当日通过看激励视频额外解锁的分钟数(与 minutes_used 相对)。 +-- 当日免费额度 = plans.daily_minutes + usage_daily.ad_bonus_minutes;剩余 = 额度 − minutes_used。 +-- 历史列 ad_unlocked_at(布尔式当日解锁)保留但不再用于卡控。 +ALTER TABLE usage_daily ADD COLUMN ad_bonus_minutes INTEGER NOT NULL DEFAULT 0;