package quota import ( "context" "log/slog" "time" "dudu/server/internal/store" ) // InvalidateBalance 删除 Redis 余额键 → 下次读取从 DB 冗余列懒加载(购买入账后调用)。 func (m *Manager) InvalidateBalance(ctx context.Context, uid string) error { return m.rdb.Del(ctx, store.KeyQuotaBalance(uid)).Err() } // Reconcile 对账:users.balance_seconds 必须等于 SUM(balance_ledger.delta_seconds)。 // fix=true 时以 ledger 为准修正冗余列并失效 Redis 键。返回发现的偏差数。 func (m *Manager) Reconcile(ctx context.Context, fix bool) (int, error) { type row struct { ID string Balance int64 Ledger int64 } var rows []row err := m.db.WithContext(ctx).Raw(` SELECT u.id, u.balance_seconds AS balance, COALESCE(SUM(l.delta_seconds), 0) AS ledger FROM users u LEFT JOIN balance_ledgers l ON l.user_id = u.id GROUP BY u.id, u.balance_seconds HAVING u.balance_seconds <> COALESCE(SUM(l.delta_seconds), 0)`).Scan(&rows).Error if err != nil { return 0, err } for _, r := range rows { slog.Error("balance drift detected", "user", r.ID, "redundant", r.Balance, "ledger", r.Ledger) if fix { if err := m.db.WithContext(ctx).Model(&store.User{}). Where("id = ?", r.ID).UpdateColumn("balance_seconds", r.Ledger).Error; err != nil { return len(rows), err } _ = m.InvalidateBalance(ctx, r.ID) } } return len(rows), nil } // StartDailyReconcile 每日对账任务(凌晨 4 点 CST)。 func (m *Manager) StartDailyReconcile(ctx context.Context) { go func() { for { now := time.Now().In(time.FixedZone("CST", 8*3600)) next := time.Date(now.Year(), now.Month(), now.Day(), 4, 0, 0, 0, now.Location()) if !next.After(now) { next = next.Add(24 * time.Hour) } select { case <-ctx.Done(): return case <-time.After(time.Until(next)): if n, err := m.Reconcile(ctx, true); err != nil { slog.Error("reconcile failed", "err", err) } else if n > 0 { slog.Warn("reconcile fixed drifts", "count", n) } } } }() }