fix: generate ai table docx exports

This commit is contained in:
richjohnrambo
2026-09-29 16:40:28 +08:00
parent 37e89a6457
commit 9cb8c4d93a
2 changed files with 247 additions and 5 deletions
+192 -5
View File
@@ -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": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
</Types>`,
"_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>`,
"docProps/app.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>IM Workbench</Application></Properties>`,
"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(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`)
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(`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/></w:sectPr>`)
b.WriteString(`</w:body></w:document>`)
return b.String()
}
func docxParagraph(b *strings.Builder, text string, bold bool) {
b.WriteString(`<w:p><w:r>`)
if bold {
b.WriteString(`<w:rPr><w:b/></w:rPr>`)
}
b.WriteString(`<w:t>`)
_ = xml.EscapeText(b, []byte(text))
b.WriteString(`</w:t></w:r></w:p>`)
}
func aiDocxCoreXML(item *store.AITableAnalysis) string {
created := item.CreatedAt.UTC().Format(time.RFC3339)
title := xmlEscape(firstNonEmpty(item.Topic, "智能表格解读报告"))
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dc:title>` + title + `</dc:title><dc:creator>IM Workbench</dc:creator><cp:lastModifiedBy>IM Workbench</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">` + created + `</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">` + time.Now().UTC().Format(time.RFC3339) + `</dcterms:modified></cp:coreProperties>`
}
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" {