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>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
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}
|
||||
`
|
||||
Reference in New Issue
Block a user