Files
sundynix-agentix/sundynix-gateway/internal/router/router.go
T
Blizzard fd268664c8 fix(gateway): 报告生成绕过了落库/计费/RBAC —— 生成完就永远找不回
用户实测:报告页输入主题、点生成、看着没反应,切到别的页面再回来,什么都没了。
真相是报告在后端好好地生成完了(26s),是 UI 把它弄丢了、而且永远找不回。

根因:两条提交路径漂移。SubmitTask(POST /tasks) 这一年陆续长出了预算门控/
计费租户/积分硬拦截/落库/录像五道,而 GenerateReport(POST /reports) 还停在
最初的「发个 NATS」,一道都没有。后果远不止看不到历史:

- 不落库 → 运行历史(读 sundynix_task)永远看不到报告。实测修复前该表
  report_% 前缀 0 行。
- 无 token/轨迹录像 → SSE 没有回放能力,切走即永久丢失。
- 无 MetaTenantID → 报告用量记不到租户头上 = 漏账。
- 无预算门控/积分硬拦截 → 余额为 0 也能生成,绕过全部成本护栏。
- 路由漏了 RequireTenantRole → **viewer 只读角色能生成报告烧积分**,
  而隔壁 /tasks 的注释白纸黑字写着「viewer 只读拦下」。报告一样烧钱。

修法不是把代码抄一份(那只会再漂一次),而是抽两个共用函数:
  preflight() — 预算 → 计费租户 → 积分硬拦截,返回 billingTenant
  launch()    — 落库 + PublishTask + token/轨迹录像
两条路径都走它们,不可能再各长各的。SubmitTask 行为逐行不变。

live 验证(真账号 blizzardzhang,桌面端实机):生成 report_5dae9155af5cb500
→ sundynix_task 建行且带 owner+tenant_id → 出现在运行历史首条 → 点开
完整复盘 8 节点轨迹 + 报告全文。gin 路由表也确认 /reports 中间件数
12 → 13(与 /tasks 齐平)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:13:21 +08:00

