addaa1b34f
引入 Space 中间容器(租户>Space>成员),资源作用域从 owner 改为 space_id,
让"个人私有/项目临时组队/整租户共享"出自同一模型(设计见 SPACE_DESIGN.md)。
先在纯 PG 的 Agent 上打样,零存储风险,验证协作+RBAC+切换 UX。
后端:
- 新表 Space{tenant_id,name,kind,creator,archived} + SpaceMember{space_id,user_id,role}
(如 Tenant 般不 isTenantScoped);User.ActiveSpaceID;Agent 作用域 owner→space_id,
owner 降级为创建人(供 UI 显示 / 删他人鉴权)
- store/space.go:个人空间幂等/活跃空间解析/切换/列表/建/成员CRUD/归档
- 迁移顺序坑:结构体只放非唯一 index,MigrateAgentSpaces 回填 space_id 后再建唯一
索引 idx_agent_sn + DROP 旧 idx_agent_on(否则存量空 space_id 撞车);启动序4步幂等
- 中间件 SpaceContext(注入 space_id) + RequireSpaceRole(照 RequireTenantRole)
- handler/space.go 空间端点 + 路由;agent.go 改空间作用域(删/覆盖他人需 admin)
- 计费零改动(Space 与 ResolveBillingTenantID 正交)
桌面端:
- api.ts space 接口;顶栏 SpaceSwitcher(含新建项目空间);StudioView 随空间切换
重拉编排 + viewer 禁保存;Agent 列表显示创建人 + 按 mine 控删除
验证:中间件6门控单测 + DB迁移(13个人空间/9 Agent全re-key/索引换新) + 后端HTTP全
场景(member见他人编排/删他人403、viewer存403、非成员切空间400+隔离、owner删他人200)
+ 浏览器实机(切换器3空间/Studio空间编排随切换隔离刷新/创建人显示/console无错)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
172 lines
10 KiB
Go
172 lines
10 KiB
Go
// 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 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", 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) // 当前用户的知识库列表(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.DELETE("/kb/doc", h.KbDeleteDoc) // 级联删文档(三库+MinIO+PG)
|
||
|
||
// 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", h.KbSaveNote) // 新建/编辑笔记
|
||
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", 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(), 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.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")
|
||
}
|