package main import ( "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "time" ) var afterSalesKnowledgeRebuildHook = func() { if _, err := getAutoReplyEngine().rebuildKnowledgeIndex(); err != nil && globalLogger != nil { globalLogger.Warn("[售后知识库] 重建自动客服知识索引失败: %v", err) } } func (e *AfterSalesIssueEngine) resolveIssue(issueID string, resolutionContent string) (AfterSalesKnowledgeCase, error) { issueID = strings.TrimSpace(issueID) resolutionContent = strings.TrimSpace(resolutionContent) if issueID == "" { return AfterSalesKnowledgeCase{}, fmt.Errorf("issueId为空") } if resolutionContent == "" { return AfterSalesKnowledgeCase{}, fmt.Errorf("请填写最终处理方案") } now := time.Now().Local().Format(time.RFC3339) e.mu.Lock() index := -1 for i := range e.issues { if e.issues[i].ID == issueID { index = i break } } if index < 0 { e.mu.Unlock() return AfterSalesKnowledgeCase{}, fmt.Errorf("问题不存在") } issue := e.issues[index] normalizeAfterSalesDispatchFields(&issue) issue.Status = afterSalesIssueStatusResolved issue.ResolutionContent = resolutionContent if strings.TrimSpace(issue.ResolvedAt) == "" { issue.ResolvedAt = now } issue.UpdatedAt = now knowledgeCase, err := upsertAfterSalesKnowledgeCase(issue, now) if err != nil { e.mu.Unlock() return AfterSalesKnowledgeCase{}, err } issue.KnowledgeArchivedAt = knowledgeCase.KnowledgeArchivedAt issue.KnowledgeSourcePath = knowledgeCase.MarkdownPath e.issues[index] = issue if err := e.saveIssuesLocked(); err != nil { e.mu.Unlock() return AfterSalesKnowledgeCase{}, err } e.mu.Unlock() triggerAfterSalesKnowledgeRebuild() return knowledgeCase, nil } func listAfterSalesKnowledgeCases() ([]AfterSalesKnowledgeCase, error) { store, err := readAfterSalesKnowledgeCasesFile() if err != nil { return nil, err } cases := append([]AfterSalesKnowledgeCase(nil), store.Cases...) for i := range cases { cases[i] = normalizeAfterSalesKnowledgeCase(cases[i]) cases[i].MissingMarkdown = strings.TrimSpace(cases[i].MarkdownPath) == "" || !fileExists(cases[i].MarkdownPath) } sort.Slice(cases, func(i, j int) bool { if cases[i].ResolvedAt != cases[j].ResolvedAt { return cases[i].ResolvedAt > cases[j].ResolvedAt } return cases[i].IssueID > cases[j].IssueID }) return cases, nil } func (e *AfterSalesIssueEngine) syncResolvedKnowledgeCases() error { now := time.Now().Local().Format(time.RFC3339) store, err := readAfterSalesKnowledgeCasesFile() if err != nil { return err } caseByIssueID := make(map[string]int) for i, item := range store.Cases { if id := strings.TrimSpace(item.IssueID); id != "" { caseByIssueID[id] = i } } e.mu.Lock() changedIssues := false changedCases := false for i := range e.issues { issue := &e.issues[i] if strings.TrimSpace(issue.ID) == "" || normalizeAfterSalesStatus(issue.Status) != afterSalesIssueStatusResolved { continue } if _, exists := caseByIssueID[issue.ID]; exists && strings.TrimSpace(issue.KnowledgeSourcePath) != "" { continue } resolution := strings.TrimSpace(issue.ResolutionContent) if resolution == "" { resolution = strings.TrimSpace(issue.AISuggestion) } if resolution == "" { resolution = strings.TrimSpace(issue.IssueContent) } if resolution == "" { continue } if strings.TrimSpace(issue.ResolvedAt) == "" { issue.ResolvedAt = firstNonEmpty(issue.UpdatedAt, now) changedIssues = true } if strings.TrimSpace(issue.ResolutionContent) == "" { issue.ResolutionContent = resolution changedIssues = true } if strings.TrimSpace(issue.KnowledgeArchivedAt) == "" { issue.KnowledgeArchivedAt = now changedIssues = true } issue.KnowledgeSourcePath = afterSalesKnowledgeMarkdownPath(issue.ID) changedIssues = true knowledgeCase := knowledgeCaseFromIssue(*issue, issue.KnowledgeArchivedAt) if err := writeAfterSalesKnowledgeMarkdown(knowledgeCase); err != nil { e.mu.Unlock() return err } if idx, exists := caseByIssueID[issue.ID]; exists { store.Cases[idx] = knowledgeCase } else { store.Cases = append(store.Cases, knowledgeCase) caseByIssueID[issue.ID] = len(store.Cases) - 1 } changedCases = true } if changedCases { sortAfterSalesKnowledgeCases(store.Cases) if err := writeAfterSalesKnowledgeCasesFile(store); err != nil { e.mu.Unlock() return err } } if changedIssues { if err := e.saveIssuesLocked(); err != nil { e.mu.Unlock() return err } } e.mu.Unlock() if changedCases { triggerAfterSalesKnowledgeRebuild() } return nil } func upsertAfterSalesKnowledgeCase(issue AfterSalesIssue, archivedAt string) (AfterSalesKnowledgeCase, error) { issue.ID = strings.TrimSpace(issue.ID) if issue.ID == "" { return AfterSalesKnowledgeCase{}, fmt.Errorf("issueId为空") } if strings.TrimSpace(issue.ResolutionContent) == "" { return AfterSalesKnowledgeCase{}, fmt.Errorf("请填写最终处理方案") } if strings.TrimSpace(issue.ResolvedAt) == "" { issue.ResolvedAt = archivedAt } knowledgeCase := knowledgeCaseFromIssue(issue, archivedAt) if err := writeAfterSalesKnowledgeMarkdown(knowledgeCase); err != nil { return AfterSalesKnowledgeCase{}, err } store, err := readAfterSalesKnowledgeCasesFile() if err != nil { return AfterSalesKnowledgeCase{}, err } replaced := false for i := range store.Cases { if store.Cases[i].IssueID == knowledgeCase.IssueID { store.Cases[i] = knowledgeCase replaced = true break } } if !replaced { store.Cases = append(store.Cases, knowledgeCase) } sort.Slice(store.Cases, func(i, j int) bool { if store.Cases[i].ResolvedAt != store.Cases[j].ResolvedAt { return store.Cases[i].ResolvedAt > store.Cases[j].ResolvedAt } return store.Cases[i].IssueID > store.Cases[j].IssueID }) if err := writeAfterSalesKnowledgeCasesFile(store); err != nil { return AfterSalesKnowledgeCase{}, err } return knowledgeCase, nil } func knowledgeCaseFromIssue(issue AfterSalesIssue, archivedAt string) AfterSalesKnowledgeCase { knowledgeCase := AfterSalesKnowledgeCase{ IssueID: issue.ID, CreatedAt: issue.CreatedAt, UpdatedAt: issue.UpdatedAt, ResolvedAt: issue.ResolvedAt, KnowledgeArchivedAt: archivedAt, ConversationID: issue.ConversationID, RoomName: issue.RoomName, CustomerUserID: issue.CustomerUserID, CustomerName: issue.CustomerName, IssueContent: issue.IssueContent, AISuggestion: issue.AISuggestion, ResolutionContent: issue.ResolutionContent, AssignedEngineerID: issue.AssignedEngineerID, AssignedEngineerName: issue.AssignedEngineerName, ImageCount: len(issue.ImagePaths) + len(issue.ImageRefs), MarkdownPath: afterSalesKnowledgeMarkdownPath(issue.ID), } return normalizeAfterSalesKnowledgeCase(knowledgeCase) } func writeAfterSalesKnowledgeMarkdown(knowledgeCase AfterSalesKnowledgeCase) error { if err := os.MkdirAll(filepath.Dir(knowledgeCase.MarkdownPath), 0755); err != nil { return err } return os.WriteFile(knowledgeCase.MarkdownPath, []byte(renderAfterSalesKnowledgeMarkdown(knowledgeCase)), 0644) } func sortAfterSalesKnowledgeCases(cases []AfterSalesKnowledgeCase) { sort.Slice(cases, func(i, j int) bool { if cases[i].ResolvedAt != cases[j].ResolvedAt { return cases[i].ResolvedAt > cases[j].ResolvedAt } return cases[i].IssueID > cases[j].IssueID }) } func normalizeAfterSalesKnowledgeCase(item AfterSalesKnowledgeCase) AfterSalesKnowledgeCase { item.IssueID = strings.TrimSpace(item.IssueID) item.CustomerName = normalizeAfterSalesDisplayName(item.CustomerName) if strings.TrimSpace(item.MarkdownPath) == "" && item.IssueID != "" { item.MarkdownPath = afterSalesKnowledgeMarkdownPath(item.IssueID) } return item } func readAfterSalesKnowledgeCasesFile() (afterSalesKnowledgeCasesFile, error) { var store afterSalesKnowledgeCasesFile if err := readJSONFile(afterSalesKnowledgeCasesPath(), &store); err != nil { return store, err } for i := range store.Cases { store.Cases[i] = normalizeAfterSalesKnowledgeCase(store.Cases[i]) } return store, nil } func writeAfterSalesKnowledgeCasesFile(store afterSalesKnowledgeCasesFile) error { return atomicWriteJSON(afterSalesKnowledgeCasesPath(), store) } func afterSalesKnowledgeCasesPath() string { return resolveAutoReplyPath("config/after_sales_knowledge/cases.json") } func afterSalesKnowledgeMarkdownDir() string { return resolveAutoReplyPath("config/knowledge/after_sales_cases") } func afterSalesKnowledgeMarkdownPath(issueID string) string { return filepath.Join(afterSalesKnowledgeMarkdownDir(), safeAfterSalesKnowledgeFileID(issueID)+".md") } func safeAfterSalesKnowledgeFileID(value string) string { value = strings.TrimSpace(value) var builder strings.Builder for _, r := range value { switch { case r >= 'a' && r <= 'z': builder.WriteRune(r) case r >= 'A' && r <= 'Z': builder.WriteRune(r) case r >= '0' && r <= '9': builder.WriteRune(r) case r == '-' || r == '_': builder.WriteRune(r) } } if builder.Len() == 0 { return fmt.Sprintf("issue-%d", time.Now().UnixNano()) } return builder.String() } func renderAfterSalesKnowledgeMarkdown(item AfterSalesKnowledgeCase) string { var builder strings.Builder writeMarkdownLine := func(label string, value string) { value = strings.TrimSpace(value) if value == "" { value = "-" } builder.WriteString(label) builder.WriteString(value) builder.WriteString("\n") } builder.WriteString("# 售后已处理案例\n\n") writeMarkdownLine("问题ID:", item.IssueID) writeMarkdownLine("群聊:", item.RoomName) writeMarkdownLine("客户:", item.CustomerName) writeMarkdownLine("负责人:", displayAfterSalesKnowledgeEngineerName(item)) writeMarkdownLine("提出时间:", item.CreatedAt) writeMarkdownLine("处理时间:", item.ResolvedAt) writeMarkdownLine("图片数量:", fmt.Sprintf("%d", item.ImageCount)) builder.WriteString("\n## 问题\n\n") builder.WriteString(strings.TrimSpace(item.IssueContent)) builder.WriteString("\n\n## 最终处理方案\n\n") builder.WriteString(strings.TrimSpace(item.ResolutionContent)) builder.WriteString("\n\n## AI建议\n\n") aiSuggestion := strings.TrimSpace(item.AISuggestion) if aiSuggestion == "" { aiSuggestion = "-" } builder.WriteString(aiSuggestion) builder.WriteString("\n") return builder.String() } func displayAfterSalesKnowledgeEngineerName(item AfterSalesKnowledgeCase) string { if name := strings.TrimSpace(item.AssignedEngineerName); name != "" { return name } if id := strings.TrimSpace(item.AssignedEngineerID); id != "" { return id } return "-" } func revealAfterSalesKnowledgeCase(issueID string) (bool, string) { issueID = strings.TrimSpace(issueID) if issueID == "" { return false, "issueId为空" } path := afterSalesKnowledgeMarkdownPath(issueID) if !fileExists(path) { return false, "知识案例文件不存在" } cmd := exec.Command("explorer.exe", path) if err := cmd.Start(); err != nil { return false, err.Error() } return true, "opened" } func triggerAfterSalesKnowledgeRebuild() { go afterSalesKnowledgeRebuildHook() }