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>
67 lines
1.9 KiB
Go
67 lines
1.9 KiB
Go
package usage
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/wangjia/pangolin/server/internal/apierr"
|
|
)
|
|
|
|
// defaultFreeDailyMinutes is the口径 fallback when the free plan row has a NULL
|
|
// daily_minutes (should not happen given the seed, but we stay safe).
|
|
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.
|
|
//
|
|
// 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).
|
|
func (svc *Service) CheckFreeConnect(ctx context.Context, userID int64) (remainingMinutes int, apiErr *apierr.Error) {
|
|
plan, err := svc.store.EffectivePlan(ctx, userID)
|
|
if err != nil {
|
|
return 0, apierr.ErrInternal
|
|
}
|
|
|
|
limit := defaultFreeDailyMinutes
|
|
if plan.DailyMinutes.Valid {
|
|
limit = int(plan.DailyMinutes.Int64)
|
|
}
|
|
|
|
// Paid plans (no ad gate).
|
|
if !plan.AdGate {
|
|
if !plan.DailyMinutes.Valid {
|
|
return Unlimited, nil
|
|
}
|
|
day, err := svc.store.GetDay(ctx, userID, utcToday())
|
|
if err != nil {
|
|
return 0, apierr.ErrInternal
|
|
}
|
|
remaining := limit
|
|
if day != nil {
|
|
remaining = limit - day.MinutesUsed
|
|
}
|
|
if remaining < 0 {
|
|
remaining = 0
|
|
}
|
|
return remaining, nil
|
|
}
|
|
|
|
// Free plan: require ad unlock + remaining minutes.
|
|
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
|
|
}
|
|
if day.MinutesUsed >= limit {
|
|
return 0, apierr.ErrQuotaExhausted
|
|
}
|
|
return limit - day.MinutesUsed, nil
|
|
}
|