profile(16 并发)显示请求路径的 CPU 有 ~70% 花在鉴权上:每个代理请求都要用 密钥哈希查 api_keys、再用 user_id 查 users,两条随机读。 - 新增鉴权缓存(OT_PROXY_AUTH_CACHE_TTL,默认 2s,0=关闭):缓存密钥与用户行, 但密钥状态、用户状态、密钥过期仍按当前时间逐请求判定;管理端写操作与用户的 密钥写操作经中间件立即清空缓存。余额随用户行一起缓存,只影响「≤0 就 402」 这道软闸,最长滞后一个 TTL(请求照样计费)。 - last_used_at 节流改由缓存内的 touch 表记账(原先按 DB 行时间戳判断,行被缓存后 时间戳不再变化,会导致每请求都写一次库)。touch 用 sync.Map:早先与缓存共用一把 排他锁,16 并发下互斥饥饿把吞吐从 31k 拖到 3.2k(p99 7ms),换 sync.Map 后消失。 - 去掉 usage_logs 上从未被查询过滤的 key_id / trace_id 索引(没有查询按它们过滤, 每个索引都是每行一次 B-tree 插入);schema v8 迁移里显式 DROP 旧库上的索引。 实测(16 并发、mock 上游、SQLite): - 鉴权缓存开:p50 131–177µs、p99 0.6–1.2ms、RPS 56k–81k - 鉴权缓存关:p50 535–631µs、p99 1.6–2.8ms、RPS 21k–27k (4 并发下同样受益:30k→41k) 测试:新增缓存命中零 SQL、状态/过期重判、失效、TTL 到期、缓存关闭、并发七项; go vet、go test ./...、-race(proxy/usage/store/api)全绿。
154 lines
5.5 KiB
Go
154 lines
5.5 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/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"})
|
||
})
|
||
return r
|
||
}
|