package repository import ( "context" "database/sql" "errors" "fmt" "strings" "time" managedaccess "billing/internal/access" ) var ( ErrBillingQuotaExhausted = errors.New("billing quota exhausted") ErrBillingConcurrency = errors.New("billing concurrency exceeded") ) func (r *SQLiteUsageRepository) BillingState(ctx context.Context, keyID string, now time.Time) (managedaccess.BillingState, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return managedaccess.BillingState{}, err } defer func() { _ = tx.Rollback() }() if err := ensureBillingReset(ctx, tx, keyID, now); err != nil { return managedaccess.BillingState{}, err } state, err := scanBillingState(ctx, tx, keyID) if err != nil { return state, err } if err := tx.Commit(); err != nil { return state, err } return state, nil } func (r *SQLiteUsageRepository) UpdateBilling(ctx context.Context, keyID string, settings managedaccess.BillingSettings, now time.Time) (managedaccess.BillingState, error) { if settings.QuotaMicros < 0 { return managedaccess.BillingState{}, errors.New("额度不能小于 0") } if settings.MaxConcurrency < 1 || settings.MaxConcurrency > 64 { return managedaccess.BillingState{}, errors.New("并发上限必须为 1-64") } if !validResetPeriod(settings.ResetPeriod) { return managedaccess.BillingState{}, errors.New("重置周期只能是 none、daily、weekly 或 monthly") } if now.IsZero() { now = time.Now().UTC() } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return managedaccess.BillingState{}, err } defer func() { _ = tx.Rollback() }() if err := ensureBillingReset(ctx, tx, keyID, now); err != nil { return managedaccess.BillingState{}, err } current, err := scanBillingState(ctx, tx, keyID) if err != nil { return current, err } nextReset := settings.NextResetAt anchorDay := 0 if settings.ResetPeriod == managedaccess.ResetNone { nextReset = nil } else { if nextReset == nil || nextReset.IsZero() { var createdRaw string if err := tx.QueryRowContext(ctx, `SELECT created_at FROM managed_keys WHERE id=?`, keyID).Scan(&createdRaw); err != nil { return current, err } created, _ := time.Parse(time.RFC3339Nano, createdRaw) value := nextResetAfter(created, settings.ResetPeriod, now) nextReset = &value } if !nextReset.After(now) { return current, errors.New("下次重置时间必须晚于当前时间") } anchorDay = nextReset.In(shanghaiLocation()).Day() } nextValue := any(nil) if nextReset != nil { nextValue = formatTime(*nextReset) } if _, err := tx.ExecContext(ctx, ` UPDATE billing_accounts SET quota_micros=?, reset_period=?, next_reset_at=?, reset_anchor_day=?, max_concurrency=?, updated_at=? WHERE managed_key_id=?`, settings.QuotaMicros, settings.ResetPeriod, nextValue, anchorDay, settings.MaxConcurrency, formatTime(now), keyID); err != nil { return current, fmt.Errorf("更新额度设置: %w", err) } if _, err := tx.ExecContext(ctx, ` UPDATE billing_cycles SET quota_micros=? WHERE managed_key_id=? AND sequence=?`, settings.QuotaMicros, keyID, current.CycleSequence); err != nil { return current, fmt.Errorf("更新当前额度周期: %w", err) } if settings.QuotaMicros != current.QuotaMicros { balance := settings.QuotaMicros - current.SpentMicros _, err = tx.ExecContext(ctx, ` INSERT INTO billing_ledger (event_key, managed_key_id, cycle_sequence, kind, amount_micros, balance_after_micros, occurred_at, created_at) VALUES (?, ?, ?, 'quota_change', ?, ?, ?, ?)`, fmt.Sprintf("quota:%s:%d", keyID, now.UnixNano()), keyID, current.CycleSequence, settings.QuotaMicros-current.QuotaMicros, balance, formatTime(now), formatTime(now)) if err != nil { return current, fmt.Errorf("记录额度调整: %w", err) } } settingsChanged := settings.QuotaMicros != current.QuotaMicros || settings.ResetPeriod != current.ResetPeriod || settings.MaxConcurrency != current.MaxConcurrency || !sameOptionalTime(nextReset, current.NextResetAt) if settingsChanged { var name string if err := tx.QueryRowContext(ctx, `SELECT name FROM managed_keys WHERE id=?`, keyID).Scan(&name); err != nil { return current, fmt.Errorf("查询额度用户: %w", err) } event := "管理员修改用户 " + quotedName(name) + " 的额度设置" if settings.QuotaMicros != current.QuotaMicros { event = fmt.Sprintf("管理员调整用户 %s 的额度:$%s → $%s", quotedName(name), decimalMicros(current.QuotaMicros), decimalMicros(settings.QuotaMicros)) } if err := insertBusinessEvent(ctx, tx, event, BusinessEventSucceeded, now); err != nil { return current, err } } state, err := scanBillingState(ctx, tx, keyID) if err != nil { return state, err } if err := tx.Commit(); err != nil { return state, err } return state, nil } func (r *SQLiteUsageRepository) ResetBilling(ctx context.Context, keyID string, now time.Time) (managedaccess.BillingState, error) { if now.IsZero() { now = time.Now().UTC() } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return managedaccess.BillingState{}, err } defer func() { _ = tx.Rollback() }() var period string var anchorDay, sequence int64 var quota int64 if err := tx.QueryRowContext(ctx, ` SELECT reset_period, reset_anchor_day, current_cycle_sequence, quota_micros FROM billing_accounts WHERE managed_key_id=?`, keyID).Scan(&period, &anchorDay, &sequence, "a); err != nil { return managedaccess.BillingState{}, err } var next *time.Time if period != managedaccess.ResetNone { value := advanceReset(now, period, int(now.In(shanghaiLocation()).Day())) next = &value anchorDay = int64(now.In(shanghaiLocation()).Day()) } if err := resetBillingCycle(ctx, tx, keyID, sequence, quota, now, next, int(anchorDay), "manual"); err != nil { return managedaccess.BillingState{}, err } state, err := scanBillingState(ctx, tx, keyID) if err != nil { return state, err } if err := tx.Commit(); err != nil { return state, err } return state, nil } func (r *SQLiteUsageRepository) ResetAllBilling(ctx context.Context, now time.Time) (int, error) { if now.IsZero() { now = time.Now().UTC() } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return 0, err } defer func() { _ = tx.Rollback() }() type account struct { id string period string anchorDay, sequence int64 quota int64 } rows, err := tx.QueryContext(ctx, ` SELECT a.managed_key_id, a.reset_period, a.reset_anchor_day, a.current_cycle_sequence, a.quota_micros FROM billing_accounts a JOIN managed_keys k ON k.id=a.managed_key_id WHERE k.status <> 'archived' ORDER BY k.created_at, k.id`) if err != nil { return 0, fmt.Errorf("查询待重置用户: %w", err) } accounts := make([]account, 0) for rows.Next() { var item account if err := rows.Scan(&item.id, &item.period, &item.anchorDay, &item.sequence, &item.quota); err != nil { _ = rows.Close() return 0, fmt.Errorf("读取待重置用户: %w", err) } accounts = append(accounts, item) } if err := rows.Err(); err != nil { _ = rows.Close() return 0, fmt.Errorf("遍历待重置用户: %w", err) } if err := rows.Close(); err != nil { return 0, fmt.Errorf("关闭待重置用户查询: %w", err) } for _, item := range accounts { var next *time.Time anchorDay := item.anchorDay if item.period != managedaccess.ResetNone { value := advanceReset(now, item.period, now.In(shanghaiLocation()).Day()) next = &value anchorDay = int64(now.In(shanghaiLocation()).Day()) } if err := resetBillingCycle(ctx, tx, item.id, item.sequence, item.quota, now, next, int(anchorDay), ""); err != nil { return 0, err } } status := fmt.Sprintf("成功,处理 %d 个用户", len(accounts)) if err := insertBusinessEvent(ctx, tx, "管理员手动重置全部用户额度", status, now); err != nil { return 0, err } if err := tx.Commit(); err != nil { return 0, err } return len(accounts), nil } func (r *SQLiteUsageRepository) AuthorizeBilling(ctx context.Context, keyID, requestID string, now time.Time) (managedaccess.BillingState, error) { requestID = strings.TrimSpace(requestID) if requestID == "" { return managedaccess.BillingState{}, errors.New("request_id 不能为空") } if now.IsZero() { now = time.Now().UTC() } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return managedaccess.BillingState{}, err } defer func() { _ = tx.Rollback() }() if err := ensureBillingReset(ctx, tx, keyID, now); err != nil { return managedaccess.BillingState{}, err } state, err := scanBillingState(ctx, tx, keyID) if err != nil { return state, err } var existingStatus string err = tx.QueryRowContext(ctx, `SELECT status FROM billing_admissions WHERE request_id=?`, requestID).Scan(&existingStatus) if err == nil { if err := tx.Commit(); err != nil { return state, err } return state, nil } if !errors.Is(err, sql.ErrNoRows) { return state, err } if state.BalanceMicros <= 0 { return state, ErrBillingQuotaExhausted } if state.ActiveRequests >= state.MaxConcurrency { return state, ErrBillingConcurrency } if _, err := tx.ExecContext(ctx, ` INSERT INTO billing_admissions (request_id, managed_key_id, cycle_sequence, opened_at, status) VALUES (?, ?, ?, ?, 'open')`, requestID, keyID, state.CycleSequence, formatTime(now)); err != nil { return state, fmt.Errorf("占用并发额度: %w", err) } state.ActiveRequests++ if err := tx.Commit(); err != nil { return state, err } return state, nil } func (r *SQLiteUsageRepository) CompleteBillingAdmission(ctx context.Context, requestID string, now time.Time) error { if strings.TrimSpace(requestID) == "" { return nil } if now.IsZero() { now = time.Now().UTC() } _, err := r.db.ExecContext(ctx, ` UPDATE billing_admissions SET status='closed', closed_at=? WHERE request_id=? AND status='open'`, formatTime(now), requestID) return err } func (r *SQLiteUsageRepository) ReleaseStaleAdmissions(ctx context.Context, now time.Time) error { if now.IsZero() { now = time.Now().UTC() } _, err := r.db.ExecContext(ctx, ` UPDATE billing_admissions SET status='abandoned', closed_at=? WHERE status='open'`, formatTime(now)) return err } func (r *SQLiteUsageRepository) ListBillingLedger(ctx context.Context, keyID string, beforeID int64, limit int) ([]managedaccess.LedgerEntry, error) { if limit < 1 || limit > 100 { limit = 50 } query := ` SELECT id, managed_key_id, cycle_sequence, kind, amount_micros, balance_after_micros, request_id, execution_id, model, occurred_at, created_at FROM billing_ledger WHERE managed_key_id=?` args := []any{keyID} if beforeID > 0 { query += ` AND id < ?` args = append(args, beforeID) } query += ` ORDER BY id DESC LIMIT ?` args = append(args, limit) rows, err := r.readDB.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() entries := make([]managedaccess.LedgerEntry, 0, limit) for rows.Next() { var entry managedaccess.LedgerEntry var occurredRaw, createdRaw string if err := rows.Scan(&entry.ID, &entry.KeyID, &entry.CycleSequence, &entry.Kind, &entry.AmountMicros, &entry.BalanceAfterMicros, &entry.RequestID, &entry.ExecutionID, &entry.Model, &occurredRaw, &createdRaw); err != nil { return nil, err } entry.OccurredAt, _ = time.Parse(time.RFC3339Nano, occurredRaw) entry.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdRaw) entries = append(entries, entry) } return entries, rows.Err() } func scanBillingState(ctx context.Context, tx *sql.Tx, keyID string) (managedaccess.BillingState, error) { var state managedaccess.BillingState var nextRaw sql.NullString var startedRaw string err := tx.QueryRowContext(ctx, ` SELECT a.managed_key_id, a.quota_micros, c.spent_micros, a.lifetime_spent_micros, a.reset_period, a.next_reset_at, a.max_concurrency, a.current_cycle_sequence, c.started_at, (SELECT COUNT(*) FROM billing_admissions d WHERE d.managed_key_id=a.managed_key_id AND d.status='open') FROM billing_accounts a JOIN billing_cycles c ON c.managed_key_id=a.managed_key_id AND c.sequence=a.current_cycle_sequence WHERE a.managed_key_id=?`, keyID).Scan(&state.KeyID, &state.QuotaMicros, &state.SpentMicros, &state.LifetimeSpentMicros, &state.ResetPeriod, &nextRaw, &state.MaxConcurrency, &state.CycleSequence, &startedRaw, &state.ActiveRequests) if err != nil { return state, err } state.BalanceMicros = state.QuotaMicros - state.SpentMicros state.CycleStartedAt, _ = time.Parse(time.RFC3339Nano, startedRaw) if nextRaw.Valid && nextRaw.String != "" { value, err := time.Parse(time.RFC3339Nano, nextRaw.String) if err == nil { state.NextResetAt = &value } } return state, nil } func ensureBillingReset(ctx context.Context, tx *sql.Tx, keyID string, now time.Time) error { if now.IsZero() { now = time.Now().UTC() } var period string var nextRaw sql.NullString var anchorDay int var sequence, quota int64 if err := tx.QueryRowContext(ctx, ` SELECT reset_period, next_reset_at, reset_anchor_day, current_cycle_sequence, quota_micros FROM billing_accounts WHERE managed_key_id=?`, keyID).Scan(&period, &nextRaw, &anchorDay, &sequence, "a); err != nil { return err } if period == managedaccess.ResetNone || !nextRaw.Valid || nextRaw.String == "" { return nil } next, err := time.Parse(time.RFC3339Nano, nextRaw.String) if err != nil || now.Before(next) { return err } boundary := next future := advanceReset(next, period, anchorDay) for !future.After(now) { boundary = future future = advanceReset(future, period, anchorDay) } return resetBillingCycle(ctx, tx, keyID, sequence, quota, boundary, &future, anchorDay, "automatic") } func resetBillingCycle(ctx context.Context, tx *sql.Tx, keyID string, sequence, quota int64, boundary time.Time, next *time.Time, anchorDay int, auditMode string) error { if _, err := tx.ExecContext(ctx, ` UPDATE billing_cycles SET ended_at=? WHERE managed_key_id=? AND sequence=?`, formatTime(boundary), keyID, sequence); err != nil { return err } newSequence := sequence + 1 if _, err := tx.ExecContext(ctx, ` INSERT INTO billing_cycles (managed_key_id, sequence, started_at, quota_micros, spent_micros) VALUES (?, ?, ?, ?, 0)`, keyID, newSequence, formatTime(boundary), quota); err != nil { return err } nextValue := any(nil) if next != nil { nextValue = formatTime(*next) } if _, err := tx.ExecContext(ctx, ` UPDATE billing_accounts SET current_cycle_sequence=?, next_reset_at=?, reset_anchor_day=?, updated_at=? WHERE managed_key_id=?`, newSequence, nextValue, anchorDay, formatTime(boundary), keyID); err != nil { return err } _, err := tx.ExecContext(ctx, ` INSERT OR IGNORE INTO billing_ledger (event_key, managed_key_id, cycle_sequence, kind, amount_micros, balance_after_micros, occurred_at, created_at) VALUES (?, ?, ?, 'cycle_reset', ?, ?, ?, ?)`, fmt.Sprintf("reset:%s:%d", keyID, newSequence), keyID, newSequence, quota, quota, formatTime(boundary), formatTime(time.Now().UTC())) if err != nil { return err } if auditMode == "" { return nil } var name string if err := tx.QueryRowContext(ctx, `SELECT name FROM managed_keys WHERE id=?`, keyID).Scan(&name); err != nil { return fmt.Errorf("查询重置用户: %w", err) } event := "管理员手动重置用户 " + quotedName(name) + " 的额度" if auditMode == "automatic" { event = "系统执行用户 " + quotedName(name) + " 的额度自然重置" } return insertBusinessEvent(ctx, tx, event, BusinessEventSucceeded, boundary) } func sameOptionalTime(left, right *time.Time) bool { if left == nil || right == nil { return left == nil && right == nil } return left.Equal(*right) } func validResetPeriod(period string) bool { switch period { case managedaccess.ResetNone, managedaccess.ResetDaily, managedaccess.ResetWeekly, managedaccess.ResetMonthly: return true default: return false } } func shanghaiLocation() *time.Location { location, err := time.LoadLocation("Asia/Shanghai") if err != nil { return time.FixedZone("Asia/Shanghai", 8*60*60) } return location } func nextResetAfter(anchor time.Time, period string, now time.Time) time.Time { if anchor.IsZero() { anchor = now } anchorDay := anchor.In(shanghaiLocation()).Day() next := advanceReset(anchor, period, anchorDay) for !next.After(now) { next = advanceReset(next, period, anchorDay) } return next } func advanceReset(value time.Time, period string, anchorDay int) time.Time { location := shanghaiLocation() local := value.In(location) switch period { case managedaccess.ResetDaily: return local.AddDate(0, 0, 1).UTC() case managedaccess.ResetWeekly: return local.AddDate(0, 0, 7).UTC() case managedaccess.ResetMonthly: year, month := local.Year(), local.Month()+1 if month > 12 { year++ month = 1 } lastDay := time.Date(year, month+1, 0, local.Hour(), local.Minute(), local.Second(), local.Nanosecond(), location).Day() day := anchorDay if day < 1 { day = local.Day() } if day > lastDay { day = lastDay } return time.Date(year, month, day, local.Hour(), local.Minute(), local.Second(), local.Nanosecond(), location).UTC() default: return time.Time{} } }