fix: generate ai table docx exports
This commit is contained in:
+192
-5
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user