From 9cb8c4d93aa6106b4ad73d159ca875564d172f48 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Tue, 29 Sep 2026 16:40:28 +0800 Subject: [PATCH] fix: generate ai table docx exports --- internal/httpapi/ai.go | 197 ++++++++++++++++++++++++++++- internal/httpapi/ai_export_test.go | 55 ++++++++ 2 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 internal/httpapi/ai_export_test.go diff --git a/internal/httpapi/ai.go b/internal/httpapi/ai.go index 89b473a..e251b58 100644 --- a/internal/httpapi/ai.go +++ b/internal/httpapi/ai.go @@ -1,11 +1,16 @@ package httpapi import ( + "archive/zip" + "bytes" "crypto/sha256" "encoding/hex" "encoding/json" + "encoding/xml" "errors" + "fmt" "net/http" + "net/url" "os" "strconv" "strings" @@ -421,18 +426,59 @@ func (a *API) aiTableAnalysisResource(w http.ResponseWriter, r *http.Request, p if _, ok := requireIdempotencyKey(w, r); !ok { return } - if _, err := st.AITableAnalysis(r.Context(), p.UserID, recordID); err != nil { + var body aiTableExportRequest + if !decodeBody(w, r, &body) { + return + } + format := strings.ToLower(strings.TrimSpace(body.Format)) + if format == "" { + format = "docx" + } + if format != "docx" { + writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "format", "supported": []string{"docx"}}) + return + } + item, err := st.AITableAnalysis(r.Context(), p.UserID, recordID) + if err != nil { writeStoreError(w, r, err) return } + fileName := aiTableExportFileName(item) writeSuccess(w, r, map[string]any{ - "file_id": "fil_export_" + randomToken(8), - "file_name": recordID + "-解读报告.docx", - "download_url": "", + "file_id": "ai_export_" + recordID, + "file_name": fileName, + "download_url": absoluteURL(r, "/v1/workbench/ai/table-analyses/"+recordID+"/export/download?format=docx"), "expires_at": time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339Nano), }) return } + if tail == "export/download" && r.Method == http.MethodGet { + format := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) + if format == "" { + format = "docx" + } + if format != "docx" { + writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "format", "supported": []string{"docx"}}) + return + } + item, err := st.AITableAnalysis(r.Context(), p.UserID, recordID) + if err != nil { + writeStoreError(w, r, err) + return + } + data, err := aiTableAnalysisDocx(item) + if err != nil { + writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil) + return + } + fileName := aiTableExportFileName(item) + w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document") + w.Header().Set("Content-Disposition", contentDispositionAttachment(fileName)) + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + return + } if tail != "" || r.Method != http.MethodGet { methodNotAllowed(w, r) return @@ -668,6 +714,10 @@ type aiPolishRequest struct { MaxOutputChars int `json:"max_output_chars"` } +type aiTableExportRequest struct { + Format string `json:"format"` +} + type aiDocumentGenerateRequest struct { SourceText string `json:"source_text"` TitleHint string `json:"title_hint"` @@ -849,7 +899,7 @@ func aiTableAnalysisPayload(item *store.AITableAnalysis) map[string]any { "preview": decodeJSONAny(item.PreviewJSON, map[string]any{}), "alerts": decodeJSONAny(item.AlertsJSON, []any{}), "interpretation": item.Interpretation, - "export_options": []string{"docx", "pdf"}, + "export_options": []string{"docx"}, "created_at": item.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": item.UpdatedAt.UTC().Format(time.RFC3339Nano), } @@ -950,6 +1000,143 @@ func allowedTableFileType(value string) bool { } } +func aiTableAnalysisDocx(item *store.AITableAnalysis) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + files := map[string]string{ + "[Content_Types].xml": ` + + + + + + +`, + "_rels/.rels": ` + + + + +`, + "docProps/app.xml": ` +IM Workbench`, + "docProps/core.xml": aiDocxCoreXML(item), + "word/document.xml": aiTableAnalysisDocumentXML(item), + } + for name, content := range files { + w, err := zw.Create(name) + if err != nil { + return nil, err + } + if _, err := w.Write([]byte(content)); err != nil { + return nil, err + } + } + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func aiTableAnalysisDocumentXML(item *store.AITableAnalysis) string { + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + docxParagraph(&b, firstNonEmpty(item.Topic, "智能表格解读报告"), true) + docxParagraph(&b, "文件信息", true) + docxParagraph(&b, "文件名:"+item.FileName, false) + docxParagraph(&b, "文件类型:"+item.FileType, false) + docxParagraph(&b, "文件大小:"+strconv.FormatInt(item.FileSize, 10)+" bytes", false) + docxParagraph(&b, "核心指标", true) + for _, metric := range aiExportList(item.MetricsJSON) { + docxParagraph(&b, "- "+aiExportMetricText(metric), false) + } + docxParagraph(&b, "异常提醒", true) + for _, alert := range aiExportList(item.AlertsJSON) { + docxParagraph(&b, "- "+aiExportAlertText(alert), false) + } + docxParagraph(&b, "核心解读", true) + for _, line := range strings.Split(strings.TrimSpace(item.Interpretation), "\n") { + if strings.TrimSpace(line) != "" { + docxParagraph(&b, strings.TrimSpace(line), false) + } + } + b.WriteString(``) + b.WriteString(``) + return b.String() +} + +func docxParagraph(b *strings.Builder, text string, bold bool) { + b.WriteString(``) + if bold { + b.WriteString(``) + } + b.WriteString(``) + _ = xml.EscapeText(b, []byte(text)) + b.WriteString(``) +} + +func aiDocxCoreXML(item *store.AITableAnalysis) string { + created := item.CreatedAt.UTC().Format(time.RFC3339) + title := xmlEscape(firstNonEmpty(item.Topic, "智能表格解读报告")) + return ` + +` + title + `IM WorkbenchIM Workbench` + created + `` + time.Now().UTC().Format(time.RFC3339) + `` +} + +func aiExportList(raw string) []map[string]any { + var items []map[string]any + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return nil + } + return items +} + +func aiExportMetricText(item map[string]any) string { + return strings.TrimSpace(fmt.Sprintf("%v:%v(%v,%v)", item["name"], item["value"], item["change_text"], item["trend"])) +} + +func aiExportAlertText(item map[string]any) string { + return strings.TrimSpace(fmt.Sprintf("[%v] %v:%v", item["level"], item["title"], item["description"])) +} + +func aiTableExportFileName(item *store.AITableAnalysis) string { + name := strings.TrimSpace(firstNonEmpty(item.Topic, item.RecordID, "智能表格解读报告")) + replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_") + name = replacer.Replace(name) + if len([]rune(name)) > 80 { + name = string([]rune(name)[:80]) + } + return name + "-解读报告.docx" +} + +func absoluteURL(r *http.Request, path string) string { + scheme := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) + if scheme == "" { + if r.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + } + host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")) + if host == "" { + host = r.Host + } + return scheme + "://" + host + path +} + +func contentDispositionAttachment(fileName string) string { + escaped := url.QueryEscape(fileName) + return `attachment; filename="report.docx"; filename*=UTF-8''` + escaped +} + +func xmlEscape(value string) string { + var b strings.Builder + _ = xml.EscapeText(&b, []byte(value)) + return b.String() +} + func mustJSON(value any, fallback string) string { data, err := json.Marshal(value) if err != nil || len(data) == 0 || string(data) == "null" { diff --git a/internal/httpapi/ai_export_test.go b/internal/httpapi/ai_export_test.go new file mode 100644 index 0000000..3ae862d --- /dev/null +++ b/internal/httpapi/ai_export_test.go @@ -0,0 +1,55 @@ +package httpapi + +import ( + "archive/zip" + "bytes" + "io" + "strings" + "testing" + "time" + + "git.118im.com/im_team/im_workbench/internal/store" +) + +func TestAITableAnalysisDocx(t *testing.T) { + docx, err := aiTableAnalysisDocx(&store.AITableAnalysis{ + RecordID: "airec_tbl_test", + FileName: "sales.xlsx", + FileType: "xlsx", + FileSize: 1234, + Topic: "销售数据", + MetricsJSON: `[{"name":"销售额","value":"12万","change_text":"+10%","trend":"up"}]`, + AlertsJSON: `[{"level":"warning","title":"异常波动","description":"订单量下降"}]`, + Interpretation: "整体销售额增长。", + CreatedAt: time.Date(2026, 9, 29, 8, 0, 0, 0, time.UTC), + }) + if err != nil { + t.Fatal(err) + } + zr, err := zip.NewReader(bytes.NewReader(docx), int64(len(docx))) + if err != nil { + t.Fatalf("docx is not a zip: %v", err) + } + var document string + for _, file := range zr.File { + if file.Name != "word/document.xml" { + continue + } + rc, err := file.Open() + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(rc) + _ = rc.Close() + if err != nil { + t.Fatal(err) + } + document = string(data) + break + } + for _, want := range []string{"销售数据", "销售额", "异常波动", "整体销售额增长"} { + if !strings.Contains(document, want) { + t.Fatalf("document.xml missing %q: %s", want, document) + } + } +}