package main import ( "crypto/sha1" "encoding/hex" "encoding/json" "fmt" "os" "sort" "strings" "time" "github.com/google/uuid" ) var afterSalesAICollector = callAfterSalesAI func observeAfterSalesEvent(clientID int32, raw map[string]interface{}) { getAfterSalesIssueEngine().observeEvent(clientID, raw) } func (e *AfterSalesIssueEngine) observeEvent(clientID int32, raw map[string]interface{}) { message, ok := e.extractMessage(clientID, raw) if !ok { return } e.mu.Lock() defer e.mu.Unlock() for i := range e.messages { if e.messages[i].MessageID == message.MessageID { e.messages[i] = message e.trimMessagesLocked(time.Now()) e.updateStateMessageCountLocked() _ = e.saveMessagesLocked() _ = e.saveStateLocked() return } } e.messages = append(e.messages, message) e.trimMessagesLocked(time.Now()) e.updateStateMessageCountLocked() if err := e.saveMessagesLocked(); err != nil && globalLogger != nil { globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞垮劗濡插牓鏌涢銈呮瀺缂佽鲸鐗滅槐鎾诲磼濞戞瑥纰嶉梺瀹︽澘濡界€垫澘瀚蹇涱敃閵? %v", err) } if err := e.saveStateLocked(); err != nil && globalLogger != nil { globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞炬櫆閻撳倿鏌涢妷顔煎闁艰尙濞€閺岋絽螣閸喚鍘梺閫炲苯鍘哥紒鈧笟鈧俊鐢稿箣閻樺啿顏? %v", err) } } func (e *AfterSalesIssueEngine) extractMessage(clientID int32, raw map[string]interface{}) (AfterSalesMessage, bool) { autoEngine := getAutoReplyEngine() autoEngine.observeGroupNames(clientID, raw) msg := extractAutoReplyMessage(clientID, raw) autoEngine.enrichAutoReplyMessage(&msg, time.Now()) if !msg.IsGroup || strings.TrimSpace(msg.ConversationID) == "" { return AfterSalesMessage{}, false } identity := autoEngine.classifySenderIdentity(msg) imagePath, imageRef := extractAfterSalesImageFromMessage(msg, raw) messageID := strings.TrimSpace(firstNonEmpty(msg.ServerID, msg.LocalID)) if messageID == "" { messageID = stableAfterSalesMessageID(clientID, msg, imagePath, imageRef) } fileAttachment := extractAfterSalesFileFromMessage(msg, raw, messageID) messageType := msg.MessageType if imagePath != "" || imageRef != "" || looksLikeImageMessage(raw) { messageType = "image" } if fileAttachment.Path != "" || fileAttachment.Ref != "" || looksLikeAfterSalesFileMessage(msg, raw) { messageType = "file" } if messageType != "text" && messageType != "image" && messageType != "file" { return AfterSalesMessage{}, false } content := strings.TrimSpace(msg.Content) if content == "" && messageType == "image" { content = "image" } if content == "" && messageType == "file" { content = "file" if fileAttachment.Name != "" { content += ": " + fileAttachment.Name } } if content == "" && imagePath == "" && imageRef == "" && fileAttachment.Path == "" && fileAttachment.Ref == "" { return AfterSalesMessage{}, false } sendAt := parseAfterSalesSendTime(msg.SendTime) if sendAt <= 0 { sendAt = time.Now().Unix() } senderName := strings.TrimSpace(firstNonEmpty(identity.Name, msg.FromNickName)) if senderName == "" { senderName = "unknown customer" } roomName := strings.TrimSpace(msg.GroupName) if roomName == "" || roomName == msg.ConversationID { if resolved := autoEngine.ResolveGroupName(msg.ConversationID); resolved != "" { roomName = resolved } } if roomName == "" { roomName = msg.ConversationID } return AfterSalesMessage{ MessageID: messageID, ClientID: clientID, ConversationID: msg.ConversationID, RoomName: roomName, SenderUserID: msg.FromWxID, SenderName: senderName, SenderIdentity: identity.Kind, Content: content, MessageType: messageType, ImagePath: imagePath, ImageRef: imageRef, FilePath: fileAttachment.Path, FileRef: fileAttachment.Ref, FileName: fileAttachment.Name, FileContent: fileAttachment.Content, FileExtractStatus: fileAttachment.ExtractStatus, SendTime: sendAt, ReceivedAt: time.Now().Unix(), }, true } func (e *AfterSalesIssueEngine) triggerCollectAsync(conversationID string, manual bool) (bool, string) { conversationID = normalizeAfterSalesCollectConversationID(conversationID) if manual { e.mu.Lock() messages := append([]AfterSalesMessage(nil), e.messages...) e.mu.Unlock() if len(filterAfterSalesMessages(messages, 0, time.Now().Unix(), conversationID)) == 0 { return true, afterSalesCollectEmptyMessage(conversationID, manual, 0) } } e.mu.Lock() if e.state.Collecting { e.mu.Unlock() return false, "after-sales collection is already running" } e.state.Collecting = true e.state.LastError = "" e.updateStateMessageCountLocked() _ = e.saveStateLocked() e.mu.Unlock() go e.collectLockedAsync(conversationID, manual) if manual && conversationID != "" { if name := getAutoReplyEngine().ResolveGroupName(conversationID); name != "" { return true, fmt.Sprintf("started after-sales collection for group %s", name) } return true, "started after-sales collection for selected group" } return true, "started after-sales collection" } func (e *AfterSalesIssueEngine) collectLockedAsync(conversationID string, manual bool) { prewarmAfterSalesGroupNames() added, scanned, err := e.collectNow(conversationID, manual) e.mu.Lock() defer e.mu.Unlock() e.state.Collecting = false e.state.LastAddedCount = added e.state.LastCollectedAt = time.Now().Unix() if err != nil { e.state.LastError = err.Error() } else { e.state.LastError = afterSalesCollectEmptyMessage(conversationID, manual, scanned) if !manual { e.state.LastCollectAt = time.Now().Unix() } e.repairIssuesLocked() } e.updateStateMessageCountLocked() if saveErr := e.saveStateLocked(); saveErr != nil && globalLogger != nil { globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞炬櫆閻撳倿鏌涢妷顔煎闁艰尙濞€閺岋絽螣閸喚鍘梺閫炲苯鍘哥紒鈧笟鈧俊鐢稿箣閻樺啿顏? %v", saveErr) } } func (e *AfterSalesIssueEngine) collectNow(conversationID string, manual bool) (int, int, error) { conversationID = normalizeAfterSalesCollectConversationID(conversationID) e.mu.Lock() state := e.state messages := append([]AfterSalesMessage(nil), e.messages...) issues := append([]AfterSalesIssue(nil), e.issues...) e.mu.Unlock() now := time.Now() from := int64(0) if !manual { from = state.LastCollectAt if from <= 0 { from = now.Add(-afterSalesFirstCollectWindow).Unix() } } targets := filterAfterSalesMessages(messages, from, now.Unix(), conversationID) if len(targets) == 0 { return 0, 0, nil } cfg := getAutoReplyEngine().getConfig() if strings.TrimSpace(cfg.AI.BaseURL) == "" || strings.TrimSpace(cfg.AI.Model) == "" { return 0, len(targets), fmt.Errorf("AI config is incomplete, cannot collect after-sales issues") } existingFingerprints := make(map[string]AfterSalesIssue) for _, issue := range issues { if issue.Fingerprint != "" { existingFingerprints[issue.Fingerprint] = issue } } added := 0 for _, batch := range batchAfterSalesMessages(targets, afterSalesBatchSize) { candidates, err := afterSalesAICollector(cfg.AI, batch) if err != nil { return added, len(targets), err } added += e.mergeAIIssueCandidates(candidates, batch, existingFingerprints) } return added, len(targets), nil } func prewarmAfterSalesGroupNames() { engine := getAutoReplyEngine() done := make(chan struct{}) go func() { _ = engine.refreshIdentityGroups("after_sales_collect") close(done) }() select { case <-done: case <-time.After(2 * time.Second): } } func (e *AfterSalesIssueEngine) mergeAIIssueCandidates(candidates []afterSalesAIIssueCandidate, batch []AfterSalesMessage, existing map[string]AfterSalesIssue) int { if len(candidates) == 0 { return 0 } messageByID := make(map[string]AfterSalesMessage) for _, msg := range batch { messageByID[msg.MessageID] = msg } batchID := newAfterSalesID() now := time.Now().Local().Format(time.RFC3339) added := 0 e.mu.Lock() for _, candidate := range candidates { issueContent := strings.TrimSpace(candidate.IssueContent) if issueContent == "" { continue } sourceIDs := uniqueNonEmptyStrings(candidate.SourceMessageIDs) seed := firstCandidateMessage(sourceIDs, batch, messageByID) customerUserID := strings.TrimSpace(candidate.CustomerUserID) if customerUserID == "" { customerUserID = seed.SenderUserID } customerName := normalizeAfterSalesDisplayName(firstNonEmpty(candidate.CustomerName, seed.SenderName)) roomName := strings.TrimSpace(firstNonEmpty(candidate.RoomName, seed.RoomName)) conversationID := seed.ConversationID if conversationID == "" && len(batch) > 0 { conversationID = batch[0].ConversationID } if roomName == "" || roomName == conversationID || strings.HasPrefix(roomName, "R:") { if resolved := getAutoReplyEngine().ResolveGroupName(conversationID); resolved != "" { roomName = resolved } } if roomName == "" { roomName = conversationID } sourceClientID := seed.ClientID sourceAccountUserID, sourceAccountName := getAutoReplyEngine().sourceAccountForClient(sourceClientID) fingerprint := afterSalesFingerprint(conversationID, customerUserID, issueContent) if existingIssue, ok := existing[fingerprint]; ok { if existingIssue.Status == afterSalesIssueStatusResolved || existingIssue.Status == afterSalesIssueStatusIgnored || existingIssue.ID != "" { continue } } imagePaths, imageRefs := collectCandidateImages(candidate, sourceIDs, batch, messageByID) fileAttachments := collectCandidateFileAttachments(sourceIDs, batch, messageByID) issue := AfterSalesIssue{ ID: newAfterSalesID(), CreatedAt: now, UpdatedAt: now, ConversationID: conversationID, RoomName: roomName, SourceClientID: sourceClientID, SourceAccountUserID: sourceAccountUserID, SourceAccountName: sourceAccountName, CustomerUserID: customerUserID, CustomerName: customerName, IssueContent: issueContent, ImagePaths: imagePaths, ImageRefs: imageRefs, FileAttachments: fileAttachments, AISuggestion: strings.TrimSpace(candidate.AISuggestion), Status: afterSalesIssueStatusPending, SourceMessageIDs: sourceIDs, Fingerprint: fingerprint, CollectBatchID: batchID, AIConfidence: candidate.Confidence, AISuggestionEdited: false, } if len(issue.SourceMessageIDs) == 0 { issue.SourceMessageIDs = messageIDsForBatch(batch) } e.issues = append(e.issues, issue) existing[fingerprint] = issue added++ } if added > 0 { if err := e.saveIssuesLocked(); err != nil && globalLogger != nil { globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞炬櫆閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈闁绘劖鍨濋弶顓㈡煟? %v", err) } } shouldRefreshDispatch := added > 0 e.mu.Unlock() if shouldRefreshDispatch { e.refreshDispatchAssignmentsAsync() } return added } func (e *AfterSalesIssueEngine) autoCollectLoop() { ticker := time.NewTicker(time.Minute) defer ticker.Stop() for range ticker.C { e.mu.Lock() enabled := e.state.AutoCollectEnabled collecting := e.state.Collecting last := e.state.LastCollectedAt e.mu.Unlock() if !enabled || collecting { continue } if last > 0 && time.Since(time.Unix(last, 0)) < afterSalesAutoCollectEvery { continue } e.triggerCollectAsync("", false) } } func (e *AfterSalesIssueEngine) trimMessagesLocked(now time.Time) { cutoff := now.Add(-afterSalesMessageBufferHours * time.Hour).Unix() next := e.messages[:0] for _, msg := range e.messages { ts := msg.SendTime if ts <= 0 { ts = msg.ReceivedAt } if ts >= cutoff { next = append(next, msg) } } e.messages = next } func filterAfterSalesMessages(messages []AfterSalesMessage, from int64, to int64, conversationID string) []AfterSalesMessage { conversationID = normalizeAfterSalesCollectConversationID(conversationID) result := make([]AfterSalesMessage, 0, len(messages)) for _, msg := range messages { if conversationID != "" && strings.TrimSpace(msg.ConversationID) != conversationID { continue } ts := msg.SendTime if ts <= 0 { ts = msg.ReceivedAt } if ts > from && ts <= to { result = append(result, msg) } } sort.Slice(result, func(i, j int) bool { if result[i].ConversationID != result[j].ConversationID { return result[i].ConversationID < result[j].ConversationID } return result[i].SendTime < result[j].SendTime }) return result } func normalizeAfterSalesCollectConversationID(conversationID string) string { conversationID = strings.TrimSpace(conversationID) if strings.EqualFold(conversationID, afterSalesManualCollectAll) { return "" } return conversationID } func afterSalesCollectEmptyMessage(conversationID string, manual bool, scanned int) string { if !manual || scanned > 0 { return "" } if normalizeAfterSalesCollectConversationID(conversationID) != "" { return "selected group has no cached messages to analyze" } return "no cached messages to analyze" } func batchAfterSalesMessages(messages []AfterSalesMessage, size int) [][]AfterSalesMessage { if size <= 0 { size = afterSalesBatchSize } grouped := make(map[string][]AfterSalesMessage) keys := make([]string, 0) for _, msg := range messages { key := msg.ConversationID if _, exists := grouped[key]; !exists { keys = append(keys, key) } grouped[key] = append(grouped[key], msg) } sort.Strings(keys) var batches [][]AfterSalesMessage for _, key := range keys { items := grouped[key] sort.Slice(items, func(i, j int) bool { return items[i].SendTime < items[j].SendTime }) for start := 0; start < len(items); start += size { end := start + size if end > len(items) { end = len(items) } batches = append(batches, append([]AfterSalesMessage(nil), items[start:end]...)) } } return batches } func firstCandidateMessage(sourceIDs []string, batch []AfterSalesMessage, byID map[string]AfterSalesMessage) AfterSalesMessage { for _, id := range sourceIDs { if msg, ok := byID[id]; ok && msg.SenderIdentity != senderIdentityInternal { return msg } } for _, id := range sourceIDs { if msg, ok := byID[id]; ok { return msg } } for _, msg := range batch { if msg.SenderIdentity != senderIdentityInternal { return msg } } if len(batch) > 0 { return batch[0] } return AfterSalesMessage{} } func collectCandidateImages(candidate afterSalesAIIssueCandidate, sourceIDs []string, batch []AfterSalesMessage, byID map[string]AfterSalesMessage) ([]string, []string) { paths := append([]string(nil), candidate.ImagePaths...) refs := append([]string(nil), candidate.ImageRefs...) addMessageImage := func(msg AfterSalesMessage) { if msg.ImagePath != "" { paths = append(paths, msg.ImagePath) } if msg.ImageRef != "" { refs = append(refs, msg.ImageRef) } } if len(sourceIDs) > 0 { for _, id := range sourceIDs { if msg, ok := byID[id]; ok { addMessageImage(msg) } } } else { for _, msg := range batch { addMessageImage(msg) } } return uniqueExistingImagePaths(paths), uniqueNonEmptyStrings(refs) } func collectCandidateFileAttachments(sourceIDs []string, batch []AfterSalesMessage, byID map[string]AfterSalesMessage) []AfterSalesFileAttachment { files := make([]AfterSalesFileAttachment, 0) addMessageFile := func(msg AfterSalesMessage) { file := AfterSalesFileAttachment{ Name: msg.FileName, Path: msg.FilePath, Ref: msg.FileRef, Content: msg.FileContent, ExtractStatus: msg.FileExtractStatus, SourceMessageID: msg.MessageID, } file = normalizeAfterSalesFileAttachment(file) if file.Path != "" || file.Ref != "" || file.Content != "" { files = append(files, file) } } if len(sourceIDs) > 0 { for _, id := range sourceIDs { if msg, ok := byID[id]; ok { addMessageFile(msg) } } } else { for _, msg := range batch { addMessageFile(msg) } } return normalizeAfterSalesFileAttachments(files) } func messageIDsForBatch(batch []AfterSalesMessage) []string { result := make([]string, 0, len(batch)) for _, msg := range batch { result = append(result, msg.MessageID) } return uniqueNonEmptyStrings(result) } func uniqueExistingImagePaths(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 } if _, err := os.Stat(item); err == nil { seen[item] = struct{}{} result = append(result, item) } } return result } func afterSalesFingerprint(conversationID string, customerUserID string, content string) string { normalized := normalizeAfterSalesIssueText(content) raw := strings.Join([]string{strings.TrimSpace(conversationID), strings.TrimSpace(customerUserID), normalized}, "|") sum := sha1.Sum([]byte(raw)) return hex.EncodeToString(sum[:]) } func normalizeAfterSalesIssueText(text string) string { text = strings.ToLower(strings.TrimSpace(text)) replacer := strings.NewReplacer("\r", "", "\n", "", "\t", "", " ", "", ",", ",", "。", ".", "?", "?", "!", "!") return replacer.Replace(text) } func stableAfterSalesMessageID(clientID int32, msg autoReplyMessage, imagePath string, imageRef string) string { raw := fmt.Sprintf("%d|%s|%s|%s|%s|%s|%s|%s|%s", clientID, msg.ConversationID, msg.SendTime, msg.FromWxID, msg.Content, imagePath, imageRef, msg.MediaFileName, msg.MediaFileID) sum := sha1.Sum([]byte(raw)) return hex.EncodeToString(sum[:]) } func newAfterSalesID() string { return uuid.NewString() } func parseAfterSalesSendTime(raw string) int64 { raw = strings.TrimSpace(raw) if raw == "" { return 0 } var n int64 if err := json.Unmarshal([]byte(raw), &n); err == nil { if n > 1000000000000 { n = n / 1000 } return n } if _, err := fmt.Sscanf(raw, "%d", &n); err == nil { if n > 1000000000000 { n = n / 1000 } return n } return 0 } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" }