ac38d5e663
部署前生产级审计(可靠性/数据层/安全三路)后,清掉 7 处代码级硬伤: A1 后台定时器 goroutine 无 panic recover → 单个 DB panic 崩整个 gateway。加 safeGo/ safeCall,包住订阅/掉单补偿/微信推送/探针 goroutine,单轮 tick 再兜一层。 A2 提示词控制面(建/激活/停用,热广播全服务)只 RequireAuth → 任意登录用户改全局提示词。 三写端点+列表挂 RequireAdmin。 A3 HITL 审批端点无角色门 → viewer 可放行烧钱执行。加 RequireTenantRole(member)。 A4 审计/护栏列表 limit 无校验,limit=-1 让 gorm 取消 LIMIT 全表扫。加 clampLimit/ clampOffset,AdminTasks/AdminSpaces 补上界。 A5 限流 Redis 一挂就完全放行(fail-open)。加进程内固定窗口兜底(fail-safe) + 登录/注册 按 IP 专用严限流(10/min)。 A6 公开 by-id 端点(stream/exec/report导出/kb导入流)无鉴权无租户过滤。加 AuthFromHeaderOrQuery(从 ?token= 取 JWT) + task/report 按 owner 归属校验;桌面端 5 处 EventSource/下载 URL 经 tokenQuery 附 JWT。 A7 文件上传无大小上限(整文件进内存 OOM 面) → 50MB 闸(KB_MAX_UPLOAD_BYTES)+ LimitReader; http.Server 加 ReadHeaderTimeout/ReadTimeout/MaxHeaderBytes(不设 WriteTimeout 保 SSE)。 带单测:clampLimit/safeCall/procLimiter/AuthFromHeaderOrQuery/TaskOwner。 build+vet+全量 test 绿;desktop tsc 绿。B(迁移工具/实时探针/出网韧性/登录锁定/leader选举) 与 C(TLS/PG HA/K8s/备份自动化/可观测)分期后做,参照 production_readiness.md。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
145 lines
5.6 KiB
Go
145 lines
5.6 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
)
|
||
|
||
// 全平台任务观测(管理端「运行观测」页)。系统级口径:跨租户看所有任务,
|
||
// 故走 WithoutTenant 旁路租户插件 + raw Table 查询(同 RecentRuns 手动控制过滤)。
|
||
|
||
// AdminTaskRow 是管理端任务流一行:任务 + 租户名 + 提交人邮箱 + 评测分级(LEFT JOIN)。
|
||
type AdminTaskRow struct {
|
||
TaskID string `json:"task_id"`
|
||
TenantID string `json:"tenant_id"`
|
||
TenantName string `json:"tenant_name"`
|
||
Owner string `json:"owner"`
|
||
OwnerEmail string `json:"owner_email"`
|
||
Status string `json:"status"`
|
||
Detail string `json:"detail"`
|
||
Topic string `json:"topic"`
|
||
At time.Time `json:"at"`
|
||
EvalLevel string `json:"eval_level"`
|
||
EvalOverall float64 `json:"eval_overall"`
|
||
}
|
||
|
||
// AllTasks 全平台任务流(管理端观测;可按状态/租户过滤)。倒序,翻页。
|
||
// status 空=全部;tenantID 空=全租户。
|
||
func (p *Postgres) AllTasks(ctx context.Context, status, tenantID string, limit int) []AdminTaskRow {
|
||
if p.db == nil {
|
||
return nil
|
||
}
|
||
if limit <= 0 || limit > 200 {
|
||
limit = 50
|
||
}
|
||
ctx = WithoutTenant(ctx) // 系统级:看所有租户的任务
|
||
q := p.db.WithContext(ctx).Table("sundynix_task as t").
|
||
Select("t.task_id, t.tenant_id, t.owner, t.status, t.detail, t.created_at as at, " +
|
||
"coalesce(tn.name,'') as tenant_name, coalesce(u.email,'') as owner_email, " +
|
||
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, " +
|
||
"coalesce(t.graph->>'topic','') as topic").
|
||
Joins("left join sundynix_tenant tn on tn.id = t.tenant_id").
|
||
Joins("left join sundynix_user u on u.id = t.owner").
|
||
Joins("left join sundynix_eval e on e.task_id = t.task_id").
|
||
Where("t.deleted_at is null")
|
||
if status != "" {
|
||
q = q.Where("t.status = ?", status)
|
||
}
|
||
if tenantID != "" {
|
||
q = q.Where("t.tenant_id = ?", tenantID)
|
||
}
|
||
var out []AdminTaskRow
|
||
q.Order("t.created_at desc").Limit(limit).Scan(&out)
|
||
return out
|
||
}
|
||
|
||
// TaskStatusCounts 全平台任务按状态计数(观测页的筛选标签 + 分布)。
|
||
func (p *Postgres) TaskStatusCounts(ctx context.Context) map[string]int64 {
|
||
out := map[string]int64{}
|
||
if p.db == nil {
|
||
return out
|
||
}
|
||
ctx = WithoutTenant(ctx)
|
||
var rows []struct {
|
||
Status string
|
||
N int64
|
||
}
|
||
p.db.WithContext(ctx).Table("sundynix_task").
|
||
Select("status, count(*) as n").
|
||
Where("deleted_at is null").
|
||
Group("status").Scan(&rows)
|
||
for _, r := range rows {
|
||
out[r.Status] = r.N
|
||
}
|
||
return out
|
||
}
|
||
|
||
// AdminTaskDetail 是管理端任务下钻:基本信息 + 持久化的输出/轨迹 + 评测。
|
||
// 轨迹与输出取自 sundynix_task 的收尾落库列(Redis 流只有 10min TTL,历史任务只能靠它们)。
|
||
type AdminTaskDetail struct {
|
||
AdminTaskRow
|
||
Graph string `json:"graph"` // 提交时的 DSL 原文
|
||
Output string `json:"output"` // 最终模型输出
|
||
Trace string `json:"trace"` // 执行轨迹事件 JSON 数组(原文透传,前端解析)
|
||
// gorm:"-":这是查完再单独填的组合字段,不是关联;不标记的话 Scan 会当成关系报错。
|
||
Eval *AdminEval `gorm:"-" json:"eval"` // 无评测时为 null
|
||
}
|
||
|
||
// AdminEval 是下钻里的评测明细(比列表页的 level/overall 多出评语与命中项)。
|
||
type AdminEval struct {
|
||
Overall float64 `json:"overall"`
|
||
Rule float64 `json:"rule"`
|
||
LLM float64 `json:"llm"`
|
||
Faithful float64 `json:"faithful"`
|
||
Level string `json:"level"`
|
||
Flags string `json:"flags"`
|
||
Reason string `json:"reason"`
|
||
Sources int `json:"sources"`
|
||
Corrected bool `json:"corrected"`
|
||
}
|
||
|
||
// TaskOwner 按 task_id 返回提交者 user.id(供 SSE/导出等公开 by-id 端点做归属校验)。
|
||
// 跨租户查(WithoutTenant):这些端点无租户上下文,靠 owner 判权。不存在返回空串。
|
||
func (p *Postgres) TaskOwner(ctx context.Context, taskID string) string {
|
||
if p.db == nil || taskID == "" {
|
||
return ""
|
||
}
|
||
var owner string
|
||
p.db.WithContext(WithoutTenant(ctx)).Model(&Task{}).
|
||
Where("task_id = ? and deleted_at is null", taskID).Select("owner").Scan(&owner)
|
||
return owner
|
||
}
|
||
|
||
// TaskDetail 按 task_id 取单条任务的完整下钻数据(管理端,跨租户)。
|
||
// 必须 WithoutTenant:Task/Eval 都在租户插件作用域内,用请求 ctx 查别的租户的任务
|
||
// 不会报错,而是静默返回空——看起来像"这任务没产出",比报错更难排查。
|
||
func (p *Postgres) TaskDetail(ctx context.Context, taskID string) *AdminTaskDetail {
|
||
if p.db == nil || taskID == "" {
|
||
return nil
|
||
}
|
||
ctx = WithoutTenant(ctx)
|
||
var d AdminTaskDetail
|
||
err := p.db.WithContext(ctx).Table("sundynix_task as t").
|
||
Select("t.task_id, t.tenant_id, t.owner, t.status, t.detail, t.created_at as at, "+
|
||
"t.output, t.trace, coalesce(cast(t.graph as text),'') as graph, "+
|
||
"coalesce(tn.name,'') as tenant_name, coalesce(u.email,'') as owner_email, "+
|
||
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, "+
|
||
"coalesce(t.graph->>'topic','') as topic").
|
||
Joins("left join sundynix_tenant tn on tn.id = t.tenant_id").
|
||
Joins("left join sundynix_user u on u.id = t.owner").
|
||
Joins("left join sundynix_eval e on e.task_id = t.task_id").
|
||
Where("t.task_id = ? and t.deleted_at is null", taskID).
|
||
Scan(&d).Error
|
||
if err != nil || d.TaskID == "" {
|
||
return nil
|
||
}
|
||
var e Eval
|
||
if p.db.WithContext(ctx).Where("task_id = ?", taskID).First(&e).Error == nil {
|
||
d.Eval = &AdminEval{
|
||
Overall: e.Overall, Rule: e.Rule, LLM: e.LLM, Faithful: e.Faithful,
|
||
Level: e.Level, Flags: e.Flags, Reason: e.Reason, Sources: e.Sources, Corrected: e.Corrected,
|
||
}
|
||
}
|
||
return &d
|
||
}
|