Files
openteam/server/internal/api/router.go
T
Sakurasan c39c3e7da4 feat: 补齐上游细节、渠道观测与模型定价元数据
- 上游 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 过滤与双形状、缓存写价两条路径、失败归因。
2026-10-17 04:09:39 +08:00

151 lines
5.2 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))
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
}