package main import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" "sync" "time" ) type AfterSalesIssueEngine struct { mu sync.Mutex issues []AfterSalesIssue messages []AfterSalesMessage state AfterSalesCollectState } var afterSalesIssueEngine *AfterSalesIssueEngine func initAfterSalesIssueEngine() { engine := &AfterSalesIssueEngine{} if err := engine.load(); err != nil && globalLogger != nil { globalLogger.Warn("[售后问题库] 加载本地数据失败: %v", err) } engine.updateStateMessageCountLocked() afterSalesIssueEngine = engine go engine.autoCollectLoop() } func getAfterSalesIssueEngine() *AfterSalesIssueEngine { if afterSalesIssueEngine == nil { initAfterSalesIssueEngine() } return afterSalesIssueEngine } func (e *AfterSalesIssueEngine) load() error { e.mu.Lock() defer e.mu.Unlock() var errs []string if err := readJSONFile(afterSalesIssuesPath(), &e.issues); err != nil { errs = append(errs, err.Error()) } if err := readJSONFile(afterSalesStatePath(), &e.state); err != nil { errs = append(errs, err.Error()) } if err := readJSONFile(afterSalesMessageBufferPath(), &e.messages); err != nil { errs = append(errs, err.Error()) } e.normalizeIssuesLocked() if e.repairIssuesLocked() { _ = e.saveIssuesLocked() } e.trimMessagesLocked(time.Now()) e.updateStateMessageCountLocked() if len(errs) > 0 { return errors.New(strings.Join(errs, "; ")) } return nil } func (e *AfterSalesIssueEngine) snapshotIssues() []AfterSalesIssue { e.mu.Lock() defer e.mu.Unlock() result := append([]AfterSalesIssue(nil), e.issues...) sort.Slice(result, func(i, j int) bool { return result[i].CreatedAt > result[j].CreatedAt }) return result } func (e *AfterSalesIssueEngine) snapshotStatus() AfterSalesCollectState { e.mu.Lock() defer e.mu.Unlock() e.updateStateMessageCountLocked() return e.state } func (e *AfterSalesIssueEngine) saveIssue(issue AfterSalesIssue) error { e.mu.Lock() defer e.mu.Unlock() now := time.Now().Local().Format(time.RFC3339) issue.ID = strings.TrimSpace(issue.ID) if issue.ID == "" { issue.ID = newAfterSalesID() } if strings.TrimSpace(issue.CreatedAt) == "" { issue.CreatedAt = now } issue.UpdatedAt = now issue.Status = normalizeAfterSalesStatus(issue.Status) issue.CustomerName = normalizeAfterSalesDisplayName(issue.CustomerName) issue.ImagePaths = uniqueNonEmptyStrings(issue.ImagePaths) issue.ImageRefs = uniqueNonEmptyStrings(issue.ImageRefs) issue.FileAttachments = normalizeAfterSalesFileAttachments(issue.FileAttachments) issue.SourceMessageIDs = uniqueNonEmptyStrings(issue.SourceMessageIDs) issue.SourceAccountUserID = strings.TrimSpace(issue.SourceAccountUserID) issue.SourceAccountName = strings.TrimSpace(issue.SourceAccountName) normalizeAfterSalesDispatchFields(&issue) if issue.Fingerprint == "" { issue.Fingerprint = afterSalesFingerprint(issue.ConversationID, issue.CustomerUserID, issue.IssueContent) } for i := range e.issues { if e.issues[i].ID == issue.ID { if strings.TrimSpace(issue.AISuggestion) != strings.TrimSpace(e.issues[i].AISuggestion) { issue.AISuggestionEdited = true } if issue.CollectBatchID == "" { issue.CollectBatchID = e.issues[i].CollectBatchID } if issue.Fingerprint == "" { issue.Fingerprint = e.issues[i].Fingerprint } if issue.SourceClientID == 0 { issue.SourceClientID = e.issues[i].SourceClientID } if strings.TrimSpace(issue.SourceAccountUserID) == "" { issue.SourceAccountUserID = e.issues[i].SourceAccountUserID } if strings.TrimSpace(issue.SourceAccountName) == "" { issue.SourceAccountName = e.issues[i].SourceAccountName } e.issues[i] = issue return e.saveIssuesLocked() } } e.issues = append(e.issues, issue) return e.saveIssuesLocked() } func (e *AfterSalesIssueEngine) deleteIssue(id string) bool { e.mu.Lock() defer e.mu.Unlock() id = strings.TrimSpace(id) if id == "" { return false } next := e.issues[:0] deleted := false for _, issue := range e.issues { if issue.ID == id { deleted = true continue } next = append(next, issue) } e.issues = next if deleted { if err := e.saveIssuesLocked(); err != nil && globalLogger != nil { globalLogger.Warn("[售后问题库] 删除后保存失败: %v", err) } } return deleted } func (e *AfterSalesIssueEngine) setAutoCollectEnabled(enabled bool) error { e.mu.Lock() e.state.AutoCollectEnabled = enabled e.updateStateMessageCountLocked() err := e.saveStateLocked() e.mu.Unlock() return err } func (e *AfterSalesIssueEngine) normalizeIssuesLocked() { for i := range e.issues { e.issues[i].Status = normalizeAfterSalesStatus(e.issues[i].Status) e.issues[i].CustomerName = normalizeAfterSalesDisplayName(e.issues[i].CustomerName) e.issues[i].ImagePaths = uniqueNonEmptyStrings(e.issues[i].ImagePaths) e.issues[i].ImageRefs = uniqueNonEmptyStrings(e.issues[i].ImageRefs) e.issues[i].FileAttachments = normalizeAfterSalesFileAttachments(e.issues[i].FileAttachments) e.issues[i].SourceMessageIDs = uniqueNonEmptyStrings(e.issues[i].SourceMessageIDs) e.issues[i].SourceAccountUserID = strings.TrimSpace(e.issues[i].SourceAccountUserID) e.issues[i].SourceAccountName = strings.TrimSpace(e.issues[i].SourceAccountName) normalizeAfterSalesDispatchFields(&e.issues[i]) if e.issues[i].ID == "" { e.issues[i].ID = newAfterSalesID() } if e.issues[i].CreatedAt == "" { e.issues[i].CreatedAt = time.Now().Local().Format(time.RFC3339) } if e.issues[i].UpdatedAt == "" { e.issues[i].UpdatedAt = e.issues[i].CreatedAt } if e.issues[i].Fingerprint == "" { e.issues[i].Fingerprint = afterSalesFingerprint(e.issues[i].ConversationID, e.issues[i].CustomerUserID, e.issues[i].IssueContent) } } } func (e *AfterSalesIssueEngine) repairIssuesLocked() bool { changed := false messageByID := make(map[string]AfterSalesMessage) for _, msg := range e.messages { if strings.TrimSpace(msg.MessageID) != "" { messageByID[msg.MessageID] = msg } } for i := range e.issues { issue := &e.issues[i] conversationID := strings.TrimSpace(issue.ConversationID) roomName := strings.TrimSpace(issue.RoomName) if conversationID != "" && (roomName == "" || roomName == conversationID || strings.HasPrefix(roomName, "R:")) { if resolved := getAutoReplyEngine().ResolveGroupName(conversationID); resolved != "" { issue.RoomName = resolved changed = true } } if issue.SourceClientID == 0 || strings.TrimSpace(issue.SourceAccountUserID) == "" || strings.TrimSpace(issue.SourceAccountName) == "" { for _, id := range issue.SourceMessageIDs { msg, ok := messageByID[id] if !ok || msg.ClientID == 0 { continue } if issue.SourceClientID == 0 { issue.SourceClientID = msg.ClientID changed = true } userID, name := getAutoReplyEngine().sourceAccountForClient(msg.ClientID) if strings.TrimSpace(issue.SourceAccountUserID) == "" && userID != "" { issue.SourceAccountUserID = userID changed = true } if strings.TrimSpace(issue.SourceAccountName) == "" && name != "" { issue.SourceAccountName = name changed = true } break } } paths := append([]string(nil), issue.ImagePaths...) for _, id := range issue.SourceMessageIDs { if msg, ok := messageByID[id]; ok && msg.ImagePath != "" { paths = append(paths, msg.ImagePath) } } if len(paths) == 0 && len(issue.ImageRefs) > 0 { for _, ref := range issue.ImageRefs { if path := resolveAfterSalesImageRef(ref); path != "" { paths = append(paths, path) } } } paths = uniqueExistingImagePaths(paths) if !sameStringSlice(issue.ImagePaths, paths) { issue.ImagePaths = paths changed = true } files := append([]AfterSalesFileAttachment(nil), issue.FileAttachments...) for _, id := range issue.SourceMessageIDs { if msg, ok := messageByID[id]; ok { files = append(files, collectCandidateFileAttachments([]string{id}, nil, map[string]AfterSalesMessage{id: msg})...) } } files = normalizeAfterSalesFileAttachments(files) if !sameAfterSalesFileAttachments(issue.FileAttachments, files) { issue.FileAttachments = files changed = true } } return changed } func sameAfterSalesFileAttachments(a []AfterSalesFileAttachment, b []AfterSalesFileAttachment) bool { a = normalizeAfterSalesFileAttachments(a) b = normalizeAfterSalesFileAttachments(b) if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func (e *AfterSalesIssueEngine) saveIssuesLocked() error { return atomicWriteJSON(afterSalesIssuesPath(), e.issues) } func (e *AfterSalesIssueEngine) saveStateLocked() error { return atomicWriteJSON(afterSalesStatePath(), e.state) } func (e *AfterSalesIssueEngine) saveMessagesLocked() error { return atomicWriteJSON(afterSalesMessageBufferPath(), e.messages) } func (e *AfterSalesIssueEngine) updateStateMessageCountLocked() { e.state.MessageBufferCount = len(e.messages) } func afterSalesIssuesPath() string { return resolveAutoReplyPath("config/after_sales_issues.json") } func afterSalesStatePath() string { return resolveAutoReplyPath("config/after_sales_collect_state.json") } func afterSalesMessageBufferPath() string { return resolveAutoReplyPath("config/after_sales_message_buffer.json") } func readJSONFile(path string, target interface{}) error { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("%s: %w", path, err) } if len(strings.TrimSpace(string(data))) == 0 { return nil } if err := json.Unmarshal(data, target); err != nil { return fmt.Errorf("%s: %w", path, err) } return nil } func atomicWriteJSON(path string, value interface{}) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } data, err := json.MarshalIndent(value, "", " ") if err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0644); err != nil { return err } return os.Rename(tmp, path) } func normalizeAfterSalesStatus(status string) string { switch strings.ToLower(strings.TrimSpace(status)) { case afterSalesIssueStatusResolved: return afterSalesIssueStatusResolved case afterSalesIssueStatusIgnored: return afterSalesIssueStatusIgnored default: return afterSalesIssueStatusPending } } func normalizeAfterSalesDisplayName(name string) string { name = strings.TrimSpace(name) if name == "" || strings.EqualFold(name, "unknown") { return "未知客户" } return name } func uniqueNonEmptyStrings(items []string) []string { seen := make(map[string]struct{}) result := make([]string, 0, len(items)) for _, item := range items { item = strings.TrimSpace(item) if item == "" { continue } if _, exists := seen[item]; exists { continue } seen[item] = struct{}{} result = append(result, item) } return result } func sameStringSlice(a []string, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }