feat: 添加注释

This commit is contained in:
Blizzard
2026-04-01 15:29:35 +08:00
parent aef2e152dc
commit 6162c9110c
28 changed files with 1293 additions and 298 deletions
+125
View File
@@ -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 会完整解析 .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}
}