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)
+ }
+ }
+}