Files

499 lines
17 KiB
Go

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, &quota); 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, &quota); 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{}
}
}