// Package router 装配 Gin 统一接入层的路由与中间件。 package router import ( "context" "io/fs" "log" "net/http" "os" "path/filepath" "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/handler" "github.com/sundynix/sundynix-gateway/internal/middleware" "github.com/sundynix/sundynix-gateway/internal/nats" "github.com/sundynix/sundynix-gateway/internal/store" "github.com/sundynix/sundynix-gateway/internal/webui" "github.com/sundynix/sundynix-shared/blob" ) // 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) // 微信支付渠道装配:DB 配置优先(admin 控制面热重载)→ env 兜底 → 隐藏。失败不阻断启动。 h.InitWechat(context.Background()) // 掉单补偿定时器:周期扫 pending 微信单确认到账(用户扫完码关页面也能补入账)。 h.StartReconcile(context.Background()) h.StartSubscriptionTicker(context.Background()) // 订阅按周期发放积分 + 到期置失效 // 可观测性根端点:Prometheus 抓取 + k8s 存活/就绪探针(不挂业务中间件鉴权)。 r.GET("/metrics", gin.WrapH(promhttp.Handler())) r.GET("/healthz", h.Healthz) r.GET("/readyz", h.Readyz) // 微信公众号消息推送回调(服务器配置 URL):GET 验签回 echostr,POST 收关注/扫码事件。 // 顶级路径(非 /api),微信服务器直接访问,无鉴权头。 r.GET("/wx/mp/callback", h.WxMPVerify) r.POST("/wx/mp/callback", h.WxMPEvent) api := r.Group("/api/v1") { // —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)—— api.GET("/pricing", h.PublicPricing) // 公开定价(官网未登录也要能看价,故不挂鉴权) // 微信扫码登录(全公开:ticket 是唯一凭证;PC 建票与轮询都无鉴权头) api.POST("/wx/mp/ticket", h.WxMPTicket) // PC 建票 + 微信二维码 api.GET("/wx/mp/poll", h.WxMPPoll) // PC 轮询登录态 // 登录/注册爆破面大:在全局限流之上再叠一道更严的按 IP 专用限流(10/min)。 api.POST("/auth/register", middleware.RateLimitN(cache, 10, "auth-reg"), h.Register) // 注册 + 签发 JWT api.POST("/auth/login", middleware.RateLimitN(cache, 10, "auth-login"), h.Login) // 登录 + 签发 JWT api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401) api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯) // 这些 by-id 端点带不了 Bearer 头(EventSource/下载),改由 AuthFromHeaderOrQuery // 从 ?token= 取 JWT 强制鉴权;task/report 三个再按 owner 归属校验(handler 内)。 api.GET("/tasks/:id/stream", middleware.AuthFromHeaderOrQuery(), h.StreamTask) // SSE 回流 Token Stream api.GET("/tasks/:id/exec", middleware.AuthFromHeaderOrQuery(), h.StreamExec) // SSE 回流执行轨迹 api.GET("/kb/ingest/:id/stream", middleware.AuthFromHeaderOrQuery(), h.KbIngestStream) // 入库进度 SSE(登录即可,进度非敏感) api.GET("/reports/:id/export", middleware.AuthFromHeaderOrQuery(), h.ExportReport) // 按需导出 api.GET("/reports/:id/download", middleware.AuthFromHeaderOrQuery(), h.ExportReport) // 兼容旧入口(默认 docx) api.POST("/billing/callback/:channel", h.PaymentCallback) // 支付回调(渠道服务器带不了 Bearer;渠道验签是唯一的门) api.GET("/voice/stream", middleware.AuthFromHeaderOrQuery(), h.VoiceStream) // 语音会话 WebSocket(?token= 鉴权,WS 带不了 Bearer 头) api.GET("/local/runner", middleware.AuthFromHeaderOrQuery(), h.LocalRunner) // 桌面本地执行器注册 WebSocket(JARVIS 本地的手) // —— 受保护: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.RequireTenantRole(db, store.RoleMember), middleware.Audit(db), h.ApproveTask) // HITL 人工审批:≥member(放行会烧租户积分,viewer 拦下),审计 p.GET("/tenants/current", h.TenantCurrent) // 当前租户上下文 + 角色 + 可花余额(多租户) p.GET("/me/tenants", h.MyTenantsList) // 我所属租户(供切换) p.POST("/me/tenants", h.CreateMyTenant) // 自助建组织(创建者即 owner,建完切入) p.POST("/me/tenant", h.SwitchTenant) // 切换当前活跃租户 // 租户成员自助管理(薄 Web 面):作用于活跃租户,看 ≥viewer、写 ≥admin + 审计。 p.GET("/tenants/current/members", middleware.RequireTenantRole(db, store.RoleViewer), h.TenantMembers) p.POST("/tenants/current/members", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantAddMember) p.PUT("/tenants/current/members/:uid", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantSetMemberRole) p.DELETE("/tenants/current/members/:uid", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantRemoveMember) // 二维码邀请(可复用团队码):看 ≥viewer,建/撤销 ≥admin + 审计。扫码入组走 /wx/mp/callback。 p.GET("/tenants/current/invites", middleware.RequireTenantRole(db, store.RoleViewer), h.ListTenantInvites) p.POST("/tenants/current/invites", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.CreateTenantInvite) p.DELETE("/tenants/current/invites/:id", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.RevokeTenantInvite) p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗) p.GET("/me/jarvis", h.GetMyJarvis) // 我的 JARVIS 设置(名字/人设/自带豆包) p.PUT("/me/jarvis", h.SaveMyJarvis) // 保存我的 JARVIS 设置 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 控制面(平台级配置:建版本 → 激活 → 控制面热下发各服务)。 // 全平台配置、activate 热广播到所有服务、影响所有租户输出 —— 必须 RequireAdmin, // 否则任意登录用户可改全局提示词。读写同源都挂 admin。 p.GET("/prompts", middleware.RequireAdmin(), h.PromptList) // 列出全部版本 + 可配键 p.POST("/prompts/version", middleware.RequireAdmin(), middleware.Audit(db), h.PromptCreateVersion) // 建新版本(不自动激活) p.POST("/prompts/activate", middleware.RequireAdmin(), middleware.Audit(db), h.PromptActivate) // 激活某版本 → 广播热更新(审计) p.POST("/prompts/deactivate", middleware.RequireAdmin(), 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) // 充值(P5.1 兑换码 + P5.2 微信 Native):动钱的 ≥member + 审计;查询全员可看。 p.GET("/billing/packs", h.BillingPacks) p.GET("/billing/sub-plans", h.BillingSubPlans) // 在售订阅套餐 p.GET("/billing/subscription", h.MySubscription) // 我的订阅(到期时间/发放次数) p.GET("/billing/orders", h.BillingOrders) p.GET("/billing/orders/:id", h.BillingOrderStatus) // 轮询单态(pending 时顺路主动查单确认) p.POST("/billing/redeem", middleware.RequireTenantRole(db, store.RoleMember), middleware.Audit(db), h.BillingRedeem) p.POST("/billing/orders", middleware.RequireTenantRole(db, store.RoleMember), middleware.Audit(db), h.BillingCreateOrder) 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) // 给租户充值/发放积分 // 支付配置面(P5.1/P5.2):兑换码生成/查看 + 积分包配置 + 微信支付配置(DB 热生效) admin.POST("/redeem-codes", h.AdminGenRedeemCodes) admin.GET("/redeem-codes", h.AdminRedeemCodes) admin.GET("/packs", h.AdminPacks) admin.PUT("/packs", h.AdminSavePack) admin.GET("/wechat-mp", h.AdminGetWechatMP) // 微信扫码登录配置 admin.PUT("/wechat-mp", h.AdminSaveWechatMP) admin.GET("/wechat-users", h.AdminWechatUsers) // 微信用户列表(openid/加入时间/余额) admin.GET("/payment/wechat", h.AdminGetWechatPay) admin.PUT("/payment/wechat", h.AdminSaveWechatPay) admin.GET("/voice", h.AdminGetVoiceConfig) // 语音(火山豆包)配置 admin.PUT("/voice", h.AdminSaveVoiceConfig) admin.GET("/sub-plans", h.AdminSubPlans) // 订阅套餐(含下架) admin.PUT("/sub-plans", h.AdminSaveSubPlan) // 配价格/时长/发放节奏 admin.GET("/subscriptions", h.AdminSubscriptions) // 全平台订阅观测 admin.GET("/orders", h.AdminOrders) // 全平台充值订单流 + 状态计数 admin.GET("/orders/reconcile", h.AdminReconcile) // 日终对账:paid 单 ↔ 账本 grant admin.POST("/orders/:id/refund", h.AdminRefundOrder) // 人工退款:置 refunded + adjust 负分录 + 回退余额(审计) // 多租户成员管理(平台运维口径) admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额) admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner) admin.PUT("/tenants/:id/shared-billing", h.AdminSetSharedBilling) // 共享计费开关 admin.PUT("/tenants/:id/plan", h.AdminSetTenantPlan) // 方案等级 admin.PUT("/tenants/:id/status", h.AdminSetTenantStatus) // 租户状态 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("/tasks", h.AdminTasks) // 全平台任务/运行观测(状态/租户筛 + HITL 待审批) admin.GET("/tasks/:id", h.AdminTaskDetail) // 任务下钻:DSL/输出/轨迹/评测(跨租户,走 WithoutTenant) admin.GET("/spaces", h.AdminSpaces) // 全平台空间观测(跨租户 Space + 成员数) admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额 admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据) admin.GET("/datasources", h.AdminDatasources) // 数据源清单:全平台知识库 + 文档数(真数据) admin.POST("/kb/search", h.AdminKbSearch) // 检索试验台:按完整作用域键跨租户检索(支持单路 mode 对比) admin.POST("/migrate-kb-storage", h.MigrateKBStorage) // 增量3:存量 KB 三库 owner/kb→space/kb 重灌(一次性) admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页) admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页) } } // ── 内嵌 admin 运维控制台:非 API 路径走 SPA ── // admin 用 HashRouter,深链走 /#/...,服务端只会收到对 / 和 /assets/* 的请求: // 命中真实文件直吐,其余回退根 index.html 交前端接管。/api/、/metrics、/healthz、 // /readyz 都是已注册路由,永不进 NoRoute。embed 加载失败仅降级(控制台不可用),不影响 API。 if adminDist, err := webui.Dist(); err != nil { log.Printf("[gateway] admin 控制台静态资源加载失败(UI 不可用,API 不受影响): %v", err) } else { adminServer := http.FileServer(http.FS(adminDist)) r.NoRoute(func(c *gin.Context) { p := c.Request.URL.Path if strings.HasPrefix(p, "/api/") { c.JSON(http.StatusNotFound, gin.H{"error": "接口不存在"}) return } // 微信域名校验文件(MP_verify_xxx.txt):配置 JS安全域名/网页授权域名时微信要求 // 能从域名根目录直接访问。文件放 WECHAT_VERIFY_DIR(容器里挂宿主机目录), // 不进镜像也不进 git。 // 放在 NoRoute 里而不是注册 /:mpfile —— 后者会匹配**所有单段路径**, // 把官网的 /pricing、/download 全变成 404(实测踩过)。 if dir := os.Getenv("WECHAT_VERIFY_DIR"); dir != "" { name := strings.TrimPrefix(p, "/") // 只放行这一种文件名,且不含路径分隔符,避免变成任意文件下载口子 if strings.HasPrefix(name, "MP_verify_") && strings.HasSuffix(name, ".txt") && !strings.ContainsAny(name, `/\`) && !strings.Contains(name, "..") { c.File(filepath.Join(dir, name)) return } } if rel := strings.TrimPrefix(p, "/"); rel != "" { if _, statErr := fs.Stat(adminDist, rel); statErr == nil { adminServer.ServeHTTP(c.Writer, c.Request) // /assets/* 等真实文件 return } } c.Request.URL.Path = "/" // 其余回退 index.html adminServer.ServeHTTP(c.Writer, c.Request) }) } return r } // cors 控制跨源访问。允许来源经 CORS_ALLOW_ORIGIN 配置,支持逗号分隔多值 // (桌面端 Web 预览 + 薄 Web 面 + admin 可能是不同 origin):命中则回显请求 Origin。 // 安全默认:开发期缺省放行 "*"(便利);生产期(APP_ENV=prod / GIN_MODE=release)未显式配置则 // **不放行任意源**(不发 ACAO 头 → 浏览器按同源策略拦截),逼运维显式配置允许的源。 func cors() gin.HandlerFunc { raw := os.Getenv("CORS_ALLOW_ORIGIN") if raw == "" { if isProd() { log.Printf("[cors] 生产环境未设 CORS_ALLOW_ORIGIN:默认不放行跨源,请显式配置允许的源(逗号分隔多个,如 https://app.example.com,https://console.example.com)") } else { raw = "*" // 开发便利 } } allowed := map[string]bool{} for _, o := range strings.Split(raw, ",") { if o = strings.TrimSpace(o); o != "" { allowed[o] = true } } return func(c *gin.Context) { switch { case allowed["*"]: c.Header("Access-Control-Allow-Origin", "*") case allowed[c.GetHeader("Origin")]: // 多 origin 场景 ACAO 只能发单值:命中白名单则回显请求方,并以 Vary 防缓存串源。 c.Header("Access-Control-Allow-Origin", c.GetHeader("Origin")) c.Header("Vary", "Origin") case len(allowed) == 1: // 单值配置保持旧行为:无条件直写(兼容不带 Origin 头的探活/工具请求也能看到头)。 for o := range allowed { c.Header("Access-Control-Allow-Origin", o) } 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") }