// 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 会完整解析 .xlsx(ZIP 格式), // 遇到损坏文件会返回 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} }