Files
sundynix-agentix/sundynix-gateway/internal/router/router.go
T
Blizzard 883540bd7e feat(auth): 微信扫码登录改为「带参二维码 + 关注/扫码事件」(登录即涨粉)
上一版做成了网页授权(OAuth 允许页),方向错了。改成用户要的流程:
扫码 → 弹公众号关注页 → 关注即登录,服务号顺带涨粉。

流程:PC 建票 → 后端用 access_token 调「带参数二维码」接口(scene=ticket) →
展示微信二维码图 → 用户扫码关注 → 微信推 subscribe/SCAN 事件到 /wx/mp/callback →
按 openid 找/建用户 → ticket 置 authorized → PC 轮询拿 JWT。

明文模式(消息加解密):回调只验签名 sha1(sort(token,ts,nonce)),不做 AES。

关键实现点:
- access_token 缓存进 Redis(跨实例共享,避免重复拉取互相失效)+ 进程内锁双检;
- 事件同时处理 subscribe(未关注,EventKey 带 qrscene_ 前缀)与 SCAN(已关注,不带);
- 事件回调必须验签——否则任何人 POST 一个 openid 就能登录别人;
- 回调无论如何回 "success",否则微信重试并给用户弹"公众号故障";
- User.wechat_openid 用部分唯一索引(WHERE <> ''),避开存量空串互撞。

配置(appid/secret/token)后台可改、secret AES 加密入库。管理端「运维 → 登录设置」
列出还需在公众平台做的事(服务器 URL / Token 一致 / 明文模式 / IP 白名单)。

本地验证(真流程,非 mock):验签回 echostr 与微信算法一致;模拟 subscribe 事件
→ 建号 + 置票 → PC 轮询拿到 token+user → 库里确有该 openid 用户。真微信推真事件
留待部署后扫码。前端 web 登录页加「微信扫码/邮箱」双 tab,默认微信。

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

285 lines
20 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 (
"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 验签回 echostrPOST 收关注/扫码事件。
// 顶级路径(非 /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 轮询登录态
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
api.POST("/billing/callback/:channel", h.PaymentCallback) // 支付回调(渠道服务器带不了 Bearer;渠道验签是唯一的门)
// —— 受保护: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/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)
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)
// 充值(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("/payment/wechat", h.AdminGetWechatPay)
admin.PUT("/payment/wechat", h.AdminSaveWechatPay)
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")
}