- 上游 429 的 Retry-After 转成渠道冷却时长(夹在 10 分钟内;比默认值短则不缩短, 否则 1s 就把坏渠道放回轮转)。失败计数改由 proxyOne 就地报——只有它读得到响应头。 - 故障转移时间预算 OT_PROXY_FAILOVER_BUDGET(默认 30s):只约束第二个及以后的渠道, 首个不受限(长回答本就要跑几分钟),但坏渠道不再把客户端拖到 N×timeout。 - 流式空闲心跳 OT_PROXY_STREAM_HEARTBEAT(默认 15s):补一行 SSE 注释 `: ping`, 防止 nginx/Cloudflare 的空闲超时掐掉长思考。心跳与正文共用一把写的锁, gin 的 ResponseWriter 不是并发安全的;注释行不进转换也不计费。 - 渠道观测:GET /api/v1/admin/channels/stats?window=5m|1h|6h|24h|7d 聚合请求量、 成功率、P50/P95/max 延迟、成本与 top_errors;分位只取成功样本,canceled(客户端 主动断开)不计入渠道错误率。渠道页桌面表格加「近N小时」列、移动端加同内容条, 顶部可切窗口。 - 修一个让统计瞎掉的归因问题:全部渠道失败时 recordError 传的是 nil, usage_logs 里 channel_id=0,这类失败在渠道维度完全看不见。改为归到最后一个 真正试过的渠道。 - 模型定价与元数据:Model 新增 cache_write_price(0 则回退输入价×1.25,可显式配 Anthropic 1h 的 ×2)、context_length 与 supports_tools/vision/reasoning; finishUsage 走 CacheWritePriceOr()。/v1/models 叠加用户与密钥白名单过滤 (原来会把限制外的模型名直接泄漏在列表里)并返回元数据,价格不外泄。 - /v1/models 的 Anthropic 分支原是死代码:协议只按路径判,而两种客户端共用这个路径。 改为按 x-api-key / anthropic-version 头判,OpenAI SDK 从不发这两个头,互不影响。 - NewGateway 的十个位置参数换成 Options 结构体(三个 Duration 两个 int,传错编译期发现不了)。 新增 6 个用例:Retry-After 两种写法与夹顶、重试预算截止、空闲心跳出帧、 /v1/models 过滤与双形状、缓存写价两条路径、失败归因。
151 lines
5.2 KiB
Go
151 lines
5.2 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))
|
||
|
||
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 ---
|
||
api := r.Group("/api/v1")
|
||
{
|
||
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))
|
||
{
|
||
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)
|
||
{
|
||
// 用户
|
||
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("/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
|
||
}
|