5759c1862e
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。 含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。 Co-Authored-By: Claude <noreply@anthropic.com>
140 lines
4.2 KiB
Go
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}
|
|
`
|