- 预扣:每请求按 body 估算成本上界计入冻结额(1e-8 USD 原子计数),402 判定 改为「余额 - 在途冻结 ≤ 0」,并发的长流式请求不再互相透支;OT_PROXY_RESERVE_MAX_TOKENS 控制估算基数(默认 4096,0=关闭),handler defer 释放,失败/中断/panic 均覆盖 - 调度:全部候选都在运行期冷却时,剩余冷却 ≤ OT_PROXY_COOLDOWN_GRACE 的最早恢复渠道 兜底重试一次而不是 503(429 Retry-After 语义);DB 层不可用的渠道不参与兜底 - 指标:openteam_proxy_requests_total / request_duration / channel_failures / usage_queue_depth / frozen_balance,/metrics 默认关闭(OT_METRICS_ENABLED 开启)
160 lines
5.8 KiB
Go
160 lines
5.8 KiB
Go
package api
|
||
|
||
import (
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/openteam/server/internal/api/middleware"
|
||
"github.com/openteam/server/internal/app"
|
||
"github.com/openteam/server/internal/pkg/metrics"
|
||
"github.com/openteam/server/internal/proxy"
|
||
)
|
||
|
||
// NewRouter 装配所有路由:
|
||
// - /v1/* 代理端点(Bearer API Key,OpenAI 格式错误体)
|
||
// - /api/v1/* 管理 API(会话 JWT)
|
||
func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
||
if a.Cfg.Env == "production" {
|
||
gin.SetMode(gin.ReleaseMode)
|
||
}
|
||
r := gin.New()
|
||
// 只有显式配置了反代列表才信任 X-Forwarded-For;否则 ClientIP 退回 RemoteAddr,
|
||
// 避免伪造头把认证限速绕开。
|
||
if len(a.Cfg.TrustedProxies) > 0 {
|
||
if err := r.SetTrustedProxies(a.Cfg.TrustedProxies); err != nil {
|
||
panic("invalid trusted proxies: " + err.Error())
|
||
}
|
||
}
|
||
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS(a.Cfg.CORSAllowOrigins), middleware.SecurityHeaders())
|
||
|
||
h := NewHandler(a, gw, a.Passkeys)
|
||
|
||
// --- 代理端点(对外)---
|
||
proxyGroup := r.Group("/v1", middleware.BodyLimit(int64(a.Cfg.Proxy.MaxBodyMB)*1024*1024))
|
||
{
|
||
proxyGroup.Any("/chat/completions", gw.Auth, gw.Handle)
|
||
proxyGroup.Any("/responses", gw.Auth, gw.Handle)
|
||
proxyGroup.Any("/messages", gw.Auth, gw.Handle)
|
||
proxyGroup.Any("/models", gw.Auth, gw.Handle)
|
||
}
|
||
// 静态资源(前端构建产物,存在时托管)
|
||
const dist = "web/dist"
|
||
if _, err := os.Stat(dist); err == nil {
|
||
// /assets 文件名含内容 hash,不可变:长缓存 + immutable,避免每次回源
|
||
assets := r.Group("/assets")
|
||
assets.Use(func(c *gin.Context) {
|
||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||
c.Next()
|
||
})
|
||
assets.Static("", dist+"/assets")
|
||
r.StaticFile("/favicon.svg", dist+"/favicon.svg")
|
||
}
|
||
|
||
// 未匹配路由:/v1/* 走代理鉴权;其余回退 SPA 或 404
|
||
r.NoRoute(func(c *gin.Context) {
|
||
if strings.HasPrefix(c.Request.URL.Path, "/v1") {
|
||
gw.Auth(c)
|
||
if !c.IsAborted() {
|
||
gw.Handle(c)
|
||
}
|
||
return
|
||
}
|
||
if _, err := os.Stat(dist); err == nil {
|
||
// SPA 入口:必须每次回源校验,否则浏览器启发式缓存会保留引用已删除 chunk 的旧 HTML → 黑屏
|
||
c.Header("Cache-Control", "no-cache")
|
||
c.File(dist + "/index.html")
|
||
return
|
||
}
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
})
|
||
|
||
// --- 管理 API ---
|
||
// 请求体上限 1MB:全部接口都是小 JSON(配置/密钥/用户),未认证面(login/register)
|
||
// 不给大 body 的内存放大空间。
|
||
api := r.Group("/api/v1", middleware.BodyLimit(1<<20))
|
||
{
|
||
auth := api.Group("/auth")
|
||
{
|
||
auth.POST("/register", h.Register)
|
||
auth.POST("/login", h.Login)
|
||
auth.POST("/refresh", h.Refresh)
|
||
auth.POST("/logout", h.Logout)
|
||
auth.POST("/password", middleware.SessionAuth(a), h.ChangePassword)
|
||
auth.GET("/me", middleware.SessionAuth(a), h.Me)
|
||
}
|
||
|
||
webauthn := api.Group("/webauthn")
|
||
{
|
||
webauthn.POST("/register/begin", middleware.SessionAuth(a), h.PasskeyRegisterBegin)
|
||
webauthn.POST("/register/complete", middleware.SessionAuth(a), h.PasskeyRegisterComplete)
|
||
webauthn.POST("/login/begin", h.PasskeyLoginBegin)
|
||
webauthn.POST("/login/complete", h.PasskeyLoginComplete)
|
||
webauthn.GET("/passkeys", middleware.SessionAuth(a), h.PasskeyList)
|
||
webauthn.DELETE("/passkeys/:id", middleware.SessionAuth(a), h.PasskeyDelete)
|
||
}
|
||
|
||
user := api.Group("", middleware.SessionAuth(a), h.InvalidateAuthOnWrite)
|
||
{
|
||
user.GET("/user/profile", h.UserProfile)
|
||
user.GET("/user/balance", h.UserBalance)
|
||
user.GET("/user/models", h.UserModels)
|
||
user.GET("/usage/summary", h.UsageSummary)
|
||
user.GET("/usage/stats", h.UsageStats)
|
||
user.GET("/usage/logs", h.UsageLogs)
|
||
user.POST("/keys", h.CreateKey)
|
||
user.GET("/keys", h.ListKeys)
|
||
user.PATCH("/keys/:id", h.PatchKey)
|
||
user.DELETE("/keys/:id", h.DeleteKey)
|
||
}
|
||
|
||
admin := api.Group("/admin", middleware.SessionAuth(a), middleware.AdminOnly, h.InvalidateCatalogOnWrite)
|
||
{
|
||
// 用户
|
||
admin.GET("/users", h.AdminUsers)
|
||
admin.PATCH("/users/:id", h.AdminPatchUser)
|
||
admin.POST("/users/:id/balance", h.AdminAdjustBalance)
|
||
// 渠道
|
||
admin.GET("/channels", h.AdminChannels)
|
||
admin.GET("/channels/stats", h.AdminChannelStats)
|
||
admin.POST("/channels", h.AdminCreateChannel)
|
||
admin.PUT("/channels/:id", h.AdminUpdateChannel)
|
||
admin.DELETE("/channels/:id", h.AdminDeleteChannel)
|
||
admin.POST("/channels/:id/test", h.AdminTestChannel)
|
||
admin.POST("/channels/:id/recover", h.AdminRecoverChannel)
|
||
admin.GET("/channels/:id/models/remote", h.AdminChannelRemoteModels)
|
||
admin.GET("/channels/:id/models", h.AdminChannelModels)
|
||
admin.POST("/channels/:id/models", h.AdminChannelAddModel)
|
||
admin.PATCH("/channels/:id/models/:bid", h.AdminChannelUpdateModel)
|
||
admin.DELETE("/channels/:id/models/:bid", h.AdminChannelDeleteModel)
|
||
// 模型与定价
|
||
admin.GET("/models", h.AdminModels)
|
||
admin.DELETE("/models/unused", h.AdminDeleteUnusedModels)
|
||
admin.POST("/models", h.AdminCreateModel)
|
||
admin.PUT("/models/:id", h.AdminUpdateModel)
|
||
admin.DELETE("/models/:id", h.AdminDeleteModel)
|
||
admin.POST("/models/:id/bindings", h.AdminCreateModelBinding)
|
||
admin.DELETE("/models/:id/bindings/:bid", h.AdminDeleteModelBinding)
|
||
// 统计与用量
|
||
admin.GET("/stats/overview", h.AdminStatsOverview)
|
||
admin.GET("/usage", h.AdminUsage)
|
||
admin.GET("/usage/:id", h.AdminUsageDetail)
|
||
// 配置
|
||
admin.GET("/config", h.AdminConfig)
|
||
admin.PUT("/config", h.AdminPutConfig)
|
||
// 充值审核(M6 预留)
|
||
}
|
||
}
|
||
|
||
r.GET("/healthz", func(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||
})
|
||
// Prometheus 指标端点:默认关闭——模型/渠道名与流量规模本身就是信息,
|
||
// 公网部署要开时建议在反代层做访问控制。
|
||
if a.Cfg.MetricsEnabled {
|
||
r.GET("/metrics", gin.WrapH(metrics.Handler()))
|
||
}
|
||
return r
|
||
}
|