Files
2026-04-01 15:29:35 +08:00

126 lines
4.2 KiB
Go
Raw Permalink 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 service 提供 Excel (.xlsx) 格式的知识库导入功能。
//
// # 为什么单独一个文件
//
// Excel 导入依赖 github.com/xuri/excelize/v2 这个较重的第三方库(约 5 MB)。
// 将其单独放在 import_excel.go 而非并入 import.go,原因是:
// 1. 将来若需要 go build tag 条件编译(如精简版不含 Excel 支持),
// 只需在此文件头加一行 //go:build !lite 即可;
// 2. 代码审查时,Excel 相关逻辑和 CSV 逻辑不混杂,更清晰。
package service
import (
"fmt"
"strings"
"github.com/xuri/excelize/v2"
"AI-Expert-Sidebar/internal/database"
"AI-Expert-Sidebar/internal/models"
)
// ImportExcel 读取 .xlsx 文件第一个工作表,并将合法行插入当前活跃知识库。
//
// # 期望的 Excel 格式
//
// A1 起始的第一行为表头(列顺序任意,大小写无关):
//
// keyword | question | answer | category
//
// required 列:keyword / question / answer
// optional 列:category(缺失默认 "通用"
//
// # 与 ImportCSV 的设计对比
//
// 两者共享相同的"表头自动检测 + 逐行容错"策略,
// 不同之处在于:
// - excelize.GetRows 一次性将整个 Sheet 读入内存([][]string),
// 而 CSV 是逐行流式读取;
// - Excel 因格式复杂(合并单元格/公式/样式),
// 一次性读取后统一处理更稳定,遇到短行也能安全截断。
//
// # 为什么只读第一个工作表
//
// 大多数用户的知识库 Excel 只有一个 Sheet。
// 若多 Sheet 都读取,反而容易把"说明"或"示例"Sheet 的数据误导入。
// 当前选择保守策略:仅读 Sheet[0],未来有需求可扩展为让用户选择 Sheet。
func ImportExcel(filePath string) ImportResult {
db := database.Get()
if db == nil {
return ImportResult{Error: "知识库未初始化,请先创建或选择知识库"}
}
// excelize.OpenFile 会完整解析 .xlsxZIP 格式),
// 遇到损坏文件会返回 error 而非 panic
f, err := excelize.OpenFile(filePath)
if err != nil {
return ImportResult{Error: fmt.Sprintf("无法打开 Excel 文件: %v", err)}
}
defer f.Close() // 释放 excelize 内部持有的文件句柄和内存
sheets := f.GetSheetList()
if len(sheets) == 0 {
return ImportResult{Error: "Excel 文件中没有工作表"}
}
// GetRows 返回 [][]string,每个元素是一行,每行是一个字段切片
rows, err := f.GetRows(sheets[0])
if err != nil {
return ImportResult{Error: fmt.Sprintf("读取工作表失败: %v", err)}
}
if len(rows) < 2 {
// rows[0] 是表头,至少需要 1 行数据
return ImportResult{Error: "工作表没有数据行(至少需要表头行 + 一行数据)"}
}
// 与 CSV 导入相同的表头自动检测逻辑,不要求列的固定顺序
colIdx := make(map[string]int)
for i, h := range rows[0] {
colIdx[strings.ToLower(strings.TrimSpace(h))] = i
}
for _, required := range []string{"keyword", "question", "answer"} {
if _, ok := colIdx[required]; !ok {
return ImportResult{Error: fmt.Sprintf("缺少必需列: %q(需要: keyword, question, answer", required)}
}
}
catIdx, hasCat := colIdx["category"]
var imported, skipped int
for _, row := range rows[1:] {
// Excel 中某些行尾部的空单元格会被 excelize 截断,
// 需要防止 index out of range,先计算需要的最大列索引
maxIdx := colIdx["keyword"]
if colIdx["question"] > maxIdx {
maxIdx = colIdx["question"]
}
if colIdx["answer"] > maxIdx {
maxIdx = colIdx["answer"]
}
if len(row) <= maxIdx {
skipped++
continue
}
keyword := strings.TrimSpace(row[colIdx["keyword"]])
question := strings.TrimSpace(row[colIdx["question"]])
answer := strings.TrimSpace(row[colIdx["answer"]])
if keyword == "" || question == "" || answer == "" {
skipped++
continue
}
cat := "通用"
if hasCat && catIdx < len(row) {
if v := strings.TrimSpace(row[catIdx]); v != "" {
cat = v
}
}
entry := models.Entry{Keyword: keyword, Question: question, Answer: answer, Category: cat}
if err := db.Create(&entry).Error; err != nil {
skipped++
} else {
imported++
}
}
return ImportResult{Imported: imported, Skipped: skipped}
}