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>
106 lines
3.9 KiB
Go
106 lines
3.9 KiB
Go
package handler
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"github.com/sundynix/sundynix-shared/contract"
|
||
)
|
||
|
||
// GenerateReport: POST /api/v1/reports —— 触发报告生成。
|
||
// 组装一个 intent=report 的任务发到 NATS,Dispatcher 走专用编排(规划→分章并行→渲染 docx)。
|
||
// 返回 task_id;客户端用 GET /tasks/:id/stream 看实时进度,完成后用 /reports/:id/download 取 Word。
|
||
func (h *Handler) GenerateReport(c *gin.Context) {
|
||
var body struct {
|
||
Topic string `json:"topic"`
|
||
KB string `json:"kb"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil || body.Topic == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "topic required"})
|
||
return
|
||
}
|
||
// 报告和普通任务一样烧钱,必须过同一道关卡(预算/计费租户/积分硬拦截)。
|
||
// 此前这里直接 PublishTask,绕过了全部三项。
|
||
billingTenant, ok := h.preflight(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
id := newReportID()
|
||
graph, _ := json.Marshal(map[string]any{"topic": body.Topic}) // 占位 DSL,报告编排实际读 Meta
|
||
task := &contract.Task{
|
||
ID: id,
|
||
Graph: graph,
|
||
Meta: map[string]any{
|
||
contract.MetaIntent: contract.IntentReport,
|
||
contract.MetaTopic: body.Topic,
|
||
contract.MetaKB: body.KB,
|
||
contract.MetaUserID: userID(c),
|
||
contract.MetaTenantID: billingTenant, // 用量按计费租户扣,此前报告完全没记 → 漏账
|
||
contract.MetaSessionID: sessionID(c),
|
||
},
|
||
}
|
||
// launch 而非裸 PublishTask:报告也是一次「执行」,要落库(→ 进运行历史、可复盘)
|
||
// 并开录像(→ SSE 可回放,切走再回来不丢)。
|
||
if err := h.launch(c, task); err != nil {
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusAccepted, gin.H{"task_id": id})
|
||
}
|
||
|
||
// ExportReport: GET /api/v1/reports/:id/export?format=docx|md —— 按需把报告源渲染为指定格式并下载。
|
||
// 生成阶段只存源;此处经 mcp-go report_export 现渲染("导出时再处理")。PDF 由前端打印预览生成。
|
||
func (h *Handler) ExportReport(c *gin.Context) {
|
||
id := c.Param("id")
|
||
if !h.requireTaskOwner(c, id) { // 报告按 task_id 寻址:仅提交者可导出
|
||
return
|
||
}
|
||
format := c.DefaultQuery("format", "docx")
|
||
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("report_export"),
|
||
&contract.ToolCall{Tool: "report_export", Args: map[string]any{"task_id": id, "format": format}})
|
||
if err != nil || res == nil || !res.OK {
|
||
msg := "报告尚未生成或已过期"
|
||
if res != nil && res.Error != "" {
|
||
msg = res.Error
|
||
}
|
||
c.JSON(http.StatusNotFound, gin.H{"error": msg})
|
||
return
|
||
}
|
||
switch format {
|
||
case "md", "markdown":
|
||
c.Header("Content-Disposition", `attachment; filename="`+id+`.md"`)
|
||
c.Header("Content-Type", "text/markdown; charset=utf-8")
|
||
c.String(http.StatusOK, res.Content)
|
||
default: // docx:res.Content 为 minio://<key>(对象存储,跨机可取)或本地路径(单机降级)
|
||
const docxMime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||
c.Header("Content-Disposition", `attachment; filename="`+id+`.docx"`)
|
||
c.Header("Content-Type", docxMime)
|
||
if key, ok := strings.CutPrefix(res.Content, contract.BlobScheme); ok {
|
||
// 对象存储:从 MinIO 流式取回,不依赖 gateway 与 mcp-go 共享本地盘。
|
||
if h.blob == nil || !h.blob.Ready() {
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": "对象存储不可用,无法下载报告"})
|
||
return
|
||
}
|
||
data, gerr := h.blob.GetBytes(c.Request.Context(), key)
|
||
if gerr != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "报告产物已过期或不存在"})
|
||
return
|
||
}
|
||
c.Data(http.StatusOK, docxMime, data)
|
||
return
|
||
}
|
||
c.File(res.Content) // 本地路径:单机/共享卷降级
|
||
}
|
||
}
|
||
|
||
func newReportID() string {
|
||
var b [8]byte
|
||
_, _ = rand.Read(b[:])
|
||
return "report_" + hex.EncodeToString(b[:])
|
||
}
|