feat: 添加注释
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// 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}
|
||||
}
|
||||
Reference in New Issue
Block a user