// 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} }