d2662a1f37
问题:历史任务超 Redis 流 10min TTL 后,SSE 回放在空流上永久阻塞 → 运行页卡「流式中…」、
轨迹/工具/输出全空。
修复:收尾把最终输出 + 执行轨迹持久化到 PG,历史复盘改读库(不依赖 Redis TTL):
- store:Task 加 output/trace 两列;SaveTaskOutput/SaveTaskTrace/GetRunDetail。
trace 用 type:text(不是 jsonb)——否则提交时空串 "" 入 jsonb 列会 INSERT 失败、整条任务不落库。
(已 ALTER 既有 trace 列 jsonb→text。)
- gateway:token/exec 录制器在 done 时把累计的输出/轨迹快照落库。
- 新增 GET /tasks/:id/replay 返回持久化的 {output, exec}。
- RunsView:选中历史运行改 runReplay() 读库(秒回、phase 立即 done/error),不再 SSE 回放。
即便旧任务无持久化数据,也是 done+空态,绝不再卡「流式中…」。
live:新任务落库 output 305 字(含表格) + 轨迹 5 事件,/replay 正确返回;tsc+vite、gateway 全绿。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
117 lines
6.0 KiB
Go
117 lines
6.0 KiB
Go
// Package router 装配 Gin 统一接入层的路由与中间件。
|
||
package router
|
||
|
||
import (
|
||
"os"
|
||
|
||
"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.RateLimit(cache))
|
||
r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)
|
||
r.Use(middleware.Guardrail()) // Harness: Input Guardrail
|
||
|
||
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 Stream(task_id 寻址)
|
||
api.GET("/tasks/:id/exec", h.StreamExec) // SSE 回流执行轨迹(task_id 寻址)
|
||
api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(job_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", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid)
|
||
p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout/waiting/rejected)
|
||
p.POST("/tasks/:id/approve", h.ApproveTask) // HITL 人工审批决定(批准/拒绝)
|
||
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) // 当前用户的知识库列表(owner 隔离)
|
||
p.POST("/kb/create", h.KbCreate) // 新建知识库
|
||
p.POST("/kb/ingest", h.KbIngest) // 文本入库
|
||
p.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库
|
||
p.POST("/kb/search", h.KbSearch) // 检索台
|
||
p.GET("/kb/vault", h.KbVault) // 文库列表
|
||
p.GET("/kb/doc", h.KbDoc) // 取单篇文档
|
||
p.GET("/kb/links", h.KbLinks) // 某库双链
|
||
p.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记
|
||
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
|
||
p.GET("/agents", h.AgentList) // 我的编排列表(owner 隔离)
|
||
p.POST("/agents", h.AgentSave) // 保存/更新编排
|
||
p.DELETE("/agents", h.AgentDelete) // 删除编排
|
||
p.POST("/reports", h.GenerateReport) // 报告生成
|
||
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())
|
||
{
|
||
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("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
|
||
}
|
||
}
|
||
return r
|
||
}
|
||
|
||
// cors 控制跨源访问。允许来源经 CORS_ALLOW_ORIGIN 配置(缺省 "*" 仅供开发;
|
||
// 生产应设为具体源,如 https://app.example.com)。Vary 保证按 Origin 正确缓存。
|
||
func cors() gin.HandlerFunc {
|
||
origin := "*"
|
||
if v := os.Getenv("CORS_ALLOW_ORIGIN"); v != "" {
|
||
origin = v
|
||
}
|
||
return func(c *gin.Context) {
|
||
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()
|
||
}
|
||
}
|