123 lines
4.1 KiB
Go
123 lines
4.1 KiB
Go
// Package service 提供 CSV 格式的知识库导入功能。
|
||
//
|
||
// CSV 被选为首要导入格式,原因:
|
||
// 1. 轻量、无格式依赖,可用记事本/Excel/Numbers 等任意工具创建;
|
||
// 2. Go 标准库 encoding/csv 原生支持,无需引入任何第三方依赖;
|
||
// 3. 对于知识库数据(纯文本问答),CSV 已足够表达所有字段。
|
||
package service
|
||
|
||
import (
|
||
"encoding/csv"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"strings"
|
||
|
||
"AI-Expert-Sidebar/internal/database"
|
||
"AI-Expert-Sidebar/internal/models"
|
||
)
|
||
|
||
// ImportResult 是导入操作的结果摘要,返回给前端显示 Toast 通知。
|
||
// 同时用于 CSV 和 Excel 导入,两者共用此结构体。
|
||
type ImportResult struct {
|
||
// Imported 是成功写入数据库的行数。
|
||
Imported int `json:"imported"`
|
||
// Skipped 是被跳过的行数(空行、缺字段、写入失败等)。
|
||
Skipped int `json:"skipped"`
|
||
// Error 非空时表示导入整体失败(文件不存在、格式错误等),
|
||
// 此时 Imported/Skipped 没有意义。
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// ImportCSV 读取 CSV 文件并将合法行批量插入到当前活跃知识库。
|
||
//
|
||
// # 期望的 CSV 格式
|
||
//
|
||
// 第一行必须是表头(顺序任意,大小写无关):
|
||
//
|
||
// keyword,question,answer,category
|
||
// 浇水频率,多肉多久浇一次水,10-14天一次,浇水
|
||
//
|
||
// required 列:keyword / question / answer(三者缺一则整批失败)
|
||
// optional 列:category(缺失时默认为 "通用")
|
||
//
|
||
// # 容错策略
|
||
//
|
||
// 单行解析失败(字段为空、列数不足)时只 skipped++,不中断整个导入。
|
||
// 这样一份有 5% 脏数据的 CSV 依然能有效导入 95% 的正常数据,
|
||
// 比"遇到错误立即中止"的方案用户体验好很多。
|
||
//
|
||
// # 为什么用流式读取(csv.Reader)而不是一次性读入内存
|
||
//
|
||
// 对于超大 CSV(数万条),一次性 ioutil.ReadAll 会占用大量内存;
|
||
// csv.Reader 逐行读取,内存消耗恒定(约等于单行大小),
|
||
// 且在写入失败时可以立即停止。
|
||
func ImportCSV(filePath string) ImportResult {
|
||
f, err := os.Open(filePath)
|
||
if err != nil {
|
||
return ImportResult{Error: fmt.Sprintf("无法打开文件: %v", err)}
|
||
}
|
||
defer f.Close()
|
||
|
||
db := database.Get()
|
||
if db == nil {
|
||
return ImportResult{Error: "知识库未初始化,请先创建或选择知识库"}
|
||
}
|
||
|
||
r := csv.NewReader(f)
|
||
r.TrimLeadingSpace = true // 自动去除字段前后的空格
|
||
r.LazyQuotes = true // 宽松解析:允许字段内出现未转义的引号
|
||
|
||
// 读取并标准化表头行,构建列名→列序号的映射
|
||
header, err := r.Read()
|
||
if err != nil {
|
||
return ImportResult{Error: fmt.Sprintf("读取表头失败: %v", err)}
|
||
}
|
||
colIdx := make(map[string]int)
|
||
for i, h := range header {
|
||
colIdx[strings.ToLower(strings.TrimSpace(h))] = i
|
||
}
|
||
// 严格校验必需列是否存在,给出明确错误信息而非 index out of range panic
|
||
for _, required := range []string{"keyword", "question", "answer"} {
|
||
if _, ok := colIdx[required]; !ok {
|
||
return ImportResult{Error: fmt.Sprintf("CSV 缺少必需列: %q(需要: keyword, question, answer)", required)}
|
||
}
|
||
}
|
||
catIdx, hasCat := colIdx["category"]
|
||
|
||
var imported, skipped int
|
||
for {
|
||
row, err := r.Read()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
// 单行解析错误(如奇数引号):跳过该行,继续下一行
|
||
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}
|
||
}
|