package gateway import ( "context" "errors" "fmt" "strconv" "time" "aigateway.local/core/internal/apikey" "github.com/redis/go-redis/v9" ) var ErrTokenQuotaUnavailable = errors.New("token quota unavailable") type TokenReservation struct { Allowed bool APIKeyID string CounterKey string Reserved int64 Limit int64 Remaining int64 ResetAt time.Time } type TokenQuotaController interface { Reserve(context.Context, apikey.Principal, int64, time.Time) (TokenReservation, error) Commit(context.Context, TokenReservation, int64) error } type RedisTokenQuotaController struct { client *redis.Client reserveScript *redis.Script commitScript *redis.Script } func NewRedisTokenQuotaController(client *redis.Client) *RedisTokenQuotaController { return &RedisTokenQuotaController{ client: client, reserveScript: redis.NewScript(tokenReserveScript), commitScript: redis.NewScript(tokenCommitScript), } } func (c *RedisTokenQuotaController) Reserve(ctx context.Context, principal apikey.Principal, estimate int64, now time.Time) (TokenReservation, error) { if principal.APIKeyID == "" { return TokenReservation{Allowed: true}, nil } if estimate < 0 { estimate = 0 } // Accounts without a monthly token quota never need a reservation and must // not create a pointless monthly counter key in Redis. if principal.MonthlyTokenQuota == 0 { return TokenReservation{Allowed: true}, nil } if c == nil || c.client == nil { return TokenReservation{}, ErrTokenQuotaUnavailable } now = now.UTC() reset := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC) key := apikey.MonthlyTokenUsageKey(principal.APIKeyID, now) result, err := c.reserveScript.Run(ctx, c.client, []string{key}, estimate, principal.MonthlyTokenQuota, int64(reset.Sub(now).Seconds())+86400).Slice() if err != nil || len(result) != 2 { if principal.MonthlyTokenQuota == 0 { return TokenReservation{Allowed: true}, nil } return TokenReservation{}, fmt.Errorf("%w: %v", ErrTokenQuotaUnavailable, err) } allowed, err := redisInteger(result[0]) if err != nil { return TokenReservation{}, ErrTokenQuotaUnavailable } current, err := redisInteger(result[1]) if err != nil { return TokenReservation{}, ErrTokenQuotaUnavailable } return TokenReservation{ Allowed: allowed == 1, APIKeyID: principal.APIKeyID, CounterKey: key, Reserved: estimate, Limit: principal.MonthlyTokenQuota, Remaining: max(principal.MonthlyTokenQuota-current, 0), ResetAt: reset, }, nil } func (c *RedisTokenQuotaController) Commit(ctx context.Context, reservation TokenReservation, actual int64) error { if c == nil || c.client == nil || reservation.CounterKey == "" || !reservation.Allowed { return nil } if actual < 0 { actual = 0 } if _, err := c.commitScript.Run(ctx, c.client, []string{reservation.CounterKey}, actual-reservation.Reserved).Result(); err != nil { return fmt.Errorf("%w: %v", ErrTokenQuotaUnavailable, err) } return nil } const tokenReserveScript = ` local estimate = tonumber(ARGV[1]) local quota = tonumber(ARGV[2]) local current = tonumber(redis.call('GET', KEYS[1]) or '0') if quota > 0 and current + estimate > quota then return {0, current} end if estimate > 0 then current = redis.call('INCRBY', KEYS[1], estimate) if current == estimate then redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3])) end elseif redis.call('EXISTS', KEYS[1]) == 0 then redis.call('SET', KEYS[1], 0, 'EX', tonumber(ARGV[3])) end return {1, current} ` const tokenCommitScript = ` local delta = tonumber(ARGV[1]) -- 预留与提交之间月份可能已翻转,计数器键已过期:此时直接放弃回写, -- 不能重建一个永不过期的残留键(旧月份数据已无意义)。 if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end local current = tonumber(redis.call('GET', KEYS[1]) or '0') local updated = current + delta if updated < 0 then updated = 0 end redis.call('SET', KEYS[1], updated, 'KEEPTTL') return updated ` func writeTokenQuotaHeaders(header mapHeader, reservation TokenReservation) { if reservation.Limit <= 0 || reservation.ResetAt.IsZero() { return } header.Set("X-TokenLimit-Limit", strconv.FormatInt(reservation.Limit, 10)) header.Set("X-TokenLimit-Remaining", strconv.FormatInt(max(reservation.Remaining, 0), 10)) header.Set("X-TokenLimit-Reset", strconv.FormatInt(reservation.ResetAt.Unix(), 10)) } type mapHeader interface{ Set(string, string) }