Files
openteam/server/internal/api/router.go
T
Sakurasan 8e5fe16611 perf: 鉴权缓存让命中请求零 SQL,去掉两个无用索引
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)全绿。
2026-10-30 16:31:41 +08:00

154 lines
5.5 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 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
}