Files
sundynix-agentix/sundynix-gateway/internal/handler/report.go
T
Blizzard ec9431bfa4 fix(report): 报告源/产物落 MinIO,去掉 gateway↔mcp-go 本地盘耦合
多机/容器化第一时间断裂的隐患(ARCHITECTURE_REVIEW §7 #1):此前报告
源(reportStore)与 .docx 产物(reportExport/reportRender)都写 mcp-go
本地 SUNDYNIX_REPORTS_DIR,gateway 再按同一路径 c.File 回流——mcp-go
与 gateway 不共享磁盘即断。

- blob 包从 gateway/internal 提到 sundynix-shared/blob,gateway 与 mcp-go
  共用同一 MinIO;新增 PutBytes/GetBytes 走二进制(.docx)。
- mcp-go 注入 blob:report 源/产物优先 Put 到对象存储(键 reports/<id>.{json,docx}),
  导出结果返回 minio://<key>;MinIO 未就绪回退本地盘(单机降级,getSource 兼容旧本地报告)。
- gateway ExportReport 识别 minio:// → GetBytes 流式下载,否则 c.File 本地降级。
- 测试:mcp 本地回落往返单测 + shared/blob 真 MinIO 二进制往返(BLOB_TEST_ENDPOINT 门控)。
  四模块 build 绿,受影响测试全过。
- 附带:go mod tidy 引入 genproto 拆包冲突,四模块统一钉单体 genproto 新版解决
  (注:勿 go work sync,会剪掉 indirect require 回落旧版重现冲突)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:29:31 +08:00

103 lines
3.8 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 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 的任务发到 NATSDispatcher 走专用编排(规划→分章并行→渲染 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")
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: // docxres.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[:])
}