package main import ( "errors" "os" "path/filepath" "strings" "testing" "time" "qiweimanager/config" ) func TestAfterSalesAtomicJSONReadWrite(t *testing.T) { path := filepath.Join(t.TempDir(), "issues.json") want := []AfterSalesIssue{{ ID: "issue-1", RoomName: "VIP客户售后群-001", CustomerName: "张三", Status: afterSalesIssueStatusPending, }} if err := atomicWriteJSON(path, want); err != nil { t.Fatalf("atomicWriteJSON failed: %v", err) } var got []AfterSalesIssue if err := readJSONFile(path, &got); err != nil { t.Fatalf("readJSONFile failed: %v", err) } if len(got) != 1 || got[0].ID != want[0].ID || got[0].CustomerName != want[0].CustomerName { t.Fatalf("unexpected issues: %#v", got) } badPath := filepath.Join(t.TempDir(), "bad.json") if err := os.WriteFile(badPath, []byte("{bad json"), 0644); err != nil { t.Fatalf("write bad json: %v", err) } if err := readJSONFile(badPath, &got); err == nil { t.Fatal("expected bad JSON to return an error") } } func TestParseAfterSalesAIResponseFromMarkdownFence(t *testing.T) { text := "```json\n" + `[{"room_name":"售后群","customer_user_id":"wm_1","customer_name":"李四","issue_content":"软件无法登录","image_paths":["C:\\tmp\\a.png",""],"image_refs":["cdn-1"],"ai_suggestion":"建议先检查网络。","source_message_ids":["m1"],"confidence":0.82}]` + "\n```" got, err := parseAfterSalesAIResponse(text) if err != nil { t.Fatalf("parseAfterSalesAIResponse failed: %v", err) } if len(got) != 1 { t.Fatalf("expected 1 candidate, got %d", len(got)) } if got[0].CustomerName != "李四" || got[0].IssueContent != "软件无法登录" { t.Fatalf("unexpected candidate: %#v", got[0]) } if len(got[0].ImagePaths) != 1 || got[0].ImagePaths[0] != `C:\tmp\a.png` { t.Fatalf("image paths were not normalized: %#v", got[0].ImagePaths) } } func TestAfterSalesCollectUsesFirstHourWindowAndAdvancesOnSuccess(t *testing.T) { restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t) defer restoreAutoReply() defer restoreCollector() now := time.Now() engine := &AfterSalesIssueEngine{ messages: []AfterSalesMessage{ { MessageID: "old", ConversationID: "room-1", RoomName: "售后群", SenderUserID: "wm_1", SenderName: "张三", SenderIdentity: senderIdentityExternal, Content: "两小时前的问题", MessageType: "text", SendTime: now.Add(-2 * time.Hour).Unix(), ReceivedAt: now.Add(-2 * time.Hour).Unix(), }, { MessageID: "new", ConversationID: "room-1", RoomName: "售后群", SenderUserID: "wm_1", SenderName: "张三", SenderIdentity: senderIdentityExternal, Content: "刚才登录报错", MessageType: "text", SendTime: now.Add(-30 * time.Minute).Unix(), ReceivedAt: now.Add(-30 * time.Minute).Unix(), }, }, } var seen []AfterSalesMessage afterSalesAICollector = func(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) { seen = append([]AfterSalesMessage(nil), messages...) return []afterSalesAIIssueCandidate{{ RoomName: "售后群", CustomerUserID: "wm_1", CustomerName: "张三", IssueContent: "登录报错", AISuggestion: "建议先核对网络和账号状态。", SourceMessageIDs: []string{"new"}, Confidence: 0.9, }}, nil } engine.collectLockedAsync("", false) if engine.state.LastCollectAt == 0 { t.Fatal("expected successful collect to advance LastCollectAt") } if engine.state.LastAddedCount != 1 || len(engine.issues) != 1 { t.Fatalf("expected one added issue, state=%#v issues=%#v", engine.state, engine.issues) } if len(seen) != 1 || seen[0].MessageID != "new" { t.Fatalf("first collection should only analyze the latest hour, saw %#v", seen) } if engine.state.LastError != "" { t.Fatalf("unexpected LastError: %s", engine.state.LastError) } } func TestAfterSalesCollectDoesNotAdvanceOnFailure(t *testing.T) { restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t) defer restoreAutoReply() defer restoreCollector() now := time.Now() engine := &AfterSalesIssueEngine{ messages: []AfterSalesMessage{{ MessageID: "m1", ConversationID: "room-1", RoomName: "售后群", SenderUserID: "wm_1", SenderName: "张三", SenderIdentity: senderIdentityExternal, Content: "登录报错", MessageType: "text", SendTime: now.Add(-10 * time.Minute).Unix(), ReceivedAt: now.Add(-10 * time.Minute).Unix(), }}, } afterSalesAICollector = func(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) { return nil, errors.New("AI failed") } engine.collectLockedAsync("", false) if engine.state.LastCollectAt != 0 { t.Fatalf("failed collect must not advance LastCollectAt, got %d", engine.state.LastCollectAt) } if engine.state.LastError == "" { t.Fatal("expected LastError after failed collect") } } func TestAfterSalesManualCollectScansAllCachedMessages(t *testing.T) { restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t) defer restoreAutoReply() defer restoreCollector() now := time.Now() engine := &AfterSalesIssueEngine{ state: AfterSalesCollectState{LastCollectAt: now.Add(-10 * time.Minute).Unix()}, messages: []AfterSalesMessage{ { MessageID: "old-room-1", ConversationID: "room-1", RoomName: "Room 1", SenderUserID: "wm_1", SenderName: "Customer 1", SenderIdentity: senderIdentityExternal, Content: "old issue", MessageType: "text", SendTime: now.Add(-3 * time.Hour).Unix(), ReceivedAt: now.Add(-3 * time.Hour).Unix(), }, { MessageID: "new-room-2", ConversationID: "room-2", RoomName: "Room 2", SenderUserID: "wm_2", SenderName: "Customer 2", SenderIdentity: senderIdentityExternal, Content: "new issue", MessageType: "text", SendTime: now.Add(-5 * time.Minute).Unix(), ReceivedAt: now.Add(-5 * time.Minute).Unix(), }, }, } var seen []AfterSalesMessage afterSalesAICollector = func(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) { seen = append(seen, messages...) return nil, nil } added, scanned, err := engine.collectNow("", true) if err != nil { t.Fatalf("manual collect failed: %v", err) } if added != 0 || scanned != 2 || len(seen) != 2 { t.Fatalf("expected manual collect to scan all cached messages, added=%d scanned=%d seen=%#v", added, scanned, seen) } } func TestAfterSalesManualCollectFiltersSelectedGroup(t *testing.T) { restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t) defer restoreAutoReply() defer restoreCollector() now := time.Now() engine := &AfterSalesIssueEngine{ messages: []AfterSalesMessage{ { MessageID: "room-1-msg", ConversationID: "room-1", RoomName: "Room 1", SenderUserID: "wm_1", SenderName: "Customer 1", SenderIdentity: senderIdentityExternal, Content: "room 1 issue", MessageType: "text", SendTime: now.Add(-30 * time.Minute).Unix(), ReceivedAt: now.Add(-30 * time.Minute).Unix(), }, { MessageID: "room-2-msg", ConversationID: "room-2", RoomName: "Room 2", SenderUserID: "wm_2", SenderName: "Customer 2", SenderIdentity: senderIdentityExternal, Content: "room 2 issue", MessageType: "text", SendTime: now.Add(-20 * time.Minute).Unix(), ReceivedAt: now.Add(-20 * time.Minute).Unix(), }, }, } var seen []AfterSalesMessage afterSalesAICollector = func(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) { seen = append([]AfterSalesMessage(nil), messages...) return nil, nil } _, scanned, err := engine.collectNow("room-2", true) if err != nil { t.Fatalf("selected manual collect failed: %v", err) } if scanned != 1 || len(seen) != 1 || seen[0].MessageID != "room-2-msg" { t.Fatalf("expected only selected group message, scanned=%d seen=%#v", scanned, seen) } } func TestAfterSalesManualCollectDoesNotAdvanceAutoCursor(t *testing.T) { restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t) defer restoreAutoReply() defer restoreCollector() now := time.Now() lastCollectAt := now.Add(-15 * time.Minute).Unix() engine := &AfterSalesIssueEngine{ state: AfterSalesCollectState{Collecting: true, LastCollectAt: lastCollectAt}, messages: []AfterSalesMessage{{ MessageID: "m1", ConversationID: "room-1", RoomName: "Room 1", SenderUserID: "wm_1", SenderName: "Customer", SenderIdentity: senderIdentityExternal, Content: "old cached issue", MessageType: "text", SendTime: now.Add(-2 * time.Hour).Unix(), ReceivedAt: now.Add(-2 * time.Hour).Unix(), }}, } afterSalesAICollector = func(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) { return nil, nil } engine.collectLockedAsync("", true) if engine.state.LastCollectAt != lastCollectAt { t.Fatalf("manual collect should not advance automatic cursor, got %d want %d", engine.state.LastCollectAt, lastCollectAt) } if engine.state.Collecting { t.Fatal("manual collect should clear collecting flag") } } func TestAfterSalesManualCollectSelectedGroupNoCachedMessages(t *testing.T) { engine := &AfterSalesIssueEngine{} added, scanned, err := engine.collectNow("room-missing", true) if err != nil { t.Fatalf("empty selected collect failed: %v", err) } if added != 0 || scanned != 0 { t.Fatalf("expected no additions for empty selected group, added=%d scanned=%d", added, scanned) } if msg := afterSalesCollectEmptyMessage("room-missing", true, scanned); msg == "" { t.Fatal("expected empty selected group message") } } func TestAfterSalesHistoryImportParsesCopiedText(t *testing.T) { base := time.Date(2026, 5, 28, 15, 30, 0, 0, time.Local) raw := "郑悦滨 2026-05-28 15:13 那玩意儿没电了,存不住数据。\n你量一下电压,肯定低于3V了。\n梁锦明 15:14 郑工\n郑悦滨:梁师傅好" messages := parseAfterSalesHistoryMessages("R:room-1", "设备售后问题交流群", raw, base) if len(messages) != 3 { t.Fatalf("expected 3 parsed messages, got %d: %#v", len(messages), messages) } if messages[0].SenderName != "郑悦滨" || !strings.Contains(messages[0].Content, "存不住数据") || !strings.Contains(messages[0].Content, "低于3V") { t.Fatalf("unexpected first message: %#v", messages[0]) } if messages[2].SenderName != "郑悦滨" || messages[2].Content != "梁师傅好" { t.Fatalf("colon format was not parsed: %#v", messages[2]) } } func TestAfterSalesHistoryImportDedupesMessages(t *testing.T) { engine := &AfterSalesIssueEngine{} raw := "Customer " + time.Now().Format("2006-01-02") + " 15:13 device broken" messages := parseAfterSalesHistoryMessages("room-1", "Room 1", raw, time.Now()) first := engine.mergeHistoryMessages(messages) second := engine.mergeHistoryMessages(messages) if first != 1 || second != 0 { t.Fatalf("expected first import only, first=%d second=%d messages=%#v", first, second, engine.messages) } if engine.state.MessageBufferCount != 1 { t.Fatalf("expected message buffer count 1, got %d", engine.state.MessageBufferCount) } } func TestAfterSalesHistoryImportCollectsIssue(t *testing.T) { restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t) defer restoreAutoReply() defer restoreCollector() engine := &AfterSalesIssueEngine{} afterSalesAICollector = func(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) { if len(messages) != 1 || messages[0].ConversationID != "room-1" { t.Fatalf("unexpected imported messages sent to collector: %#v", messages) } return []afterSalesAIIssueCandidate{{ CustomerUserID: messages[0].SenderUserID, CustomerName: messages[0].SenderName, IssueContent: "device cannot store data", SourceMessageIDs: []string{messages[0].MessageID}, Confidence: 0.9, }}, nil } imported, added, err := engine.importHistoryAndCollect(AfterSalesHistoryImportRequest{ ConversationID: "room-1", RoomName: "Room 1", RawText: "Customer 2026-05-28 15:13 device cannot store data", }) if err != nil { t.Fatalf("history import failed: %v", err) } if imported != 1 || added != 1 || len(engine.issues) != 1 { t.Fatalf("expected imported issue, imported=%d added=%d issues=%#v", imported, added, engine.issues) } } func TestAfterSalesHistoryImportSegmentsMultipleIssues(t *testing.T) { restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t) defer restoreAutoReply() defer restoreCollector() engine := &AfterSalesIssueEngine{} calls := 0 afterSalesAICollector = func(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) { calls++ if len(messages) != 1 { t.Fatalf("expected one imported issue segment per AI call, got %#v", messages) } msg := messages[0] return []afterSalesAIIssueCandidate{{ CustomerUserID: msg.SenderUserID, CustomerName: msg.SenderName, IssueContent: msg.Content, SourceMessageIDs: []string{msg.MessageID}, Confidence: 0.9, }}, nil } result, err := engine.importHistoryAndCollectDetailed(AfterSalesHistoryImportRequest{ ConversationID: "room-1", RoomName: "Room 1", RawText: strings.Join([]string{ "Customer 2026-05-28 15:13 first device cannot store data and needs repair", "Customer 2026-05-28 15:20 second device reports voltage error and cannot start", }, "\n"), }) if err != nil { t.Fatalf("history import failed: %v", err) } if result.Imported != 2 || result.Segments != 2 || result.Added != 2 || calls != 2 || len(engine.issues) != 2 { t.Fatalf("expected two segmented issues, result=%#v calls=%d issues=%#v", result, calls, engine.issues) } } func TestAfterSalesMergeSkipsExistingResolvedFingerprint(t *testing.T) { content := "软件无法登录" fingerprint := afterSalesFingerprint("room-1", "wm_1", content) existingIssue := AfterSalesIssue{ ID: "existing", ConversationID: "room-1", CustomerUserID: "wm_1", IssueContent: content, Status: afterSalesIssueStatusResolved, Fingerprint: fingerprint, } engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{existingIssue}} batch := []AfterSalesMessage{{ MessageID: "m1", ConversationID: "room-1", RoomName: "售后群", SenderUserID: "wm_1", SenderName: "张三", SenderIdentity: senderIdentityExternal, Content: content, MessageType: "text", SendTime: time.Now().Unix(), }} added := engine.mergeAIIssueCandidates([]afterSalesAIIssueCandidate{{ RoomName: "售后群", CustomerUserID: "wm_1", CustomerName: "张三", IssueContent: content, AISuggestion: "建议检查账号状态。", SourceMessageIDs: []string{"m1"}, }}, batch, map[string]AfterSalesIssue{fingerprint: existingIssue}) if added != 0 { t.Fatalf("expected duplicate resolved issue to be skipped, added=%d", added) } if len(engine.issues) != 1 || engine.issues[0].Status != afterSalesIssueStatusResolved { t.Fatalf("existing resolved issue was changed: %#v", engine.issues) } } func TestAfterSalesCollectResolvesMissingRoomNameFromCache(t *testing.T) { restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t) defer restoreAutoReply() defer restoreCollector() autoReplyEngine.groupNames = map[string]string{"R:room-1": "Resolved Group"} engine := &AfterSalesIssueEngine{} batch := []AfterSalesMessage{{ MessageID: "m1", ConversationID: "R:room-1", RoomName: "R:room-1", SenderUserID: "wm_1", SenderName: "Customer", SenderIdentity: senderIdentityExternal, Content: "error", MessageType: "text", SendTime: time.Now().Unix(), }} added := engine.mergeAIIssueCandidates([]afterSalesAIIssueCandidate{{ CustomerUserID: "wm_1", CustomerName: "Customer", IssueContent: "error", SourceMessageIDs: []string{"m1"}, }}, batch, map[string]AfterSalesIssue{}) if added != 1 || len(engine.issues) != 1 { t.Fatalf("expected one issue, added=%d issues=%#v", added, engine.issues) } if engine.issues[0].RoomName != "Resolved Group" { t.Fatalf("expected resolved group name, got %q", engine.issues[0].RoomName) } } func TestAfterSalesCollectRecordsSourceAccount(t *testing.T) { restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t) defer restoreAutoReply() defer restoreCollector() restoreClients := setTestIdentifiedClients(t, map[uint32]string{7: "robot-a"}) defer restoreClients() autoReplyEngine.rememberCurrentAccountNames(7, "robot-a", "售后A") engine := &AfterSalesIssueEngine{} batch := []AfterSalesMessage{{ MessageID: "m1", ClientID: 7, ConversationID: "R:room-1", RoomName: "售后群", SenderUserID: "wm_1", SenderName: "Customer", SenderIdentity: senderIdentityExternal, Content: "error", MessageType: "text", SendTime: time.Now().Unix(), }} added := engine.mergeAIIssueCandidates([]afterSalesAIIssueCandidate{{ CustomerUserID: "wm_1", CustomerName: "Customer", IssueContent: "error", SourceMessageIDs: []string{"m1"}, }}, batch, map[string]AfterSalesIssue{}) if added != 1 || len(engine.issues) != 1 { t.Fatalf("expected one issue, added=%d issues=%#v", added, engine.issues) } issue := engine.issues[0] if issue.SourceClientID != 7 || issue.SourceAccountUserID != "robot-a" || issue.SourceAccountName != "售后A" { t.Fatalf("expected source account fields, got %#v", issue) } } func TestAfterSalesImageExtractionPrefersLocalPath(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "image.jpg") if err := os.WriteFile(path, []byte{0xff, 0xd8, 0xff, 0xd9}, 0644); err != nil { t.Fatalf("write image: %v", err) } gotPath, gotRef := extractAfterSalesImageFromMessage(autoReplyMessage{MediaLocalPath: path, MediaKind: "image"}, nil) if gotPath != path || gotRef != "" { t.Fatalf("expected local image path, got path=%q ref=%q", gotPath, gotRef) } } func TestAfterSalesFileContentExtractionReadsTextFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "issue.txt") if err := os.WriteFile(path, []byte("motor alarm E42\nneeds service"), 0644); err != nil { t.Fatalf("write text file: %v", err) } content, status := extractAfterSalesFileContent(path) if status != afterSalesFileStatusReady { t.Fatalf("expected parsed status, got %q content=%q", status, content) } if !strings.Contains(content, "motor alarm E42") || !strings.Contains(content, "needs service") { t.Fatalf("expected extracted file content, got %q", content) } } func TestAfterSalesCandidateFileAttachmentsFollowSourceMessageIDs(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "issue.csv") if err := os.WriteFile(path, []byte("part,status\nlens,error"), 0644); err != nil { t.Fatalf("write csv file: %v", err) } content, status := extractAfterSalesFileContent(path) batch := []AfterSalesMessage{ { MessageID: "text-1", ConversationID: "room-1", Content: "please check attached file", MessageType: "text", }, { MessageID: "file-1", ConversationID: "room-1", Content: "file: issue.csv", MessageType: "file", FilePath: path, FileName: "issue.csv", FileContent: content, FileExtractStatus: status, }, } files := collectCandidateFileAttachments([]string{"file-1"}, batch, map[string]AfterSalesMessage{"file-1": batch[1]}) if len(files) != 1 { t.Fatalf("expected one file attachment, got %#v", files) } if files[0].Name != "issue.csv" || files[0].Path != path || files[0].SourceMessageID != "file-1" { t.Fatalf("unexpected file attachment metadata: %#v", files[0]) } if files[0].ExtractStatus != afterSalesFileStatusReady || !strings.Contains(files[0].Content, "lens") { t.Fatalf("unexpected file attachment content/status: %#v", files[0]) } } func TestAfterSalesRepairKeepsEditedRoomNameAndDedupesImages(t *testing.T) { restoreAutoReply, _ := installAfterSalesCollectTestHooks(t) defer restoreAutoReply() dir := t.TempDir() path := filepath.Join(dir, "image.jpg") if err := os.WriteFile(path, []byte{0xff, 0xd8, 0xff, 0xd9}, 0644); err != nil { t.Fatalf("write image: %v", err) } autoReplyEngine.groupNames = map[string]string{"R:room-1": "Resolved Group"} engine := &AfterSalesIssueEngine{ issues: []AfterSalesIssue{{ ID: "issue-1", ConversationID: "R:room-1", RoomName: "Edited Group", SourceMessageIDs: []string{"m1"}, }}, messages: []AfterSalesMessage{{ MessageID: "m1", ImagePath: path, }}, } if !engine.repairIssuesLocked() { t.Fatal("expected image dedupe repair to report a change") } if engine.issues[0].RoomName != "Edited Group" { t.Fatalf("edited room name was overwritten: %q", engine.issues[0].RoomName) } if len(engine.issues[0].ImagePaths) != 1 || engine.issues[0].ImagePaths[0] != path { t.Fatalf("expected one deduped image path, got %#v", engine.issues[0].ImagePaths) } } func TestAutoReplyGroupNamePersistsAcrossReload(t *testing.T) { old := identityCachePathOverride identityCachePathOverride = filepath.Join(t.TempDir(), "identity.json") defer func() { identityCachePathOverride = old }() engine := &AutoReplyEngine{ groupNames: make(map[string]string), identityGroups: make(map[int32]map[string]autoReplyGroupOption), identityCaches: make(map[int32]*autoReplyIdentityCache), status: AutoReplyStatus{ReasonCounts: map[string]int{}}, } engine.rememberGroupName(7, "R:all-hands", "All Hands", 26) reloaded := &AutoReplyEngine{ groupNames: make(map[string]string), identityGroups: make(map[int32]map[string]autoReplyGroupOption), identityCaches: make(map[int32]*autoReplyIdentityCache), status: AutoReplyStatus{ReasonCounts: map[string]int{}}, } if err := reloaded.loadIdentityCache(); err != nil { t.Fatalf("load identity cache failed: %v", err) } if got := reloaded.ResolveGroupName("R:all-hands"); got != "All Hands" { t.Fatalf("expected persisted group name, got %q", got) } } func installAfterSalesCollectTestHooks(t *testing.T) (func(), func()) { t.Helper() originalAutoReplyEngine := autoReplyEngine autoReplyEngine = &AutoReplyEngine{ config: config.AutoReplyConfig{ AI: config.AIConfig{ Provider: "openai_compatible", BaseURL: "http://127.0.0.1", Model: "test-model", TimeoutSeconds: 1, MaxTokens: 128, }, }, } originalCollector := afterSalesAICollector return func() { autoReplyEngine = originalAutoReplyEngine }, func() { afterSalesAICollector = originalCollector } }