Files
sundynix-agentix/sundynix-gateway/internal/store/task_query.go
T
Blizzard ac38d5e663 fix(prod): 后端生产级 A 类硬伤全清(7 项:授权/崩溃点/全表扫/限流/鉴权/限额)
部署前生产级审计(可靠性/数据层/安全三路)后,清掉 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>
2026-07-21 15:04:47 +08:00

145 lines
5.6 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 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 取单条任务的完整下钻数据(管理端,跨租户)。
// 必须 WithoutTenantTask/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
}