package main import ( "crypto/sha1" "encoding/hex" "fmt" "regexp" "strings" "time" ) var ( afterSalesHistoryHeaderPatterns = []*regexp.Regexp{ regexp.MustCompile(`^(.{1,40}?)\s+(\d{4}[-/年]\d{1,2}[-/月]\d{1,2}(?:日)?\s+\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`), regexp.MustCompile(`^(.{1,40}?)\s+(\d{1,2}[-/月]\d{1,2}(?:日)?\s+\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`), regexp.MustCompile(`^(.{1,40}?)\s+((?:今天|昨天|前天)\s+\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`), regexp.MustCompile(`^(.{1,40}?)\s+(\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`), regexp.MustCompile(`^(.{1,40}?)[::]\s*(.*)$`), } afterSalesHistorySystemLine = regexp.MustCompile(`^(你|您)?(修改群名为|撤回了一条消息|加入群聊|退出群聊|邀请|移出|已读|以下为新消息)`) ) type afterSalesHistoryParsedLine struct { Sender string Content string SendAt int64 } type afterSalesHistoryCollectResult struct { Imported int Segments int Added int } func (e *AfterSalesIssueEngine) importHistoryAndCollect(req AfterSalesHistoryImportRequest) (int, int, error) { result, err := e.importHistoryAndCollectDetailed(req) return result.Imported, result.Added, err } func (e *AfterSalesIssueEngine) importHistoryAndCollectDetailed(req AfterSalesHistoryImportRequest) (afterSalesHistoryCollectResult, error) { conversationID := normalizeAfterSalesCollectConversationID(req.ConversationID) roomName := strings.TrimSpace(req.RoomName) if conversationID == "" { return afterSalesHistoryCollectResult{}, fmt.Errorf("请选择要导入的群聊") } if roomName == "" { roomName = getAutoReplyEngine().ResolveGroupName(conversationID) } if roomName == "" { roomName = conversationID } rawText := strings.TrimSpace(req.RawText) if rawText == "" { return afterSalesHistoryCollectResult{}, fmt.Errorf("请先粘贴企微群聊历史消息") } messages := parseAfterSalesHistoryMessages(conversationID, roomName, rawText, time.Now()) if len(messages) == 0 { return afterSalesHistoryCollectResult{}, fmt.Errorf("未能从粘贴内容中解析出可导入的历史消息") } imported := e.mergeHistoryMessages(messages) if imported == 0 { return afterSalesHistoryCollectResult{}, nil } added, segments, err := e.collectHistoryMessages(conversationID, messages) e.mu.Lock() e.state.LastAddedCount = added e.state.LastCollectedAt = time.Now().Unix() if err != nil { e.state.LastError = err.Error() } else { e.state.LastError = "" e.repairIssuesLocked() } e.updateStateMessageCountLocked() _ = e.saveStateLocked() e.mu.Unlock() return afterSalesHistoryCollectResult{Imported: imported, Segments: segments, Added: added}, err } func (e *AfterSalesIssueEngine) mergeHistoryMessages(messages []AfterSalesMessage) int { now := time.Now() e.mu.Lock() defer e.mu.Unlock() existing := make(map[string]int, len(e.messages)) for i, msg := range e.messages { if strings.TrimSpace(msg.MessageID) != "" { existing[msg.MessageID] = i } } imported := 0 for _, msg := range messages { if msg.MessageID == "" { continue } if idx, ok := existing[msg.MessageID]; ok { e.messages[idx] = msg continue } e.messages = append(e.messages, msg) existing[msg.MessageID] = len(e.messages) - 1 imported++ } e.trimMessagesLocked(now) e.updateStateMessageCountLocked() if imported > 0 { _ = e.saveMessagesLocked() _ = e.saveStateLocked() } return imported } func (e *AfterSalesIssueEngine) collectHistoryMessages(conversationID string, importedMessages []AfterSalesMessage) (int, int, error) { e.mu.Lock() issues := append([]AfterSalesIssue(nil), e.issues...) e.mu.Unlock() cfg := getAutoReplyEngine().getConfig() if strings.TrimSpace(cfg.AI.BaseURL) == "" || strings.TrimSpace(cfg.AI.Model) == "" { return 0, 0, fmt.Errorf("AI 配置未完整,无法收集售后问题") } existingFingerprints := make(map[string]AfterSalesIssue) for _, issue := range issues { if issue.Fingerprint != "" { existingFingerprints[issue.Fingerprint] = issue } } segments := segmentAfterSalesHistoryMessages(importedMessages) added := 0 for _, segment := range segments { if len(segment) == 0 { continue } candidates, err := afterSalesAICollector(cfg.AI, segment) if err != nil { return added, len(segments), err } added += e.mergeAIIssueCandidates(candidates, segment, existingFingerprints) } return added, len(segments), nil } func segmentAfterSalesHistoryMessages(messages []AfterSalesMessage) [][]AfterSalesMessage { var segments [][]AfterSalesMessage for _, msg := range messages { if !historyMessageLooksLikeIssue(msg) { continue } segments = append(segments, []AfterSalesMessage{msg}) } if len(segments) == 0 && len(messages) > 0 { return batchAfterSalesMessages(messages, 6) } return segments } func historyMessageLooksLikeIssue(msg AfterSalesMessage) bool { content := strings.TrimSpace(msg.Content) if content == "" { return false } if msg.SenderIdentity == senderIdentityInternal { return false } lower := strings.ToLower(content) noise := []string{"你好", "谢谢", "收到", "ok", "好的", "辛苦", "师傅好"} for _, item := range noise { if lower == strings.ToLower(item) { return false } } keywords := []string{ "坏", "故障", "报错", "不", "没", "无法", "不能", "失败", "异常", "卡", "掉线", "没电", "电压", "短接", "测试", "维修", "更换", "装不上", "启动", "低于", } for _, keyword := range keywords { if strings.Contains(content, keyword) { return true } } return len([]rune(content)) >= 18 } func parseAfterSalesHistoryMessages(conversationID, roomName, rawText string, importedAt time.Time) []AfterSalesMessage { lines := strings.Split(strings.ReplaceAll(rawText, "\r\n", "\n"), "\n") parsed := make([]afterSalesHistoryParsedLine, 0) var current *afterSalesHistoryParsedLine for _, line := range lines { line = strings.TrimSpace(line) if line == "" || afterSalesHistorySystemLine.MatchString(line) { continue } next, ok := parseAfterSalesHistoryLine(line, importedAt) if ok { if current != nil { parsed = append(parsed, *current) } current = &next continue } if current == nil { current = &afterSalesHistoryParsedLine{Sender: "未知客户", SendAt: importedAt.Unix(), Content: line} } else { current.Content = strings.TrimSpace(current.Content + "\n" + line) } } if current != nil { parsed = append(parsed, *current) } messages := make([]AfterSalesMessage, 0, len(parsed)) for i, item := range parsed { content := strings.TrimSpace(item.Content) if content == "" { continue } messageType := "text" imageRef := "" if looksLikeHistoryImageText(content) { messageType = "image" imageRef = content } sendAt := item.SendAt if sendAt <= 0 { sendAt = importedAt.Unix() } senderName := cleanAfterSalesHistorySender(item.Sender) if senderName == "" { senderName = "未知客户" } messages = append(messages, AfterSalesMessage{ MessageID: stableAfterSalesHistoryMessageID(conversationID, senderName, content, sendAt, i), ClientID: 0, ConversationID: conversationID, RoomName: roomName, SenderUserID: "history:" + senderName, SenderName: senderName, SenderIdentity: inferAfterSalesHistorySenderIdentity(senderName, content), Content: content, MessageType: messageType, ImageRef: imageRef, SendTime: sendAt, ReceivedAt: importedAt.Unix(), }) } return messages } func parseAfterSalesHistoryLine(line string, base time.Time) (afterSalesHistoryParsedLine, bool) { for idx, pattern := range afterSalesHistoryHeaderPatterns { match := pattern.FindStringSubmatch(line) if len(match) == 0 { continue } sender := cleanAfterSalesHistorySender(match[1]) if sender == "" || looksLikeHistoryTime(sender) { continue } if idx == 4 { return afterSalesHistoryParsedLine{Sender: sender, Content: strings.TrimSpace(match[2]), SendAt: base.Unix()}, true } return afterSalesHistoryParsedLine{Sender: sender, SendAt: parseAfterSalesHistoryTime(match[2], base), Content: strings.TrimSpace(match[3])}, true } return afterSalesHistoryParsedLine{}, false } func parseAfterSalesHistoryTime(text string, base time.Time) int64 { text = strings.TrimSpace(text) replacements := map[string]string{"年": "-", "月": "-", "日": "", "/": "-"} for old, next := range replacements { text = strings.ReplaceAll(text, old, next) } if regexp.MustCompile(`^\d{1,2}-\d{1,2}\s+`).MatchString(text) { text = fmt.Sprintf("%d-%s", base.Year(), text) } formats := []string{ "2006-1-2 15:04:05", "2006-1-2 15:04", } for _, format := range formats { if ts, err := time.ParseInLocation(format, text, time.Local); err == nil { return ts.Unix() } } day := time.Date(base.Year(), base.Month(), base.Day(), 0, 0, 0, 0, base.Location()) switch { case strings.HasPrefix(text, "昨天"): day = day.AddDate(0, 0, -1) text = strings.TrimSpace(strings.TrimPrefix(text, "昨天")) case strings.HasPrefix(text, "前天"): day = day.AddDate(0, 0, -2) text = strings.TrimSpace(strings.TrimPrefix(text, "前天")) case strings.HasPrefix(text, "今天"): text = strings.TrimSpace(strings.TrimPrefix(text, "今天")) } if clock, err := time.ParseInLocation("15:04:05", text, base.Location()); err == nil { return time.Date(day.Year(), day.Month(), day.Day(), clock.Hour(), clock.Minute(), clock.Second(), 0, base.Location()).Unix() } if clock, err := time.ParseInLocation("15:04", text, base.Location()); err == nil { return time.Date(day.Year(), day.Month(), day.Day(), clock.Hour(), clock.Minute(), 0, 0, base.Location()).Unix() } return base.Unix() } func cleanAfterSalesHistorySender(sender string) string { sender = strings.TrimSpace(sender) sender = strings.TrimPrefix(sender, "@") sender = strings.ReplaceAll(sender, "未命名企业", "") sender = regexp.MustCompile(`\s+`).ReplaceAllString(sender, " ") return strings.TrimSpace(sender) } func inferAfterSalesHistorySenderIdentity(senderName string, content string) string { text := senderName + " " + content internalHints := []string{"郑工", "梁师傅", "师傅", "工程师", "客服", "售后"} for _, hint := range internalHints { if strings.Contains(text, hint) && strings.Contains(content, "按") { return senderIdentityInternal } } return senderIdentityExternal } func looksLikeHistoryTime(text string) bool { return regexp.MustCompile(`^\d{1,4}[-/:年月日\s]`).MatchString(strings.TrimSpace(text)) } func looksLikeHistoryImageText(content string) bool { content = strings.TrimSpace(strings.Trim(content, "[]【】")) return content == "图片" || strings.EqualFold(content, "image") } func stableAfterSalesHistoryMessageID(conversationID, senderName, content string, sendAt int64, index int) string { sum := sha1.Sum([]byte(fmt.Sprintf("history|%s|%s|%d|%d|%s", conversationID, senderName, sendAt, index, content))) return "history:" + hex.EncodeToString(sum[:]) } func afterSalesHistoryImportMessage(imported, segments, added int) string { if imported == 0 { return "历史消息已存在,没有新增导入" } return fmt.Sprintf("已同步 %d 条历史消息,分析 %d 段,新增 %d 条售后问题", imported, segments, added) }