Files
ai-gateway-go/internal/gateway/admission.go
T
superidou 5759c1862e AI Gateway Go 0.10.0 源码快照 + 旗舰版需求规划报告
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。
含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 11:45:54 +08:00

140 lines
4.2 KiB
Go

package gateway
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"aigateway.local/core/internal/apikey"
"github.com/redis/go-redis/v9"
)
var ErrAdmissionUnavailable = errors.New("admission control unavailable")
type AdmissionReason string
const (
AdmissionAllowed AdmissionReason = ""
AdmissionRateLimit AdmissionReason = "rate_limit"
AdmissionMonthlyQuota AdmissionReason = "monthly_quota"
)
type AdmissionDecision struct {
Allowed bool
Reason AdmissionReason
Limit int64
Remaining int64
ResetAt time.Time
RetryAfter time.Duration
}
type AdmissionController interface {
Allow(context.Context, apikey.Principal, time.Time) (AdmissionDecision, error)
}
type RedisAdmissionController struct {
client *redis.Client
script *redis.Script
}
func NewRedisAdmissionController(client *redis.Client) *RedisAdmissionController {
return &RedisAdmissionController{client: client, script: redis.NewScript(admissionScript)}
}
func (c *RedisAdmissionController) Allow(ctx context.Context, principal apikey.Principal, now time.Time) (AdmissionDecision, error) {
if principal.RequestsPerMinute == 0 && principal.MonthlyRequestQuota == 0 {
return AdmissionDecision{Allowed: true}, nil
}
if c == nil || c.client == nil || principal.APIKeyID == "" {
return AdmissionDecision{}, ErrAdmissionUnavailable
}
now = now.UTC()
minuteReset := now.Truncate(time.Minute).Add(time.Minute)
monthReset := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC)
minuteKey := fmt.Sprintf("gateway:limit:api-key:%s:minute:%d", principal.APIKeyID, now.Unix()/60)
monthKey := fmt.Sprintf("gateway:limit:api-key:%s:month:%s", principal.APIKeyID, now.Format("200601"))
result, err := c.script.Run(ctx, c.client, []string{minuteKey, monthKey},
principal.RequestsPerMinute, principal.MonthlyRequestQuota,
int64(minuteReset.Sub(now).Seconds())+2, int64(monthReset.Sub(now).Seconds())+86400,
).Slice()
if err != nil {
return AdmissionDecision{}, fmt.Errorf("%w: %v", ErrAdmissionUnavailable, err)
}
if len(result) != 3 {
return AdmissionDecision{}, ErrAdmissionUnavailable
}
code, err := redisInteger(result[0])
if err != nil {
return AdmissionDecision{}, ErrAdmissionUnavailable
}
minuteCount, err := redisInteger(result[1])
if err != nil {
return AdmissionDecision{}, ErrAdmissionUnavailable
}
monthCount, err := redisInteger(result[2])
if err != nil {
return AdmissionDecision{}, ErrAdmissionUnavailable
}
switch code {
case 0:
decision := AdmissionDecision{Allowed: true}
if principal.RequestsPerMinute > 0 {
decision.Limit = int64(principal.RequestsPerMinute)
decision.Remaining = max(decision.Limit-minuteCount, 0)
decision.ResetAt = minuteReset
}
return decision, nil
case 1:
return AdmissionDecision{
Allowed: false, Reason: AdmissionRateLimit, Limit: int64(principal.RequestsPerMinute),
Remaining: 0, ResetAt: minuteReset, RetryAfter: minuteReset.Sub(now),
}, nil
case 2:
return AdmissionDecision{
Allowed: false, Reason: AdmissionMonthlyQuota, Limit: principal.MonthlyRequestQuota,
Remaining: max(principal.MonthlyRequestQuota-monthCount, 0), ResetAt: monthReset, RetryAfter: monthReset.Sub(now),
}, nil
default:
return AdmissionDecision{}, ErrAdmissionUnavailable
}
}
func redisInteger(value any) (int64, error) {
switch number := value.(type) {
case int64:
return number, nil
case string:
return strconv.ParseInt(number, 10, 64)
case []byte:
return strconv.ParseInt(string(number), 10, 64)
default:
return 0, fmt.Errorf("unexpected redis integer %T", value)
}
}
const admissionScript = `
local rpm = tonumber(ARGV[1])
local monthly_quota = tonumber(ARGV[2])
local minute_count = tonumber(redis.call('GET', KEYS[1]) or '0')
local month_count = tonumber(redis.call('GET', KEYS[2]) or '0')
if monthly_quota > 0 and month_count >= monthly_quota then
return {2, minute_count, month_count}
end
if rpm > 0 and minute_count >= rpm then
return {1, minute_count, month_count}
end
if rpm > 0 then
minute_count = redis.call('INCR', KEYS[1])
if minute_count == 1 then redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3])) end
end
if monthly_quota > 0 then
month_count = redis.call('INCR', KEYS[2])
if month_count == 1 then redis.call('EXPIRE', KEYS[2], tonumber(ARGV[4])) end
end
return {0, minute_count, month_count}
`