feat(gateway): 多租户2b-A —— Task 加 owner/tenant 隔离 + 系统旁路修 admin 全局口径

- store.WithoutTenant(ctx):显式跨租户旁路,插件即使 ctx 带租户也不过滤。
  修复增量2 引入的回归:admin SystemCounts 数 KB 时被 admin 自己租户误过滤。
  AdminOverview 改用旁路 ctx → 任务/KB/Doc/Eval 恢复全平台口径。
- Task 加 TenantID(插件自动填) + Owner(提交者 user.id) + isTenantScoped()。
  SaveTask 记录 owner;RecentTasks/RecentRuns 按 owner+租户过滤"我的运行"。
  RecentRuns 是 raw Table 查询绕过插件,手动补 owner+tenant WHERE。

live 验证:A 提交任务 → 行 owner/tenant 自动填对 ✓;A 的 /runs 只见己方、
B 空、legacy 无 owner 行被排除 ✓;admin/overview tasks_total=39/kb_count=21
= DB 全局真值(旁路生效,回归已修)✓。

Eval/Doc/DocLink 的 tenant 传播见 2b-B。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-07 09:18:15 +08:00
parent 30d667954b
commit ee1e9cfdca
5 changed files with 46 additions and 19 deletions
+8 -4
View File
@@ -17,15 +17,19 @@ type User struct {
// 业务 idtask_xxx,用于 NATS subject/stream)单列 TaskID,主键统一雪花。
type Task struct {
BaseModel
TaskID string `gorm:"uniqueIndex;size:64"` // task_xxx
Graph string `gorm:"type:jsonb"` // React Flow 导出的 DSL 原文
Status string `gorm:"size:32"` // submitted / running / done / failed / timeout
Detail string `gorm:"type:text"` // 失败/超时原因等(状态机回写)
TenantID string `gorm:"size:64;index"` // 多租户作用域(tenant 插件按 ctx 自动填/过滤)
Owner string `gorm:"size:64;index"` // 提交者 user.id(个人工作台按此过滤"我的运行")
TaskID string `gorm:"uniqueIndex;size:64"` // task_xxx
Graph string `gorm:"type:jsonb"` // React Flow 导出的 DSL 原文
Status string `gorm:"size:32"` // submitted / running / done / failed / timeout
Detail string `gorm:"type:text"` // 失败/超时原因等(状态机回写)
// 收尾持久化:供「运行历史复盘」永久回放(Redis 流仅 10min TTL,过期后历史任务靠这两列)。
Output string `gorm:"type:text"` // 最终模型输出(收尾时由网关从流快照落库)
Trace string `gorm:"type:text"` // 执行轨迹事件 JSON 数组(存为文本,容忍空串;不在库内查它)
}
func (Task) isTenantScoped() {}
// Eval 是一次任务的自动化评测结果(dispatcher 评完经 NATS 回写,每任务一条,按 task_id upsert)。
type Eval struct {
BaseModel
+16 -9
View File
@@ -117,11 +117,12 @@ func migrateDocLinkToID(db *gorm.DB) {
func (p *Postgres) Enabled() bool { return p.db != nil }
// SaveTask 持久化一次任务提交(best-effort:降级模式下静默跳过)。
func (p *Postgres) SaveTask(ctx context.Context, id, graph string) error {
func (p *Postgres) SaveTask(ctx context.Context, owner, id, graph string) error {
if p.db == nil {
return nil
}
return p.db.WithContext(ctx).Create(&Task{TaskID: id, Graph: graph, Status: contract.TaskSubmitted}).Error
// TenantID 由 tenant 插件按请求 ctx 自动填;Owner 显式记录提交者(供个人工作台过滤)。
return p.db.WithContext(ctx).Create(&Task{Owner: owner, TaskID: id, Graph: graph, Status: contract.TaskSubmitted}).Error
}
// UpdateTaskStatus 流转任务状态(running/done/failed/timeout),由 dispatcher 经 NATS 回写驱动。
@@ -270,12 +271,14 @@ func (p *Postgres) StatsOverview(ctx context.Context, owner string) *Overview {
}
// RecentTasks 返回最近 n 条任务(工作台「近期运行」feed)。
func (p *Postgres) RecentTasks(ctx context.Context, n int) []Task {
// RecentTasks 返回某用户最近 n 条任务(个人工作台「近期运行」feed)。
// owner 过滤"我的运行";tenant 由插件自动叠加(双保险:跨用户/跨租户都隔离)。
func (p *Postgres) RecentTasks(ctx context.Context, owner string, n int) []Task {
if p.db == nil {
return nil
}
var out []Task
p.db.WithContext(ctx).Order("created_at desc").Limit(n).Find(&out)
p.db.WithContext(ctx).Where("owner = ?", owner).Order("created_at desc").Limit(n).Find(&out)
return out
}
@@ -289,18 +292,22 @@ type RunRow struct {
EvalOverall float64 `json:"eval_overall"`
}
// RecentRuns 返回最近 n 条运行(含评测分级,供「运行历史」列表)。
func (p *Postgres) RecentRuns(ctx context.Context, n int) []RunRow {
// RecentRuns 返回某用户最近 n 条运行(含评测分级,供「运行历史」列表)。
// 注:raw Table 查询绕过 gorm 模型回调 → 租户插件不生效,故此处**手动**按 owner(+ctx 租户) 过滤。
func (p *Postgres) RecentRuns(ctx context.Context, owner string, n int) []RunRow {
if p.db == nil {
return nil
}
var out []RunRow
p.db.WithContext(ctx).Table("sundynix_task as t").
q := p.db.WithContext(ctx).Table("sundynix_task as t").
Select("t.task_id, t.status, t.detail, t.created_at as at, " +
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall").
Joins("left join sundynix_eval e on e.task_id = t.task_id").
Where("t.deleted_at is null").
Order("t.created_at desc").Limit(n).Scan(&out)
Where("t.deleted_at is null AND t.owner = ?", owner)
if tid := tenantFromCtx(ctx); tid != "" && !isSystemCtx(ctx) {
q = q.Where("t.tenant_id = ?", tid)
}
q.Order("t.created_at desc").Limit(n).Scan(&out)
return out
}
@@ -16,6 +16,7 @@ type tenantScopedMarker interface{ isTenantScoped() }
// ---- 请求上下文携带 tenant_id(中间件注入 → 传到 store → 插件读取)----
type ctxKeyTenant struct{}
type ctxKeySystem struct{}
// WithTenant 把 tenant_id 放进 context(空则原样返回,避免污染系统/回填查询)。
func WithTenant(ctx context.Context, tenantID string) context.Context {
@@ -25,6 +26,20 @@ func WithTenant(ctx context.Context, tenantID string) context.Context {
return context.WithValue(ctx, ctxKeyTenant{}, tenantID)
}
// WithoutTenant 标记本次操作为「系统/跨租户」——即使 ctx 里带着某租户(如 admin 自己的),
// 插件也**不**加 tenant 过滤。用于 admin 系统级聚合、跨租户后台任务等需全平台可见的路径。
func WithoutTenant(ctx context.Context) context.Context {
return context.WithValue(ctx, ctxKeySystem{}, true)
}
func isSystemCtx(ctx context.Context) bool {
if ctx == nil {
return false
}
b, _ := ctx.Value(ctxKeySystem{}).(bool)
return b
}
func tenantFromCtx(ctx context.Context) string {
if ctx == nil {
return ""
@@ -53,7 +68,7 @@ func isTenantScopedStmt(db *gorm.DB) bool {
// addTenantWhere:受租户模型 + ctx 有 tenant → 追加 tenant_id 过滤。
// ctx 无 tenant(系统/回填/未登录)→ 不过滤(这些路径本就需跨租户;用户面由中间件保证有 tenant)。
func addTenantWhere(db *gorm.DB) {
if !isTenantScopedStmt(db) {
if !isTenantScopedStmt(db) || isSystemCtx(db.Statement.Context) {
return
}
if tid := tenantFromCtx(db.Statement.Context); tid != "" {
@@ -65,7 +80,7 @@ func addTenantWhere(db *gorm.DB) {
// setTenantOnCreate:受租户模型 + ctx 有 tenant → 强制把 tenant_id 设为 ctx 租户(防越权写他租)。
func setTenantOnCreate(db *gorm.DB) {
if !isTenantScopedStmt(db) {
if !isTenantScopedStmt(db) || isSystemCtx(db.Statement.Context) {
return
}
if tid := tenantFromCtx(db.Statement.Context); tid != "" {