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>
This commit is contained in:
+28
-6
@@ -294,10 +294,11 @@ paths:
|
|||||||
/ads/unlock:
|
/ads/unlock:
|
||||||
post:
|
post:
|
||||||
operationId: adsUnlock
|
operationId: adsUnlock
|
||||||
summary: 激励视频广告解锁当日时长
|
summary: 激励视频广告加时(累加式)
|
||||||
description: |
|
description: |
|
||||||
免费版用户完成激励视频广告后调用。服务端向广告平台(AdMob/Unity)校验 ad_token 真伪,
|
免费版用户完成激励视频广告后调用。服务端向广告平台(AdMob/Unity)校验 ad_token 真伪,
|
||||||
通过后记录 `usage_daily.ad_unlocked_at`,当日 connect 接口方可放行。
|
通过后向 `usage_daily.ad_bonus_minutes` 累加固定分钟(每日封顶),当日免费额度随之上浮。
|
||||||
|
额度为**全账户共享**(非每设备)。响应返回本次实际加时与最新剩余分钟。
|
||||||
tags: [Commerce]
|
tags: [Commerce]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -315,8 +316,20 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
description: 广告 SDK 签发的服务端回执 token
|
description: 广告 SDK 签发的服务端回执 token
|
||||||
responses:
|
responses:
|
||||||
"204":
|
"200":
|
||||||
description: 广告解锁成功(无响应体)
|
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":
|
"400":
|
||||||
$ref: "#/components/responses/BadRequest"
|
$ref: "#/components/responses/BadRequest"
|
||||||
"401":
|
"401":
|
||||||
@@ -715,14 +728,23 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
description: 今日已使用分钟数
|
description: 今日已使用分钟数
|
||||||
example: 3
|
example: 3
|
||||||
|
minutes_cap:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
description: 今日额度 = 套餐每日分钟 + 看广告累加分钟;付费无限制时为 null
|
||||||
|
example: 20
|
||||||
minutes_remaining:
|
minutes_remaining:
|
||||||
type: integer
|
type: integer
|
||||||
nullable: true
|
nullable: true
|
||||||
description: 今日剩余分钟数,付费无限制时为 null
|
description: 今日剩余分钟数(全账户共享),付费无限制时为 null
|
||||||
example: 7
|
example: 7
|
||||||
|
ad_bonus_minutes:
|
||||||
|
type: integer
|
||||||
|
description: 今日已通过看广告累加的分钟数
|
||||||
|
example: 10
|
||||||
ad_unlocked:
|
ad_unlocked:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: 免费版今日是否已完成激励广告解锁
|
description: 免费版今日是否已通过看广告加过时(ad_bonus_minutes > 0,历史字段)
|
||||||
example: false
|
example: false
|
||||||
|
|
||||||
Device:
|
Device:
|
||||||
|
|||||||
@@ -301,7 +301,23 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
|||||||
|
|
||||||
// ── Usage ─────────────────────────────────────────────────────────────────
|
// ── Usage ─────────────────────────────────────────────────────────────────
|
||||||
usageStore := usage.NewStore(sqlDB)
|
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)
|
usageHandler := usage.NewUsageHandler(usageSvc)
|
||||||
deviceUsageHandler := usage.NewDeviceUsageHandler(usageSvc)
|
deviceUsageHandler := usage.NewDeviceUsageHandler(usageSvc)
|
||||||
adsHandler := usage.NewAdsUnlockHandler(usageSvc)
|
adsHandler := usage.NewAdsUnlockHandler(usageSvc)
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ type meResponse struct {
|
|||||||
// Web user-center fields.
|
// Web user-center fields.
|
||||||
DevicesUsed int `json:"devices_used"`
|
DevicesUsed int `json:"devices_used"`
|
||||||
DevicesMax int `json:"devices_max"`
|
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"`
|
DataTodayGB float64 `json:"data_today_gb"`
|
||||||
WeeklyGB []float64 `json:"weekly_gb"` // last 7 days, oldest→newest
|
WeeklyGB []float64 `json:"weekly_gb"` // last 7 days, oldest→newest
|
||||||
TOTPEnabled bool `json:"totp_enabled"`
|
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)。
|
// 日期在 Go 端算好传 ?,不用 MySQL 专属 UTC_DATE()(否则 SQLite 报错→恒 0)。
|
||||||
var todayBytes uint64
|
var todayBytes uint64
|
||||||
var todayMinutes int
|
var todayMinutes int
|
||||||
|
var todayAdBonus int
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
_ = a.db.QueryRowContext(ctx, `
|
_ = 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 = ?
|
FROM usage_daily WHERE user_id = ? AND date = ?
|
||||||
`, uid, today).Scan(&todayBytes, &todayMinutes)
|
`, uid, today).Scan(&todayBytes, &todayMinutes, &todayAdBonus)
|
||||||
|
|
||||||
resp := meResponse{
|
resp := meResponse{
|
||||||
UUID: uuid,
|
UUID: uuid,
|
||||||
@@ -121,11 +123,14 @@ func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
resp.ExpiresAt = &s
|
resp.ExpiresAt = &s
|
||||||
}
|
}
|
||||||
// quota_today_min: null when the plan is unlimited, else remaining minutes.
|
// quota_today_min: null when the plan is unlimited, else remaining minutes.
|
||||||
|
// 额度 = plan.daily_minutes + 当日看广告累加的 ad_bonus_minutes(账户共享)。
|
||||||
if dailyMinutes.Valid {
|
if dailyMinutes.Valid {
|
||||||
rem := int(dailyMinutes.Int64) - todayMinutes
|
cap := int(dailyMinutes.Int64) + todayAdBonus
|
||||||
|
rem := cap - todayMinutes
|
||||||
if rem < 0 {
|
if rem < 0 {
|
||||||
rem = 0
|
rem = 0
|
||||||
}
|
}
|
||||||
|
resp.QuotaCapMin = &cap
|
||||||
resp.QuotaTodayMin = &rem
|
resp.QuotaTodayMin = &rem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,6 @@ func TestContractMeResponse(t *testing.T) {
|
|||||||
assertFrozenKeys(t, "/v1/me", jsonTagSet(meResponse{}),
|
assertFrozenKeys(t, "/v1/me", jsonTagSet(meResponse{}),
|
||||||
"uuid", "email", "dp_uuid", "plan", "expires_at",
|
"uuid", "email", "dp_uuid", "plan", "expires_at",
|
||||||
"devices_used", "devices_max",
|
"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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,16 +206,33 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 2. Determine TTL from plan.
|
// 2. Determine TTL from plan.
|
||||||
var ttl time.Duration
|
var ttl time.Duration
|
||||||
if ent.AdGate {
|
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)
|
dm := int64(10)
|
||||||
if ent.DailyMinutes.Valid {
|
if ent.DailyMinutes.Valid {
|
||||||
dm = ent.DailyMinutes.Int64
|
dm = ent.DailyMinutes.Int64
|
||||||
}
|
}
|
||||||
if dm <= 0 {
|
used, bonus, qerr := a.store.AccountDayMinutes(r.Context(), uid, time.Now().UTC())
|
||||||
apierr.WriteJSON(w, http.StatusForbidden, apierr.ErrQuotaExhausted)
|
if qerr != nil {
|
||||||
|
slog.Error("connect: account day minutes failed", "user", uid, "err", qerr)
|
||||||
|
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||||
return
|
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 {
|
} else {
|
||||||
ttl = paidCredentialTTL
|
ttl = paidCredentialTTL
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ func (m *mockNodeStore) AccountDayBytes(_ context.Context, _ int64, _ time.Time)
|
|||||||
return 0, nil
|
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) {
|
func (m *mockNodeStore) CountActiveDevices(_ context.Context, _ int64, _ time.Time) (int, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ type NodeStore interface {
|
|||||||
// date — the basis for the GB 综合配额 connect gate. 0 when no usage yet.
|
// date — the basis for the GB 综合配额 connect gate. 0 when no usage yet.
|
||||||
AccountDayBytes(ctx context.Context, userID int64, date time.Time) (int64, error)
|
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
|
// CountActiveDevices counts the user's devices seen since cutoff (active). Used
|
||||||
// by the connect device-limit backstop; stale rows are excluded.
|
// by the connect device-limit backstop; stale rows are excluded.
|
||||||
CountActiveDevices(ctx context.Context, userID int64, cutoff time.Time) (int, error)
|
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
|
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
|
// CountActiveDevices counts the user's devices seen within the active window
|
||||||
// (last_seen > cutoff). Mirrors devices.Store.CountActiveDevices for the connect
|
// (last_seen > cutoff). Mirrors devices.Store.CountActiveDevices for the connect
|
||||||
// backstop; stale/never-seen rows are excluded.
|
// backstop; stale/never-seen rows are excluded.
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) {
|
|||||||
if dirty {
|
if dirty {
|
||||||
t.Fatalf("schema dirty after MigrateUp")
|
t.Fatalf("schema dirty after MigrateUp")
|
||||||
}
|
}
|
||||||
if v != 19 {
|
if v != 20 {
|
||||||
t.Errorf("version = %d, want 19", v)
|
t.Errorf("version = %d, want 20", v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Core tables exist.
|
// 2. Core tables exist.
|
||||||
|
|||||||
@@ -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 是「近实时远程下线」的服务端判据:有非吊销会话=在线,
|
// HasActiveSession 是「近实时远程下线」的服务端判据:有非吊销会话=在线,
|
||||||
// 强制退出(RevokeByDevice)后=离线 → 客户端轮询到即登出。
|
// 强制退出(RevokeByDevice)后=离线 → 客户端轮询到即登出。
|
||||||
func TestSQLite_SessionHasActiveSession(t *testing.T) {
|
func TestSQLite_SessionHasActiveSession(t *testing.T) {
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ type AdVerifier interface {
|
|||||||
Provider() string
|
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)
|
// AdMob Server-Side Verification (SSV)
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -144,9 +144,16 @@ type adsUnlockRequest struct {
|
|||||||
AdToken string `json:"ad_token"`
|
AdToken string `json:"ad_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeHTTP implements http.Handler. On success it returns 204 No Content
|
// adsUnlockResponse reports the minutes granted by this rewarded-ad view and
|
||||||
// (matching the OpenAPI contract), including the idempotent already-unlocked
|
// the account's new remaining minutes for today, so the client can refresh its
|
||||||
// case.
|
// 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) {
|
func (h *AdsUnlockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -168,13 +175,15 @@ func (h *AdsUnlockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if apiErr != nil {
|
||||||
apierr.WriteJSON(w, adsErrorStatus(apiErr.Code), apiErr)
|
apierr.WriteJSON(w, adsErrorStatus(apiErr.Code), apiErr)
|
||||||
return
|
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.
|
// adsErrorStatus maps an ads-unlock error code to an HTTP status.
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ import (
|
|||||||
const defaultFreeDailyMinutes = 10
|
const defaultFreeDailyMinutes = 10
|
||||||
|
|
||||||
// CheckFreeConnect enforces the free-plan connect gate and returns the user's
|
// 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
|
// remaining minutes for today (UTC). The quota is account-wide (shared across
|
||||||
// derive the free credential's TTL.
|
// all devices).
|
||||||
//
|
//
|
||||||
// Rules:
|
// Rules (免费版累加式看广告加时):
|
||||||
// - plan.ad_gate == false (pro/team): not minute-gated. Returns Unlimited
|
// - plan.ad_gate == false (pro/team): not minute-gated. Returns Unlimited
|
||||||
// when daily_minutes is NULL, otherwise the remaining minutes for the day.
|
// 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
|
// - plan.ad_gate == true (free): the day's allowance = daily_minutes (free=10)
|
||||||
// minutes_used must be below daily_minutes (free = 10). Returns the
|
// + ad_bonus_minutes (accumulated by watching rewarded ads). No ad is
|
||||||
// remaining minutes; otherwise a bilingual semantic error
|
// required to connect at all — the base 10 minutes are always available.
|
||||||
// (AD_NOT_UNLOCKED / QUOTA_EXHAUSTED).
|
// Returns allowance − minutes_used; QUOTA_EXHAUSTED when that reaches 0.
|
||||||
func (svc *Service) CheckFreeConnect(ctx context.Context, userID int64) (remainingMinutes int, apiErr *apierr.Error) {
|
func (svc *Service) CheckFreeConnect(ctx context.Context, userID int64) (remainingMinutes int, apiErr *apierr.Error) {
|
||||||
plan, err := svc.store.EffectivePlan(ctx, userID)
|
plan, err := svc.store.EffectivePlan(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,16 +51,19 @@ func (svc *Service) CheckFreeConnect(ctx context.Context, userID int64) (remaini
|
|||||||
return remaining, nil
|
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())
|
day, err := svc.store.GetDay(ctx, userID, utcToday())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, apierr.ErrInternal
|
return 0, apierr.ErrInternal
|
||||||
}
|
}
|
||||||
if day == nil || !day.AdUnlockedAt.Valid {
|
allowance := limit
|
||||||
return 0, apierr.ErrAdNotUnlocked
|
used := 0
|
||||||
|
if day != nil {
|
||||||
|
allowance = limit + day.AdBonusMinutes
|
||||||
|
used = day.MinutesUsed
|
||||||
}
|
}
|
||||||
if day.MinutesUsed >= limit {
|
if used >= allowance {
|
||||||
return 0, apierr.ErrQuotaExhausted
|
return 0, apierr.ErrQuotaExhausted
|
||||||
}
|
}
|
||||||
return limit - day.MinutesUsed, nil
|
return allowance - used, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ import (
|
|||||||
// nowFunc is overridable in tests to pin "today".
|
// nowFunc is overridable in tests to pin "today".
|
||||||
var nowFunc = time.Now
|
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).
|
// utcToday returns the current UTC calendar date (time truncated).
|
||||||
func utcToday() time.Time {
|
func utcToday() time.Time {
|
||||||
n := nowFunc().UTC()
|
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.
|
// TodaySummary is the /v1/me today_usage block.
|
||||||
type TodaySummary struct {
|
type TodaySummary struct {
|
||||||
MinutesUsed int `json:"minutes_used"`
|
MinutesUsed int `json:"minutes_used"`
|
||||||
|
MinutesCap *int `json:"minutes_cap"` // 当日额度 = daily + ad_bonus; nil = unlimited
|
||||||
MinutesRemaining *int `json:"minutes_remaining"` // nil = unlimited (pro/team)
|
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),
|
// 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
|
used := 0
|
||||||
adUnlocked := false
|
bonus := 0
|
||||||
if day != nil {
|
if day != nil {
|
||||||
used = day.MinutesUsed
|
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 {
|
if plan.DailyMinutes.Valid {
|
||||||
remaining := int(plan.DailyMinutes.Int64) - used
|
cap := int(plan.DailyMinutes.Int64) + bonus
|
||||||
|
remaining := cap - used
|
||||||
if remaining < 0 {
|
if remaining < 0 {
|
||||||
remaining = 0
|
remaining = 0
|
||||||
}
|
}
|
||||||
|
out.MinutesCap = &cap
|
||||||
out.MinutesRemaining = &remaining
|
out.MinutesRemaining = &remaining
|
||||||
}
|
}
|
||||||
return out, nil
|
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
|
// 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
|
// ad_token as a one-time nonce (replay-protected) → add adBonusPerAd minutes to
|
||||||
// second unlock on a day already unlocked is idempotent (alreadyUnlocked=true).
|
// the day's ad bonus (capped at adDailyBonusCeiling). It returns the granted
|
||||||
func (svc *Service) UnlockAd(ctx context.Context, userID int64, deviceID, adToken string) (alreadyUnlocked bool, apiErr *apierr.Error) {
|
// 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 == "" {
|
if deviceID == "" || adToken == "" {
|
||||||
return false, apierr.ErrBadRequest
|
return 0, 0, apierr.ErrBadRequest
|
||||||
}
|
}
|
||||||
if svc.verifier == nil {
|
if svc.verifier == nil {
|
||||||
return false, apierr.ErrInternal
|
return 0, 0, apierr.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Authenticate the receipt with the ad network.
|
// 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,
|
DeviceID: deviceID,
|
||||||
AdToken: adToken,
|
AdToken: adToken,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return false, apierr.ErrAdVerifyFailed
|
return 0, 0, apierr.ErrAdVerifyFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. One-time nonce: a genuine receipt may only be redeemed once.
|
// 2. One-time nonce: a genuine receipt may only be redeemed once.
|
||||||
if dup, err := svc.consumeAdNonce(ctx, adToken); err != nil {
|
if dup, err := svc.consumeAdNonce(ctx, adToken); err != nil {
|
||||||
return false, apierr.ErrInternal
|
return 0, 0, apierr.ErrInternal
|
||||||
} else if dup {
|
} else if dup {
|
||||||
return false, apierr.ErrAdReplay
|
return 0, 0, apierr.ErrAdReplay
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Record the unlock (idempotent per UTC day).
|
// 3. Grant the reward: add adBonusPerAd minutes, capped at the daily ceiling.
|
||||||
already, err := svc.store.MarkAdUnlocked(ctx, userID, utcToday())
|
_, g, addErr := svc.store.AddAdBonusMinutes(ctx, userID, utcToday(), adBonusPerAd, adDailyBonusCeiling)
|
||||||
if err != nil {
|
if addErr != nil {
|
||||||
return false, apierr.ErrInternal
|
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.
|
// consumeAdNonce atomically records the ad_token so it cannot be reused.
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ type Plan struct {
|
|||||||
// minute counts plus the ad-unlock timestamp — never destinations or DNS, per
|
// minute counts plus the ad-unlock timestamp — never destinations or DNS, per
|
||||||
// the no-log policy.
|
// the no-log policy.
|
||||||
type DailyUsage struct {
|
type DailyUsage struct {
|
||||||
Date time.Time
|
Date time.Time
|
||||||
BytesUp uint64
|
BytesUp uint64
|
||||||
BytesDown uint64
|
BytesDown uint64
|
||||||
MinutesUsed int
|
MinutesUsed int
|
||||||
AdUnlockedAt sql.NullTime
|
AdBonusMinutes int // 当日看广告累加解锁的额外分钟(免费版加时)
|
||||||
|
AdUnlockedAt sql.NullTime
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store wraps a *sql.DB and exposes the usage_daily / plans / users queries the
|
// 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.
|
// filled here; zero-filling is the handler's responsibility.
|
||||||
func (s *Store) GetUsageRange(ctx context.Context, userID int64, from, to time.Time) ([]DailyUsage, error) {
|
func (s *Store) GetUsageRange(ctx context.Context, userID int64, from, to time.Time) ([]DailyUsage, error) {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
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
|
FROM usage_daily
|
||||||
WHERE user_id = ? AND date BETWEEN ? AND ?
|
WHERE user_id = ? AND date BETWEEN ? AND ?
|
||||||
ORDER BY date ASC`,
|
ORDER BY date ASC`,
|
||||||
@@ -99,7 +100,7 @@ func (s *Store) GetUsageRange(ctx context.Context, userID int64, from, to time.T
|
|||||||
var out []DailyUsage
|
var out []DailyUsage
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u DailyUsage
|
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)
|
return nil, fmt.Errorf("store.GetUsageRange scan: %w", err)
|
||||||
}
|
}
|
||||||
out = append(out, u)
|
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) {
|
func (s *Store) GetDay(ctx context.Context, userID int64, day time.Time) (*DailyUsage, error) {
|
||||||
var u DailyUsage
|
var u DailyUsage
|
||||||
err := s.db.QueryRowContext(ctx,
|
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
|
FROM usage_daily
|
||||||
WHERE user_id = ? AND date = ?`,
|
WHERE user_id = ? AND date = ?`,
|
||||||
userID, day.UTC().Format(dateLayout)).
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -226,10 +227,75 @@ func (s *Store) GetDay(ctx context.Context, userID int64, day time.Time) (*Daily
|
|||||||
return &u, nil
|
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
|
// 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
|
// is not already set. It returns alreadyUnlocked=true when the day was already
|
||||||
// unlocked (making a second call idempotent). Runs inside a transaction with
|
// unlocked (making a second call idempotent). Runs inside a transaction with
|
||||||
// SELECT … FOR UPDATE to be safe under concurrent unlock attempts.
|
// 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) {
|
func (s *Store) MarkAdUnlocked(ctx context.Context, userID int64, day time.Time) (alreadyUnlocked bool, err error) {
|
||||||
d := day.UTC().Format(dateLayout)
|
d := day.UTC().Format(dateLayout)
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|||||||
@@ -297,22 +297,23 @@ func TestIntAggregateUnknownDPUUIDDropped(t *testing.T) {
|
|||||||
// Quota (CheckFreeConnect)
|
// Quota (CheckFreeConnect)
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestIntQuotaFreeNotUnlocked(t *testing.T) {
|
func TestIntQuotaFreeBaseNoAd(t *testing.T) {
|
||||||
db := setupMySQL(t)
|
db := setupMySQL(t)
|
||||||
svc := usage.NewService(usage.NewStore(db), nil, fakeVerifier{ok: true}, time.Hour)
|
svc := usage.NewService(usage.NewStore(db), nil, fakeVerifier{ok: true}, time.Hour)
|
||||||
|
|
||||||
uid := createUser(t, db, "dp-q1") // no subscription → free plan
|
uid := createUser(t, db, "dp-q1") // no subscription → free plan
|
||||||
|
|
||||||
_, apiErr := svc.CheckFreeConnect(context.Background(), uid)
|
// 免费版基础 10 分钟无需看广告即可连接(广告只用于加时)。
|
||||||
if apiErr == nil || apiErr.Code != "AD_NOT_UNLOCKED" {
|
rem, apiErr := svc.CheckFreeConnect(context.Background(), uid)
|
||||||
t.Fatalf("expected AD_NOT_UNLOCKED, got %v", apiErr)
|
if apiErr != nil {
|
||||||
|
t.Fatalf("free base connect should succeed without ad, got %v", apiErr)
|
||||||
}
|
}
|
||||||
if apiErr.MessageZH == "" || apiErr.MessageEn == "" {
|
if rem != 10 {
|
||||||
t.Error("expected bilingual error messages")
|
t.Errorf("remaining=%d, want 10 (base free minutes)", rem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIntQuotaFreeUnlockedRemainingDecrements(t *testing.T) {
|
func TestIntQuotaFreeAdBonusAccumulates(t *testing.T) {
|
||||||
db := setupMySQL(t)
|
db := setupMySQL(t)
|
||||||
store := usage.NewStore(db)
|
store := usage.NewStore(db)
|
||||||
agg := usage.NewAggregator(store, nil, time.Minute, time.Minute)
|
agg := usage.NewAggregator(store, nil, time.Minute, time.Minute)
|
||||||
@@ -320,23 +321,26 @@ func TestIntQuotaFreeUnlockedRemainingDecrements(t *testing.T) {
|
|||||||
|
|
||||||
uid := createUser(t, db, "dp-q2")
|
uid := createUser(t, db, "dp-q2")
|
||||||
|
|
||||||
if _, err := store.MarkAdUnlocked(context.Background(), uid, time.Now().UTC()); err != nil {
|
// Base free minutes (no ad).
|
||||||
t.Fatalf("unlock: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rem, apiErr := svc.CheckFreeConnect(context.Background(), uid)
|
rem, apiErr := svc.CheckFreeConnect(context.Background(), uid)
|
||||||
if apiErr != nil {
|
if apiErr != nil || rem != 10 {
|
||||||
t.Fatalf("unexpected err: %v", apiErr)
|
t.Fatalf("base: rem=%d err=%v, want 10", rem, apiErr)
|
||||||
}
|
|
||||||
if rem != 10 {
|
|
||||||
t.Errorf("remaining=%d, want 10", rem)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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})
|
agg.ReportUsage(context.Background(), usage.Report{DPUUID: "dp-q2", Minutes: 4})
|
||||||
rem, _ = svc.CheckFreeConnect(context.Background(), uid)
|
rem, _ = svc.CheckFreeConnect(context.Background(), uid)
|
||||||
if rem != 6 {
|
if rem != 16 {
|
||||||
t.Errorf("after 4 min: remaining=%d, want 6", rem)
|
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)
|
svc := usage.NewService(store, nil, fakeVerifier{ok: true}, time.Hour)
|
||||||
|
|
||||||
uid := createUser(t, db, "dp-q3")
|
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)
|
store.AggregateUsage(context.Background(), uid, time.Now().UTC(), 0, 0, 10)
|
||||||
|
|
||||||
_, apiErr := svc.CheckFreeConnect(context.Background(), uid)
|
_, 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)
|
svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: false}, time.Hour)
|
||||||
|
|
||||||
uid := createUser(t, db, "dp-ad1")
|
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" {
|
if apiErr == nil || apiErr.Code != "AD_VERIFY_FAILED" {
|
||||||
t.Fatalf("expected AD_VERIFY_FAILED, got %v", apiErr)
|
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)
|
svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: true}, time.Hour)
|
||||||
|
|
||||||
uid := createUser(t, db, "dp-ad2")
|
uid := createUser(t, db, "dp-ad2")
|
||||||
already, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz")
|
granted, _, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz")
|
||||||
if apiErr != nil || already {
|
if apiErr != nil || granted != 10 {
|
||||||
t.Fatalf("first unlock: already=%v err=%v", already, apiErr)
|
t.Fatalf("first unlock: granted=%d err=%v, want granted=10", granted, apiErr)
|
||||||
}
|
}
|
||||||
// Same token again → replay.
|
// 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" {
|
if apiErr == nil || apiErr.Code != "AD_TOKEN_REPLAY" {
|
||||||
t.Fatalf("expected AD_TOKEN_REPLAY, got %v", apiErr)
|
t.Fatalf("expected AD_TOKEN_REPLAY, got %v", apiErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIntAdsUnlockSecondTimeIdempotent(t *testing.T) {
|
func TestIntAdsUnlockAccumulates(t *testing.T) {
|
||||||
db := setupMySQL(t)
|
db := setupMySQL(t)
|
||||||
rdb := setupRedis(t)
|
rdb := setupRedis(t)
|
||||||
svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: true}, time.Hour)
|
svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: true}, time.Hour)
|
||||||
|
|
||||||
uid := createUser(t, db, "dp-ad3")
|
uid := createUser(t, db, "dp-ad3")
|
||||||
if _, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-1"); apiErr != nil {
|
// 累加式:每次不同 token 各加 10 分钟,而非幂等。
|
||||||
t.Fatalf("first unlock: %v", apiErr)
|
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.
|
g2, rem, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-2")
|
||||||
already, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-2")
|
if apiErr != nil || g2 != 10 {
|
||||||
if apiErr != nil {
|
t.Fatalf("second unlock: granted=%d err=%v, want 10", g2, apiErr)
|
||||||
t.Fatalf("second unlock err: %v", apiErr)
|
|
||||||
}
|
}
|
||||||
if !already {
|
// allowance = 10 base + 20 bonus, used 0 → remaining 30.
|
||||||
t.Error("expected already-unlocked idempotent success")
|
if rem != 30 {
|
||||||
|
t.Errorf("remaining after 2 ads=%d, want 30", rem)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exactly one unlock timestamp.
|
// Exactly one unlock timestamp.
|
||||||
@@ -505,17 +511,22 @@ func TestIntTodaySummary(t *testing.T) {
|
|||||||
store := usage.NewStore(db)
|
store := usage.NewStore(db)
|
||||||
svc := usage.NewService(store, nil, nil, time.Hour)
|
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")
|
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)
|
store.AggregateUsage(context.Background(), uFree, time.Now().UTC(), 0, 0, 3)
|
||||||
|
|
||||||
sum, apiErr := svc.TodaySummary(context.Background(), uFree)
|
sum, apiErr := svc.TodaySummary(context.Background(), uFree)
|
||||||
if apiErr != nil {
|
if apiErr != nil {
|
||||||
t.Fatalf("summary: %v", apiErr)
|
t.Fatalf("summary: %v", apiErr)
|
||||||
}
|
}
|
||||||
if sum.MinutesUsed != 3 || sum.MinutesRemaining == nil || *sum.MinutesRemaining != 7 || !sum.AdUnlocked {
|
if sum.MinutesUsed != 3 ||
|
||||||
t.Errorf("free summary wrong: %+v (remaining=%v)", sum, sum.MinutesRemaining)
|
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).
|
// Pro user: unlimited (nil remaining).
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE usage_daily DROP COLUMN ad_bonus_minutes;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE usage_daily DROP COLUMN ad_bonus_minutes;
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user