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>
209 lines
7.1 KiB
Go
209 lines
7.1 KiB
Go
package usage
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Unlimited is the sentinel returned by CheckFreeConnect for plans with no
|
|
// daily-minute cap (pro / team). Callers (e.g. the connect endpoint) treat it
|
|
// as "no minute limit; use the default paid credential TTL".
|
|
const Unlimited = -1
|
|
|
|
// dateLayout is the canonical UTC day format used as the usage_daily.date key
|
|
// and in the API response.
|
|
const dateLayout = "2006-01-02"
|
|
|
|
// Plan is the minimal subset of the plans row needed for quota decisions.
|
|
type Plan struct {
|
|
Code string // free | pro | team
|
|
DailyMinutes sql.NullInt64 // free=10; NULL = unlimited
|
|
AdGate bool // free=true: daily rewarded-ad unlock required
|
|
}
|
|
|
|
// DailyUsage mirrors a usage_daily row. It carries only aggregate byte and
|
|
// 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
|
|
}
|
|
|
|
// Store wraps a *sql.DB and exposes the usage_daily / plans / users queries the
|
|
// usage package needs.
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewStore creates a Store backed by the given MySQL connection pool.
|
|
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
|
|
|
// AggregateUsage accumulates one usage delta into usage_daily for (userID, day)
|
|
// using INSERT … ON DUPLICATE KEY UPDATE so concurrent reports from multiple
|
|
// nodes add up correctly. day is truncated to its UTC calendar date.
|
|
func (s *Store) AggregateUsage(ctx context.Context, userID int64, day time.Time, bytesUp, bytesDown uint64, minutes int) error {
|
|
d := day.UTC().Format(dateLayout)
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO usage_daily (user_id, date, bytes_up, bytes_down, minutes_used)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
bytes_up = bytes_up + VALUES(bytes_up),
|
|
bytes_down = bytes_down + VALUES(bytes_down),
|
|
minutes_used = minutes_used + VALUES(minutes_used)`,
|
|
userID, d, bytesUp, bytesDown, minutes)
|
|
if err != nil {
|
|
return fmt.Errorf("store.AggregateUsage: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LookupUserIDByDPUUID resolves a data-plane credential UUID to the owning
|
|
// user_id. Returns (0, nil) when no user matches (e.g. a rotated/stale dp_uuid)
|
|
// so the caller can decide to silently drop the report.
|
|
func (s *Store) LookupUserIDByDPUUID(ctx context.Context, dpUUID string) (int64, error) {
|
|
var id int64
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT id FROM users WHERE dp_uuid = ? LIMIT 1`, dpUUID).Scan(&id)
|
|
if err == sql.ErrNoRows {
|
|
return 0, nil
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("store.LookupUserIDByDPUUID: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// GetUsageRange returns the usage_daily rows for userID with date in
|
|
// [from, to] (inclusive, UTC dates), ordered ascending. Missing days are NOT
|
|
// 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
|
|
FROM usage_daily
|
|
WHERE user_id = ? AND date BETWEEN ? AND ?
|
|
ORDER BY date ASC`,
|
|
userID, from.UTC().Format(dateLayout), to.UTC().Format(dateLayout))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store.GetUsageRange: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
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 {
|
|
return nil, fmt.Errorf("store.GetUsageRange scan: %w", err)
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GetDay returns the usage_daily row for (userID, day), or nil if none exists.
|
|
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
|
|
FROM usage_daily
|
|
WHERE user_id = ? AND date = ?`,
|
|
userID, day.UTC().Format(dateLayout)).
|
|
Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdUnlockedAt)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store.GetDay: %w", err)
|
|
}
|
|
return &u, 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.
|
|
func (s *Store) MarkAdUnlocked(ctx context.Context, userID int64, day time.Time) (alreadyUnlocked bool, err error) {
|
|
d := day.UTC().Format(dateLayout)
|
|
|
|
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
|
if err != nil {
|
|
return false, fmt.Errorf("store.MarkAdUnlocked begin: %w", err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
var unlockedAt sql.NullTime
|
|
row := tx.QueryRowContext(ctx,
|
|
`SELECT ad_unlocked_at FROM usage_daily WHERE user_id = ? AND date = ? FOR UPDATE`,
|
|
userID, d)
|
|
switch err := row.Scan(&unlockedAt); err {
|
|
case nil:
|
|
if unlockedAt.Valid {
|
|
// Already unlocked today → idempotent success.
|
|
if cErr := tx.Commit(); cErr != nil {
|
|
return false, fmt.Errorf("store.MarkAdUnlocked commit: %w", cErr)
|
|
}
|
|
committed = true
|
|
return true, nil
|
|
}
|
|
if _, uErr := tx.ExecContext(ctx,
|
|
`UPDATE usage_daily SET ad_unlocked_at = UTC_TIMESTAMP(6)
|
|
WHERE user_id = ? AND date = ? AND ad_unlocked_at IS NULL`,
|
|
userID, d); uErr != nil {
|
|
return false, fmt.Errorf("store.MarkAdUnlocked update: %w", uErr)
|
|
}
|
|
case sql.ErrNoRows:
|
|
if _, iErr := tx.ExecContext(ctx,
|
|
`INSERT INTO usage_daily (user_id, date, ad_unlocked_at)
|
|
VALUES (?, ?, UTC_TIMESTAMP(6))`,
|
|
userID, d); iErr != nil {
|
|
return false, fmt.Errorf("store.MarkAdUnlocked insert: %w", iErr)
|
|
}
|
|
default:
|
|
return false, fmt.Errorf("store.MarkAdUnlocked select: %w", err)
|
|
}
|
|
|
|
if cErr := tx.Commit(); cErr != nil {
|
|
return false, fmt.Errorf("store.MarkAdUnlocked commit: %w", cErr)
|
|
}
|
|
committed = true
|
|
return false, nil
|
|
}
|
|
|
|
// EffectivePlan returns the plan that currently governs userID: the highest
|
|
// tier among the user's non-expired subscriptions, falling back to the free
|
|
// plan when the user has no active subscription (trial-expired free state).
|
|
func (s *Store) EffectivePlan(ctx context.Context, userID int64) (*Plan, error) {
|
|
var p Plan
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT p.code, p.daily_minutes, p.ad_gate
|
|
FROM subscriptions s
|
|
JOIN plans p ON p.id = s.plan_id
|
|
WHERE s.user_id = ? AND s.expires_at > UTC_TIMESTAMP(6)
|
|
ORDER BY FIELD(p.code, 'team', 'pro', 'free'), s.expires_at DESC
|
|
LIMIT 1`,
|
|
userID).Scan(&p.Code, &p.DailyMinutes, &p.AdGate)
|
|
if err == nil {
|
|
return &p, nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return nil, fmt.Errorf("store.EffectivePlan: %w", err)
|
|
}
|
|
|
|
// No active subscription → free plan.
|
|
if err := s.db.QueryRowContext(ctx,
|
|
`SELECT code, daily_minutes, ad_gate FROM plans WHERE code = 'free'`).
|
|
Scan(&p.Code, &p.DailyMinutes, &p.AdGate); err != nil {
|
|
return nil, fmt.Errorf("store.EffectivePlan free fallback: %w", err)
|
|
}
|
|
return &p, nil
|
|
}
|