Files
openteam/server/internal/proxy/gateway.go
T
Sakurasan b2e500ff51 feat: 金额 decimal 精确计费、failover 按失败类型封顶、渠道小时桶预聚合、分层守护测试
- 计费:19 个金额字段 float64→decimal.Decimal,DB 列保持 numeric(20,8) 零迁移;
  API 金额仍为 JSON number(MarshalJSONWithoutQuotes),前端零改动
- failover:保留时间预算,新增 OT_PROXY_FAILOVER_CAPS_NET/SERVER/RATE/QUOTA
  按失败类型封顶换渠道次数(429/配额类瞬时失败时间预算形同虚设)
- 预聚合:新增 usage_channel_hourlies(schema v10),记账事务内同步 upsert,
  长窗口渠道统计走桶不再扫明细,spent_last_30d 改扫 usage_daily,v10 一次性回填幂等
- 守护:internal/arch_test.go 用 go/parser 强制分层依赖(fail-closed)
2026-11-09 00:41:48 +08:00

608 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package proxy API 网关核心:代理 /v1/chat/completions、/v1/responses、/v1/messages、/v1/models。
// M1 直通 OpenAI 渠道;M4 起按客户端协议 × 渠道协议自动转换(见 convert)。
package proxy
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/channel"
"github.com/openteam/server/internal/pkg/apikey"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/pkg/ratelimit"
"github.com/openteam/server/internal/proxy/convert"
"github.com/openteam/server/internal/store"
"github.com/openteam/server/internal/usage"
"gorm.io/gorm"
)
const (
CtxUserID = "proxy_user_id"
CtxUser = "proxy_user" // Auth 阶段加载的完整用户(含余额),resolveUser 直接复用
CtxKeyID = "proxy_key_id"
CtxKeyModel = "proxy_key_models"
CtxTrace = "proxy_trace_id"
)
type Gateway struct {
db *gorm.DB
ch *channel.Service
rec *usage.Recorder
enc *crypto.Encryptor
lim *ratelimit.Limiter
userRPS int
logRaw bool
// 上游超时兜底值(渠道 timeout_ms 非法时使用);按超时值缓存 Transport 复用连接池。
gwTimeout time.Duration
maxBodyMB int // /v1 请求体上限(MB),仅用于超限报错文案
// 故障转移给"第二个及以后的渠道"留的时间预算;超了就直接返回最后一个错误,
// 不限制首个渠道(长回答本来就可能跑很久)。
failoverBudget time.Duration
// 按失败类型限制换渠道次数(0=不限):429/配额类失败是瞬时返回,
// 时间预算对它们形同虚设,按次数封顶才有意义。
failoverCaps map[channel.FailKind]int
// 流式空闲多久补一行 SSE 注释心跳(0=关闭)。反向代理的空闲超时会掐掉长思考的流。
streamHeartbeat time.Duration
// 流式放行前等多久内容(0=不限制)。超时未出内容取消本次尝试换下一个渠道。
firstTokenWait time.Duration
// 会话亲和 TTL(0=关闭):同一会话粘住上次应答的渠道,保住上游提示词缓存。
affinityTTL time.Duration
// 渠道并发满载时每个候选最多排队多久(0=立刻跳过)。
channelAcquireWait time.Duration
// 鉴权缓存(密钥 + 用户行),0=关闭。
auth *authCache
aff *affinityTable
tpMu sync.Mutex
tps map[time.Duration]*http.Transport
policyMu sync.Mutex
policy modelPolicy
// 模型目录缓存:每请求原本要查「是否存在」与「计价」两次同名查询,
// 这里按名称缓存 catalogTTL;管理端写操作立即失效。
modelMu sync.Mutex
modelCat map[string]modelEntry
modelAt time.Time
}
// catalogTTL 目录缓存有效期。运行期状态(冷却、健康)不受它影响,
// 它们每次读取时重新判定;这里只缓存「配置类」数据(模型、渠道、绑定)。
const catalogTTL = 10 * time.Second
type modelEntry struct {
m store.Model
ok bool
}
// modelByName 取模型(带进程内缓存);第二个返回值表示模型存在且启用。
func (g *Gateway) modelByName(name string) (store.Model, bool) {
g.modelMu.Lock()
if g.modelCat != nil && time.Since(g.modelAt) < catalogTTL {
if e, ok := g.modelCat[name]; ok {
g.modelMu.Unlock()
return e.m, e.ok
}
}
g.modelMu.Unlock()
var m store.Model
err := g.db.Where("name = ? AND enabled = ?", name, true).First(&m).Error
e := modelEntry{m: m, ok: err == nil}
g.modelMu.Lock()
if g.modelCat == nil || time.Since(g.modelAt) >= catalogTTL {
g.modelCat = map[string]modelEntry{}
g.modelAt = time.Now()
}
g.modelCat[name] = e
g.modelMu.Unlock()
return e.m, e.ok
}
// InvalidateAuth 清空鉴权缓存(密钥/用户/密钥白名单等写操作后调用)。
func (g *Gateway) InvalidateAuth() { g.auth.clear() }
// InvalidateCatalog 清空模型与渠道目录缓存(管理端改动、健康状态变化后调用)。
func (g *Gateway) InvalidateCatalog() {
g.modelMu.Lock()
g.modelCat = nil
g.modelAt = time.Time{}
g.modelMu.Unlock()
g.ch.InvalidateCatalog()
}
// modelPolicy 全局模型限制策略(来自系统配置,短时缓存)。
type modelPolicy struct {
allow []string
deny []string
at time.Time
}
const modelPolicyTTL = 5 * time.Second
// ResetModelPolicy 清空全局模型限制缓存(系统配置保存后调用)。
func (g *Gateway) ResetModelPolicy() {
g.policyMu.Lock()
g.policy = modelPolicy{}
g.policyMu.Unlock()
}
// ChannelRuntime 渠道运行期健康快照:连续失败次数 + 冷却剩余时间 + 最近失败原因(管理后台展示)。
func (g *Gateway) ChannelRuntime(id uint64) (fails int, remaining time.Duration, reason string) {
return g.ch.RuntimeState(id)
}
// RecoverChannel 立即清除渠道的运行期冷却与失败计数(管理后台手动恢复)。
func (g *Gateway) RecoverChannel(id uint64) {
g.ch.ClearRuntime(id)
}
// globalModelRestrictions 读取全局模型允许/禁止列表(缓存 modelPolicyTTL)。
func (g *Gateway) globalModelRestrictions() (allow, deny []string) {
g.policyMu.Lock()
defer g.policyMu.Unlock()
if time.Since(g.policy.at) < modelPolicyTTL {
return g.policy.allow, g.policy.deny
}
var raw string
g.db.Model(&store.SystemConfig{}).Where("key = ?", "model_allowlist").Pluck("value", &raw)
_ = json.Unmarshal([]byte(raw), &allow)
raw = ""
g.db.Model(&store.SystemConfig{}).Where("key = ?", "model_denylist").Pluck("value", &raw)
_ = json.Unmarshal([]byte(raw), &deny)
g.policy = modelPolicy{allow: allow, deny: deny, at: time.Now()}
return
}
// checkModelAllowed 模型访问控制:用户级 > 密钥级 > 全局。
// 1. 用户禁止列表命中 → 拒绝
// 2. 密钥允许列表非空且不含该模型 → 拒绝(密钥级只收窄,不放宽)
// 3. 用户允许列表非空 → 仅列表内可访问,不再看全局(管理员显式授权该用户,短路全局)
// 4. 全局禁止命中 → 拒绝
// 5. 全局允许列表非空 → 仅列表内可访问
func (g *Gateway) checkModelAllowed(c *gin.Context, u *store.User, model string) bool {
if model == "" {
return true
}
if contains(u.DeniedModels, model) {
return false
}
if keyAllow, ok := c.Get(CtxKeyModel); ok {
if list, _ := keyAllow.([]string); len(list) > 0 && !contains(list, model) {
return false
}
}
if len(u.AllowedModels) > 0 {
return contains(u.AllowedModels, model)
}
allow, deny := g.globalModelRestrictions()
if contains(deny, model) {
return false
}
if len(allow) > 0 {
return contains(allow, model)
}
return true
}
// checkModelExists 拦截不存在或已禁用的模型。/v1/models 只列启用的模型,请求侧口径必须一致:
// 禁用模型不能只是从列表消失——否则照样能调用、照样计费,管理员的下架操作形同虚设。
func (g *Gateway) checkModelExists(c *gin.Context, model string) bool {
if model == "" {
apiError(c, http.StatusBadRequest, "invalid_request_error", "model is required")
return false
}
if _, ok := g.modelByName(model); !ok {
apiError(c, http.StatusNotFound, "model_not_found",
fmt.Sprintf("The model %q does not exist or is not enabled", model))
return false
}
return true
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// Options 网关配置项。用结构体而不是十个位置参数:里面三个 time.Duration、
// 两个 int,传错的编译期发现不了。
type Options struct {
UserRPS int
LogRaw bool
GWTimeout time.Duration // 上游超时兜底(渠道 timeout_ms 优先)
MaxBodyMB int // /v1 请求体上限(MB)
FailoverBudget time.Duration // 故障转移重试的时间预算
FailoverSwitchCaps map[string]int // 按失败类型(net|server|rate|quota)限制换渠道次数,0=不限
StreamHeartbeat time.Duration // 流式空闲心跳间隔
FirstTokenWait time.Duration // 流式放行前等内容的最长时间(0=不限)
AffinityTTL time.Duration // 会话亲和 TTL(0=关闭)
ChannelAcquireWait time.Duration // 渠道满载排队时长(0=立刻跳过)
AuthCacheTTL time.Duration // 鉴权缓存 TTL(0=关闭)
}
// failoverCapsFrom 把字符串键的失败类型上限配置转成 FailKind 键;未知键忽略(配置容错)。
func failoverCapsFrom(m map[string]int) map[channel.FailKind]int {
if len(m) == 0 {
return nil
}
out := make(map[channel.FailKind]int, len(m))
for k, v := range m {
switch k {
case "net":
out[channel.FailNet] = v
case "server":
out[channel.FailServer] = v
case "rate":
out[channel.FailRate] = v
case "quota":
out[channel.FailQuota] = v
}
}
return out
}
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, o Options) *Gateway {
return &Gateway{
db: db,
ch: channel.NewService(db, enc),
rec: rec,
enc: enc,
lim: lim,
userRPS: o.UserRPS,
logRaw: o.LogRaw,
gwTimeout: o.GWTimeout,
maxBodyMB: o.MaxBodyMB,
failoverBudget: o.FailoverBudget,
failoverCaps: failoverCapsFrom(o.FailoverSwitchCaps),
streamHeartbeat: o.StreamHeartbeat,
firstTokenWait: o.FirstTokenWait,
affinityTTL: o.AffinityTTL,
channelAcquireWait: o.ChannelAcquireWait,
auth: newAuthCache(o.AuthCacheTTL),
aff: newAffinityTable(),
modelCat: map[string]modelEntry{},
tps: map[time.Duration]*http.Transport{},
}
}
// channelTimeout 该渠道的上游超时:timeout_ms 优先,未配置则用全局兜底值。
func (g *Gateway) channelTimeout(ch *store.Channel) time.Duration {
if ch.TimeoutMS > 0 {
return time.Duration(ch.TimeoutMS) * time.Millisecond
}
return g.gwTimeout
}
// clientFor 返回带该超时的上游 client。Transport 按超时值缓存以复用连接池,
// ResponseHeaderTimeout 只约束"连上之后多久要回首包"——流式正文不限时长,长回答不会被砍。
// 整段时长(含读 body)由请求 context 的 deadline 控制,见 proxyOne。
func (g *Gateway) clientFor(d time.Duration) *http.Client {
if d < 0 {
d = 0
}
g.tpMu.Lock()
defer g.tpMu.Unlock()
tp, ok := g.tps[d]
if !ok {
tp = &http.Transport{
Proxy: http.ProxyFromEnvironment,
ForceAttemptHTTP2: true,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
}
if d > 0 {
tp.ResponseHeaderTimeout = d
}
g.tps[d] = tp
}
return &http.Client{Transport: tp}
}
// Auth 代理鉴权中间件:Bearer sk-xxx → 哈希查表 → 校验状态/过期/模型白名单。
func (g *Gateway) Auth(c *gin.Context) {
// 先按路径确定客户端协议,保证 Auth 阶段错误也按协议格式返回
switch c.Request.URL.Path {
case "/v1/messages":
c.Set("protocol", convert.ProtoMessages)
case "/v1/responses":
c.Set("protocol", convert.ProtoResponses)
case "/v1/models":
// 模型目录两条协议共用同一路径,只能靠头区分:Anthropic 客户端带
// x-api-key / anthropic-version,OpenAI 客户端从不发这两个头。
if c.GetHeader("x-api-key") != "" || c.GetHeader("anthropic-version") != "" {
c.Set("protocol", convert.ProtoMessages)
} else {
c.Set("protocol", convert.ProtoChat)
}
default:
c.Set("protocol", convert.ProtoChat)
}
auth := c.GetHeader("Authorization")
key := strings.TrimPrefix(auth, "Bearer ")
key = strings.TrimSpace(key)
if key == "" {
// Anthropic 客户端(Claude Code / SDK)用 x-api-key 头而不是 Authorization
key = strings.TrimSpace(c.GetHeader("x-api-key"))
}
if !apikey.Valid(key) {
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Authorization: Bearer sk-ot-... or x-api-key: sk-ot-...")
c.Abort()
return
}
hash := apikey.Hash(key)
// 命中缓存时省掉两条随机读;状态/过期仍在下面按当前时间判定
k, u, ok := g.auth.get(hash)
if !ok {
if err := g.db.Where("key_hash = ? AND status = ?", hash, store.KeyStatusActive).First(&k).Error; err != nil {
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key")
c.Abort()
return
}
if err := g.db.First(&u, k.UserID).Error; err != nil {
apiError(c, http.StatusForbidden, "user_disabled", "User account is disabled")
c.Abort()
return
}
g.auth.put(hash, k, u)
}
// 缓存值可能是 TTL 内的旧状态:每次请求都按当前时间重判
if k.Status != store.KeyStatusActive {
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key")
c.Abort()
return
}
if u.Status != store.UserStatusActive {
apiError(c, http.StatusForbidden, "user_disabled", "User account is disabled")
c.Abort()
return
}
if k.ExpiresAt != nil && time.Now().After(*k.ExpiresAt) {
apiError(c, http.StatusUnauthorized, "key_expired", "API key has expired")
c.Abort()
return
}
// 限流与配额(内存计数)
if g.lim != nil {
if k.QuotaRequestsPerDay != nil && !g.lim.AllowRequestDaily(k.ID, *k.QuotaRequestsPerDay) {
apiError(c, http.StatusTooManyRequests, "rate_limit_exceeded", "Daily request quota exceeded for this API key")
c.Abort()
return
}
if k.QuotaTokensPerDay != nil && g.lim.TokensUsed(k.ID) >= *k.QuotaTokensPerDay {
apiError(c, http.StatusTooManyRequests, "rate_limit_exceeded", "Daily token quota exceeded for this API key")
c.Abort()
return
}
if !g.lim.AllowUserRate(u.ID, g.userRPS) {
apiError(c, http.StatusTooManyRequests, "rate_limit_exceeded", "Too many requests. Please slow down.")
c.Abort()
return
}
}
c.Set(CtxUser, &u)
c.Set(CtxUserID, u.ID)
c.Set(CtxKeyID, k.ID)
c.Set(CtxKeyModel, k.AllowedModels)
c.Set(CtxTrace, newTraceID())
// last_used_at 按分钟节流:纯展示字段,不值得每请求一次 DB 写(高 QPS 下是可观开销)。
// 密钥行可能来自缓存(时间戳不会自己变),所以节流按缓存里的 touch 记录判断。
if g.auth.shouldTouch(k.ID, time.Minute) {
g.db.Model(&store.APIKey{}).Where("id = ?", k.ID).Update("last_used_at", time.Now())
}
c.Next()
}
// Handle 路由到对应协议处理器。
func (g *Gateway) Handle(c *gin.Context) {
switch {
case c.Request.URL.Path == "/v1/chat/completions":
g.chatCompletions(c)
case c.Request.URL.Path == "/v1/responses":
g.responses(c)
case c.Request.URL.Path == "/v1/messages":
g.messages(c)
case c.Request.URL.Path == "/v1/models" && c.Request.Method == http.MethodGet:
g.models(c)
default:
apiError(c, http.StatusNotFound, "not_found", "Unknown endpoint: "+c.Request.URL.Path)
}
}
// models GET /v1/models:返回调用方真正能用的模型。
// 口径与选路一致(启用 + 至少绑定一个启用且健康的渠道),再叠加该用户/密钥的模型白名单——
// 否则被限制的模型名照样泄漏在列表里,客户端试了才知道 403。
// 按客户端协议返回对应形状,并带上目录元数据(上下文长度、能力位);价格不外泄。
func (g *Gateway) models(c *gin.Context) {
u, ok := g.resolveUser(c)
if !ok {
return
}
ids := g.ch.AvailableModelIDs()
if len(ids) == 0 {
g.writeModelList(c, nil)
return
}
var ms []store.Model
if err := g.db.Where("id IN ? AND enabled = ?", ids, true).Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
apiError(c, http.StatusInternalServerError, "internal_error", "failed to load models")
return
}
out := make([]store.Model, 0, len(ms))
for _, m := range ms {
if g.checkModelAllowed(c, u, m.Name) {
out = append(out, m)
}
}
g.writeModelList(c, out)
}
func (g *Gateway) writeModelList(c *gin.Context, ms []store.Model) {
if p, _ := c.Get("protocol"); p == convert.ProtoMessages {
data := make([]gin.H, 0, len(ms))
for _, m := range ms {
data = append(data, gin.H{
"type": "model",
"id": m.Name,
"display_name": m.Name,
"created_at": m.CreatedAt.UTC().Format(time.RFC3339),
"context_length": m.ContextLength,
"capabilities": gin.H{
"tools": m.SupportsTools, "vision": m.SupportsVision, "reasoning": m.SupportsReasoning,
},
})
}
c.JSON(http.StatusOK, gin.H{"data": data, "has_more": false})
return
}
data := make([]gin.H, 0, len(ms))
for _, m := range ms {
item := gin.H{
"id": m.Name, "object": "model",
"created": m.CreatedAt.Unix(), "owned_by": "openteam",
}
if m.ContextLength > 0 {
item["context_length"] = m.ContextLength
}
if m.SupportsTools || m.SupportsVision || m.SupportsReasoning {
item["capabilities"] = gin.H{
"tools": m.SupportsTools, "vision": m.SupportsVision, "reasoning": m.SupportsReasoning,
}
}
data = append(data, item)
}
c.JSON(http.StatusOK, gin.H{"object": "list", "data": data})
}
// candidateChannels 返回可用渠道候选(按模型绑定优先,退化全局;携带模型映射)。
func (g *Gateway) candidateChannels(model string) []channel.Candidate {
return g.ch.Candidates(model)
}
// resolveUser 取当前用户(含余额)。Auth 中间件已查过一次并放入上下文,
// handler 里直接复用,省掉每请求一次重复查询。
func (g *Gateway) resolveUser(c *gin.Context) (*store.User, bool) {
if u, ok := c.Get(CtxUser); ok {
if u2, ok := u.(*store.User); ok {
return u2, true
}
}
uid, _ := c.Get(CtxUserID)
var u store.User
if err := g.db.First(&u, uid).Error; err != nil {
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key")
return nil, false
}
return &u, true
}
// checkBalance 余额不足返回 402(PLANNING §4.4.3)。
func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
if !u.Balance.IsPositive() {
apiError(c, http.StatusPaymentRequired, "insufficient_balance", "Insufficient balance. Please recharge or contact admin.")
return false
}
return true
}
// ---------------------------------------------------------------------------
// 协议分派
func upstreamPath(proto string) string {
switch proto {
case convert.ProtoMessages:
return "/messages"
case convert.ProtoResponses:
return "/responses"
default:
return "/chat/completions"
}
}
// upstreamPlan 描述一次代理请求的上游访问方式。
type upstreamPlan struct {
proto string // 上游协议(用于分协议 base_url)
path string // 上游路径
body []byte // 已转换的请求体
lineConv func([]byte) []byte // 流式逐行转换(nil=直通)
bodyConv func([]byte) ([]byte, error) // 非流式响应体转换(nil=直通)
}
// conversionTarget 决定客户端协议在渠道上的处理方式:
// 渠道声明支持该协议则直通(返回原协议);否则转为其首选支持协议(chat > messages > responses)。
func conversionTarget(formats []string, clientProto string) string {
for _, f := range formats {
if f == clientProto {
return clientProto
}
}
for _, p := range []string{convert.ProtoChat, convert.ProtoMessages, convert.ProtoResponses} {
for _, f := range formats {
if f == p {
return p
}
}
}
return ""
}
// prepareUpstream 计算上游访问计划:渠道声明支持客户端协议则直通,否则转换;
// 应用模型名称映射(upstream_model)。
func prepareUpstream(ch *store.Channel, clientProto string, body []byte, upstreamModel string) (*upstreamPlan, error) {
target := conversionTarget(ch.FormatsEffective(), clientProto)
if target == "" {
return nil, fmt.Errorf("channel %q declares no supported protocol format", ch.Name)
}
plan := &upstreamPlan{proto: target, path: upstreamPath(target), body: body}
if target != clientProto {
converted, err := convert.ConvertRequest(body, clientProto, target)
if err != nil {
return nil, err
}
plan.body = converted
plan.lineConv = convert.NewStreamTransformer(target, clientProto)
plan.bodyConv = func(b []byte) ([]byte, error) { return convert.ConvertResponse(b, target, clientProto) }
}
// 模型名称映射:把请求体 model 字段改写为渠道侧的 upstream_model
if upstreamModel != "" {
if out, err := rewriteModel(plan.body, upstreamModel); err == nil {
plan.body = out
}
}
return plan, nil
}
// rewriteModel 改写请求体顶层的 model 字段(模型映射)。
// 定点替换而不是整包 unmarshal/marshal:后者在 100KB 请求体上实测 ~585µs、
// 470KB 分配,而这里只有一次扫描 + 拼接。
func rewriteModel(body []byte, model string) ([]byte, error) {
out, ok := rewriteModelField(body, model)
if !ok {
return body, nil // 没有顶层 model(非法或空体):原样转发,交给上游判
}
return out, nil
}
var errNoChannel = errors.New("no available channel")