173 lines
11 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 router 装配 Gin 统一接入层的路由与中间件。
package router
import (
"log"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"github.com/sundynix/sundynix-gateway/internal/blob"
"github.com/sundynix/sundynix-gateway/internal/handler"
"github.com/sundynix/sundynix-gateway/internal/middleware"
"github.com/sundynix/sundynix-gateway/internal/nats"
"github.com/sundynix/sundynix-gateway/internal/store"
)
// New 构建带有 Guardrail / 限流中间件的 Gin 引擎。
func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.Store) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery()) // panic 兜底
r.Use(otelgin.Middleware("sundynix-gateway")) // OTel: HTTP server span(链路根 + 提取上游 traceparent
r.Use(middleware.RequestID()) // 生成/透传 X-Request-ID(日志关联)
r.Use(middleware.Observe()) // Prometheus 指标 + 结构化访问日志(替代 gin 默认文本日志)
r.Use(cors()) // 桌面端/浏览器跨源访问
r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)——须在限流前,供按用户限流
r.Use(middleware.TenantContext(db)) // 多租户:注入当前 tenant_id(已登录才解析;须在 Auth 之后)
r.Use(middleware.SpaceContext(db)) // 共享工作区:注入当前 space_id(须在 TenantContext 之后)
r.Use(middleware.RateLimit(cache)) // 已认证按用户限流,否则按 IP(企业网多人共享 IP 不再互相拖累)
r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event
h := handler.New(db, cache, bus, blobStore)
// 可观测性根端点:Prometheus 抓取 + k8s 存活/就绪探针(不挂业务中间件鉴权)。
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
r.GET("/healthz", h.Healthz)
r.GET("/readyz", h.Readyz)
api := r.Group("/api/v1")
{
// —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)——
api.POST("/auth/register", h.Register) // 注册 + 签发 JWT
api.POST("/auth/login", h.Login) // 登录 + 签发 JWT
api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401)
api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯)
api.GET("/tasks/:id/stream", h.StreamTask) // SSE 回流 Token Streamtask_id 寻址)
api.GET("/tasks/:id/exec", h.StreamExec) // SSE 回流执行轨迹(task_id 寻址)
api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSEjob_id 寻址)
api.GET("/reports/:id/export", h.ExportReport) // 按需导出(report_id 寻址)
api.GET("/reports/:id/download", h.ExportReport) // 兼容旧入口(默认 docx
// —— 受保护:owner 作用域业务,必须携带有效 JWT ——
p := api.Group("", middleware.RequireAuth())
{
p.POST("/tasks", middleware.RequireTenantRole(db, store.RoleMember), h.SubmitTask) // 提交任务(烧租户积分):viewer 只读拦下
p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout/waiting/rejected
p.POST("/tasks/:id/approve", middleware.Audit(db), h.ApproveTask) // HITL 人工审批决定(批准/拒绝,审计)
p.GET("/tenants/current", h.TenantCurrent) // 当前租户上下文 + 角色 + 可花余额(多租户)
p.GET("/me/tenants", h.MyTenantsList) // 我所属租户(供切换)
p.POST("/me/tenant", h.SwitchTenant) // 切换当前活跃租户
p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗)
p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级)
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert
p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key=
p.GET("/kb/list", h.KbList) // 当前空间的知识库列表(共享工作区)
p.POST("/kb/create", middleware.RequireSpaceRole(db, store.RoleMember), h.KbCreate) // 新建知识库:空间只读 viewer 拦下
p.POST("/kb/ingest", middleware.RequireSpaceRole(db, store.RoleMember), h.KbIngest) // 文本入库:viewer 拦下
p.POST("/kb/ingest_file", middleware.RequireSpaceRole(db, store.RoleMember), h.KbIngestFile) // 文件入库:viewer 拦下
p.POST("/kb/search", h.KbSearch) // 检索台(读,全员)
p.GET("/kb/vault", h.KbVault) // 文库列表
p.GET("/kb/doc", h.KbDoc) // 取单篇文档
p.DELETE("/kb/doc", middleware.RequireSpaceRole(db, store.RoleMember), h.KbDeleteDoc) // 级联删文档:viewer 拦下
// Prompt 控制面(平台级配置:建版本 → 激活 → 控制面热下发各服务)
p.GET("/prompts", h.PromptList) // 列出全部版本 + 可配键
p.POST("/prompts/version", h.PromptCreateVersion) // 建新版本(不自动激活)
p.POST("/prompts/activate", middleware.Audit(db), h.PromptActivate) // 激活某版本 → 广播热更新(审计)
p.POST("/prompts/deactivate", middleware.Audit(db), h.PromptDeactivate) // 撤销激活 → 回退代码默认(热,审计)
p.GET("/kb/links", h.KbLinks) // 某库双链
p.POST("/kb/note", middleware.RequireSpaceRole(db, store.RoleMember), h.KbSaveNote) // 新建/编辑笔记:viewer 拦下
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
p.GET("/agents", h.AgentList) // 当前空间的编排列表(共享工作区,含创建人)
p.POST("/agents", middleware.RequireSpaceRole(db, store.RoleMember), h.AgentSave) // 保存/更新编排:空间只读 viewer 拦下
p.DELETE("/agents", middleware.RequireSpaceRole(db, store.RoleMember), h.AgentDelete) // 删除编排:viewer 拦下(删他人另需 admin,见 handler
// 共享工作区(Space):切换 / 列表 / 建 / 成员管理(增量3)
p.GET("/me/spaces", h.SpacesList) // 活跃租户内我所属的空间(供切换)
p.POST("/me/space", h.SwitchSpace) // 切换活跃空间
p.GET("/spaces/current", h.SpaceCurrent) // 当前空间上下文 + 我的角色
p.POST("/spaces", middleware.RequireTenantRole(db, store.RoleMember), h.SpaceCreate) // 建空间:租户只读 viewer 拦下
p.GET("/spaces/:id/members", h.SpaceMembers) // 空间成员列表
p.POST("/spaces/:id/members", h.SpaceAddMember) // 拉人进空间(handler 内校验空间 admin
p.PUT("/spaces/:id/members/:uid", h.SpaceSetMemberRole) // 改空间成员角色
p.DELETE("/spaces/:id/members/:uid", h.SpaceRemoveMember) // 移除空间成员
p.POST("/spaces/:id/archive", h.SpaceArchive) // 归档空间
p.POST("/reports", middleware.RequireTenantRole(db, store.RoleMember), h.GenerateReport) // 报告生成(同样烧租户积分):viewer 只读拦下
p.GET("/billing", h.Billing)
p.GET("/stats/overview", h.StatsOverview) // 工作台仪表盘聚合
p.GET("/runs", h.Runs) // 运行历史(复盘)
p.GET("/tasks/:id/replay", h.TaskReplay) // 历史运行复盘(持久化输出+轨迹,免 Redis TTL)
}
// 运维控制面:LLM 模型配置(含 API 密钥管理)—— 必须管理员(RequireAdmin)。
admin := api.Group("/admin", middleware.RequireAdmin(), middleware.Audit(db))
{
admin.GET("/models", h.ListModels)
admin.POST("/models", h.SaveModel)
admin.POST("/models/:id/active", h.SetActiveModel)
admin.DELETE("/models/:id", h.DeleteModel)
admin.POST("/models/test", h.TestModel)
admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱 + 积分权重)
admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价 + 积分权重
admin.GET("/billing-config", h.BillingConfig) // 全局计费规则(token→积分汇率 + 硬拦截开关)
admin.PUT("/billing-config", h.SaveBillingConfig)
admin.POST("/credits/grant", h.GrantCredits) // 给租户充值/发放积分
// 多租户成员管理(平台运维口径)
admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额)
admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner
admin.PUT("/tenants/:id/shared-billing", h.AdminSetSharedBilling) // 共享计费开关
admin.GET("/tenants/:id/members", h.AdminMembers) // 成员列表
admin.POST("/tenants/:id/members", h.AdminAddMember) // 按邮箱加成员
admin.PUT("/tenants/:id/members/:uid", h.AdminSetMemberRole) // 改角色
admin.DELETE("/tenants/:id/members/:uid", h.AdminRemoveMember) // 移除成员
admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康
admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额
admin.POST("/migrate-kb-storage", h.MigrateKBStorage) // 增量3:存量 KB 三库 owner/kb→space/kb 重灌(一次性)
admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页)
admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页)
}
}
return r
}
// cors 控制跨源访问。允许来源经 CORS_ALLOW_ORIGIN 配置。
// 安全默认:开发期缺省放行 "*"(便利);生产期(APP_ENV=prod / GIN_MODE=release)未显式配置则
// **不放行任意源**(不发 ACAO 头 → 浏览器按同源策略拦截),逼运维显式配置允许的源。
func cors() gin.HandlerFunc {
origin := os.Getenv("CORS_ALLOW_ORIGIN")
if origin == "" {
if isProd() {
log.Printf("[cors] 生产环境未设 CORS_ALLOW_ORIGIN:默认不放行跨源,请显式配置允许的源(如 https://app.example.com")
} else {
origin = "*" // 开发便利
}
}
return func(c *gin.Context) {
if origin != "" {
c.Header("Access-Control-Allow-Origin", origin)
if origin != "*" {
c.Header("Vary", "Origin")
}
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-ID, X-User-ID")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
// isProd 判定生产环境(与 middleware.RequireAdmin 同口径)。
func isProd() bool {
return strings.EqualFold(os.Getenv("APP_ENV"), "production") ||
strings.EqualFold(os.Getenv("APP_ENV"), "prod") ||
strings.EqualFold(os.Getenv("GIN_MODE"), "release")
}