Files
sundynix-agentix/sundynix-gateway/internal/router/router.go
T
Blizzard 348f1e0249 feat(jarvis): 能动的手(写文件/执行命令,三道闸) + 定时任务调度
此前 JARVIS 只能看不能动(本地工具纯只读)、也不会调度,补齐这两块。

【能动的手】local_write_file / local_exec,在用户自选工作目录内动手:
- 独立开关:只开只读访问不给这能力,须单独勾「允许写文件/执行命令」
- 原生确认框逐次审批:展示命令原文,默认按钮=拒绝,60s 无人应答按拒绝
  (防无人值守被静默批准);可选「本次会话都允许」,关开关即失效
- 硬黑名单:删库/提权/管道下载执行/写系统路径/装开机项/摸凭据等,
  用户点同意也不执行,连审批框都不弹。20 条危险命令 + 10 条正常命令单测
- 命令 cwd 锁沙箱根、60s 超时、输出 16KB 截断;非零退出不算失败(编译/测试
  错误对模型是有用信息)

【定时任务】sundynix_schedule + leader 锁 ticker(30s 扫) + 三个平台工具:
- 存自然语言指令而非编排图,到点走语音同一条关卡(preflightCore/launchCore)
  执行,跑完经语音事件主动播报结果
- 先推进 NextRunAt 再提交:提交失败也不会下轮重复捞起反复烧钱
- 停机期间错过的不补跑(补一堆历史提醒是骚扰),直接顺推到下一个未来时刻

【顺带修一个必崩的 bug】dispatcher 工具超时硬编码 3 秒,而审批要等人点
(60s)+执行(60s)——local_exec 100% 超时。改成工具在 list_tools 自报
timeout_sec(不在 dispatcher 硬编码工具名),超时链外松内紧:
dispatcher 160s > 网关 150s > runner 转发 140s > 桌面端 60+60s。

live 验证:①「写个 hello.sh 打印日期然后跑一下」→ 写+执行两步,文件真落磁盘
②「建个定时任务 35 秒后跑 wc -l」→ 到点自动触发 → 自主调 local_exec → 出结果

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:57:49 +08:00

302 lines
22 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()) // 订阅按周期发放积分 + 到期置失效
h.StartScheduleTicker(context.Background()) // JARVIS 定时任务:到点执行用户存的指令 + 播报结果
// 可观测性根端点: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 轮询登录态
// 登录/注册爆破面大:在全局限流之上再叠一道更严的按 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")
}