package main import ( "os" "strings" "testing" "time" "qiweimanager/config" ) func TestAfterSalesDispatchRulePriority(t *testing.T) { issue := AfterSalesIssue{ ConversationID: "R:vip-room", RoomName: "热成像售后群", CustomerName: "华南客户", IssueContent: "设备启动报错", AISuggestion: "建议检查线缆", } cfg := AfterSalesDispatchConfig{ Rules: []AfterSalesDispatchRule{ {ID: "issue", Name: "报错", EngineerUserID: "engineer-issue", IssueKeywords: []string{"报错"}, Enabled: true}, {ID: "product", Name: "热成像", EngineerUserID: "engineer-product", ProductKeywords: []string{"热成像"}, Enabled: true}, {ID: "room", Name: "VIP群", EngineerUserID: "engineer-room", ConversationIDs: []string{"R:vip-room"}, Enabled: true}, }, } match, ok := matchAfterSalesDispatchRule(issue, cfg) if !ok { t.Fatal("expected a dispatch match") } if match.Rule.EngineerUserID != "engineer-room" { t.Fatalf("expected conversation/customer rule to win, got %#v", match) } } func TestAfterSalesDispatchNoMatchStaysUnassigned(t *testing.T) { issue := AfterSalesIssue{IssueContent: "普通咨询"} cfg := AfterSalesDispatchConfig{ Rules: []AfterSalesDispatchRule{{ID: "r1", EngineerUserID: "engineer", ProductKeywords: []string{"热成像"}, Enabled: true}}, } if match, ok := matchAfterSalesDispatchRule(issue, cfg); ok { t.Fatalf("expected no match, got %#v", match) } engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ID: "i1", Status: afterSalesIssueStatusPending, IssueContent: "普通咨询"}}} engine.applyLegacyDispatchSuggestionsLocked(cfg) if engine.issues[0].DispatchStatus != afterSalesDispatchUnassigned { t.Fatalf("expected unassigned, got %s", engine.issues[0].DispatchStatus) } } func TestAfterSalesAIDispatchHighConfidenceAssignsEngineer(t *testing.T) { engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ ID: "i1", Status: afterSalesIssueStatusPending, IssueContent: "热成像镜头无法调焦", NotifyStatus: afterSalesNotifyNotSent, }}} cfg := AfterSalesDispatchConfig{ AutoNotifyMinConfidence: 0.75, Engineers: []AfterSalesEngineer{{ UserID: "engineer-a", Name: "张工", Description: "负责热成像镜头调焦问题", Enabled: true, }}, } normalizeAfterSalesDispatchConfig(&cfg) engine.applyDispatchChoice("i1", cfg, afterSalesDispatchAIChoice{ EngineerUserID: "engineer-a", Reason: "热成像镜头调焦属于张工职责", Confidence: 0.92, }, nil) got := engine.issues[0] if got.AssignedEngineerID != "engineer-a" || got.DispatchStatus != afterSalesDispatchSuggested || got.DispatchSource != dispatchSourceAI { t.Fatalf("expected AI suggested engineer, got %#v", got) } if got.DispatchConfidence != 0.92 { t.Fatalf("expected confidence 0.92, got %v", got.DispatchConfidence) } } func TestAfterSalesAIDispatchLowConfidenceStaysUnassigned(t *testing.T) { engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ ID: "i1", Status: afterSalesIssueStatusPending, IssueContent: "客户说有点奇怪", NotifyStatus: afterSalesNotifyNotSent, }}} cfg := AfterSalesDispatchConfig{ AutoNotifyMinConfidence: 0.75, Engineers: []AfterSalesEngineer{{UserID: "engineer-a", Description: "负责镜头", Enabled: true}}, } normalizeAfterSalesDispatchConfig(&cfg) engine.applyDispatchChoice("i1", cfg, afterSalesDispatchAIChoice{ EngineerUserID: "engineer-a", Reason: "不够确定", Confidence: 0.45, }, nil) got := engine.issues[0] if got.AssignedEngineerID != "" || got.DispatchStatus != afterSalesDispatchUnassigned { t.Fatalf("expected unassigned low-confidence issue, got %#v", got) } if got.DispatchConfidence != 0.45 { t.Fatalf("expected stored low confidence, got %v", got.DispatchConfidence) } } func TestAfterSalesManualAssignmentNotOverwrittenByAI(t *testing.T) { engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ ID: "i1", Status: afterSalesIssueStatusPending, AssignedEngineerID: "manual-engineer", DispatchStatus: afterSalesDispatchAssigned, DispatchSource: dispatchSourceManual, NotifyStatus: afterSalesNotifyNotSent, }}} cfg := AfterSalesDispatchConfig{ AutoNotifyMinConfidence: 0.75, Engineers: []AfterSalesEngineer{{UserID: "engineer-a", Description: "负责镜头", Enabled: true}}, } normalizeAfterSalesDispatchConfig(&cfg) engine.applyDispatchChoice("i1", cfg, afterSalesDispatchAIChoice{EngineerUserID: "engineer-a", Confidence: 0.99}, nil) if engine.issues[0].AssignedEngineerID != "manual-engineer" || engine.issues[0].DispatchSource != dispatchSourceManual { t.Fatalf("manual assignment was overwritten: %#v", engine.issues[0]) } } func TestAfterSalesAutoNotifyEnabledSendsHighConfidenceRecommendation(t *testing.T) { cleanupDispatchConfig(t) oldMatcher := afterSalesDispatchMatcher oldConfigSource := afterSalesDispatchAIConfigSource oldSender := sendAutoReplyTextSender afterSalesDispatchMatcher = func(aiCfg config.AIConfig, issue AfterSalesIssue, engineers []AfterSalesEngineer) (afterSalesDispatchAIChoice, error) { return afterSalesDispatchAIChoice{EngineerUserID: "engineer-a", Reason: "职责匹配", Confidence: 0.91}, nil } afterSalesDispatchAIConfigSource = func() (config.AIConfig, error) { return config.AIConfig{BaseURL: "http://127.0.0.1", Model: "test"}, nil } sent := 0 sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error { sent++ return nil } t.Cleanup(func() { afterSalesDispatchMatcher = oldMatcher afterSalesDispatchAIConfigSource = oldConfigSource sendAutoReplyTextSender = oldSender }) clientIdMutex.Lock() oldGlobalClientID := globalClientId oldGlobalClientMap := globalClientMap globalClientId = 7 globalClientMap = map[uint32]string{7: "robot-user"} clientIdMutex.Unlock() t.Cleanup(func() { clientIdMutex.Lock() globalClientId = oldGlobalClientID globalClientMap = oldGlobalClientMap clientIdMutex.Unlock() }) cfg := AfterSalesDispatchConfig{ AutoNotifyEnabled: true, AutoNotifyMinConfidence: 0.75, NotifyCooldownSeconds: 1, Engineers: []AfterSalesEngineer{{ UserID: "engineer-a", Name: "张工", Description: "负责镜头问题", Enabled: true, }}, } normalizeAfterSalesDispatchConfig(&cfg) engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ ID: "i1", Status: afterSalesIssueStatusPending, IssueContent: "镜头无法调焦", NotifyStatus: afterSalesNotifyNotSent, }}} engine.refreshDispatchAssignments(cfg) if sent != 1 { t.Fatalf("expected one automatic notification, got %d", sent) } if engine.issues[0].NotifyStatus != afterSalesNotifySent { t.Fatalf("expected sent issue, got %#v", engine.issues[0]) } } func TestAfterSalesAutoNotifyDisabledDoesNotSend(t *testing.T) { oldMatcher := afterSalesDispatchMatcher oldConfigSource := afterSalesDispatchAIConfigSource oldSender := sendAutoReplyTextSender afterSalesDispatchMatcher = func(aiCfg config.AIConfig, issue AfterSalesIssue, engineers []AfterSalesEngineer) (afterSalesDispatchAIChoice, error) { return afterSalesDispatchAIChoice{EngineerUserID: "engineer-a", Reason: "职责匹配", Confidence: 0.91}, nil } afterSalesDispatchAIConfigSource = func() (config.AIConfig, error) { return config.AIConfig{BaseURL: "http://127.0.0.1", Model: "test"}, nil } sent := 0 sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error { sent++ return nil } t.Cleanup(func() { afterSalesDispatchMatcher = oldMatcher afterSalesDispatchAIConfigSource = oldConfigSource sendAutoReplyTextSender = oldSender }) cfg := AfterSalesDispatchConfig{ AutoNotifyEnabled: false, AutoNotifyMinConfidence: 0.75, Engineers: []AfterSalesEngineer{{UserID: "engineer-a", Description: "负责镜头问题", Enabled: true}}, } normalizeAfterSalesDispatchConfig(&cfg) engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ID: "i1", Status: afterSalesIssueStatusPending, IssueContent: "镜头无法调焦", NotifyStatus: afterSalesNotifyNotSent}}} engine.refreshDispatchAssignments(cfg) if sent != 0 { t.Fatalf("expected no automatic notification when disabled, got %d", sent) } if engine.issues[0].AssignedEngineerID != "engineer-a" || engine.issues[0].NotifyStatus != afterSalesNotifyNotSent { t.Fatalf("expected recommendation without notification, got %#v", engine.issues[0]) } } func TestAfterSalesNotifyCooldownSkipsDuplicateSend(t *testing.T) { cleanupDispatchConfig(t) if err := saveAfterSalesDispatchConfig(AfterSalesDispatchConfig{ NotifyCooldownSeconds: 600, }); err != nil { t.Fatalf("save config: %v", err) } sent := 0 oldSender := sendAutoReplyTextSender sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error { sent++ return nil } t.Cleanup(func() { sendAutoReplyTextSender = oldSender }) engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ ID: "i1", Status: afterSalesIssueStatusPending, AssignedEngineerID: "engineer-a", DispatchStatus: afterSalesDispatchAssigned, NotifyStatus: afterSalesNotifySent, LastNotifiedAt: time.Now().Unix(), }}} result := engine.notifyEngineer("i1") if result.Success || !strings.Contains(result.Message, "冷却") { t.Fatalf("expected cooldown failure, got %#v", result) } if sent != 0 { t.Fatalf("expected no send during cooldown, got %d", sent) } } func TestAfterSalesNotifyResolvedIssueDoesNotSend(t *testing.T) { cleanupDispatchConfig(t) if err := saveAfterSalesDispatchConfig(AfterSalesDispatchConfig{ NotifyCooldownSeconds: 1, }); err != nil { t.Fatalf("save config: %v", err) } sent := 0 oldSender := sendAutoReplyTextSender sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error { sent++ return nil } t.Cleanup(func() { sendAutoReplyTextSender = oldSender }) engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ ID: "i1", Status: afterSalesIssueStatusResolved, AssignedEngineerID: "engineer-a", DispatchStatus: afterSalesDispatchAssigned, NotifyStatus: afterSalesNotifyNotSent, }}} result := engine.notifyEngineer("i1") if result.Success || !strings.Contains(result.Message, "非待处理") { t.Fatalf("expected non-pending failure, got %#v", result) } if sent != 0 { t.Fatalf("expected no send for resolved issue, got %d", sent) } if engine.issues[0].NotifyStatus != afterSalesNotifyNotSent { t.Fatalf("expected notify status unchanged, got %#v", engine.issues[0]) } } func TestAfterSalesBatchNotifyPartialFailure(t *testing.T) { cleanupDispatchConfig(t) if err := saveAfterSalesDispatchConfig(AfterSalesDispatchConfig{ NotifyCooldownSeconds: 1, }); err != nil { t.Fatalf("save config: %v", err) } oldSender := sendAutoReplyTextSender sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error { if clientID != 7 || conversationID != "S:robot-user_engineer-a" { t.Fatalf("unexpected send target client=%d conversation=%s", clientID, conversationID) } if !strings.Contains(content, "售后问题待处理") || !strings.Contains(content, "i1") { t.Fatalf("unexpected notification content: %s", content) } return nil } t.Cleanup(func() { sendAutoReplyTextSender = oldSender }) clientIdMutex.Lock() oldGlobalClientID := globalClientId oldGlobalClientMap := globalClientMap globalClientId = 7 globalClientMap = map[uint32]string{7: "robot-user"} clientIdMutex.Unlock() t.Cleanup(func() { clientIdMutex.Lock() globalClientId = oldGlobalClientID globalClientMap = oldGlobalClientMap clientIdMutex.Unlock() }) engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{ { ID: "i1", CreatedAt: time.Now().Format(time.RFC3339), Status: afterSalesIssueStatusPending, IssueContent: "设备报错", AssignedEngineerID: "engineer-a", AssignedEngineerName: "张工", DispatchStatus: afterSalesDispatchAssigned, NotifyStatus: afterSalesNotifyNotSent, }, { ID: "i2", Status: afterSalesIssueStatusPending, IssueContent: "未分配问题", NotifyStatus: afterSalesNotifyNotSent, }, }} result := engine.batchNotifyEngineers([]string{"i1", "i2"}) if result["success"].(bool) { t.Fatalf("expected partial failure, got %#v", result) } data := result["data"].(map[string]interface{}) if data["successCount"].(int) != 1 || data["totalCount"].(int) != 2 { t.Fatalf("unexpected batch data: %#v", data) } if engine.issues[0].NotifyStatus != afterSalesNotifySent { t.Fatalf("expected first issue sent, got %#v", engine.issues[0]) } if engine.issues[1].NotifyStatus != afterSalesNotifyFailed { t.Fatalf("expected second issue failed, got %#v", engine.issues[1]) } } func TestAfterSalesBatchNotifyEmptyMeansAllAssignedUnsent(t *testing.T) { cleanupDispatchConfig(t) if err := saveAfterSalesDispatchConfig(AfterSalesDispatchConfig{NotifyCooldownSeconds: 1}); err != nil { t.Fatalf("save config: %v", err) } sent := make([]string, 0) oldSender := sendAutoReplyTextSender sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error { sent = append(sent, content) return nil } t.Cleanup(func() { sendAutoReplyTextSender = oldSender }) clientIdMutex.Lock() oldGlobalClientID := globalClientId oldGlobalClientMap := globalClientMap globalClientId = 7 globalClientMap = map[uint32]string{7: "robot-user"} clientIdMutex.Unlock() t.Cleanup(func() { clientIdMutex.Lock() globalClientId = oldGlobalClientID globalClientMap = oldGlobalClientMap clientIdMutex.Unlock() }) engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{ {ID: "send-me", Status: afterSalesIssueStatusPending, AssignedEngineerID: "engineer-a", NotifyStatus: afterSalesNotifyNotSent}, {ID: "skip-unassigned", Status: afterSalesIssueStatusPending, NotifyStatus: afterSalesNotifyNotSent}, {ID: "skip-sent", Status: afterSalesIssueStatusPending, AssignedEngineerID: "engineer-a", NotifyStatus: afterSalesNotifySent}, {ID: "skip-resolved", Status: afterSalesIssueStatusResolved, AssignedEngineerID: "engineer-a", NotifyStatus: afterSalesNotifyNotSent}, }} result := engine.batchNotifyEngineers(nil) if !result["success"].(bool) { t.Fatalf("expected all eligible notifications to succeed, got %#v", result) } data := result["data"].(map[string]interface{}) if data["successCount"].(int) != 1 || data["totalCount"].(int) != 1 { t.Fatalf("unexpected all-notify data: %#v", data) } if len(sent) != 1 || !strings.Contains(sent[0], "send-me") { t.Fatalf("expected only one notification for send-me, got %#v", sent) } } func cleanupDispatchConfig(t *testing.T) { t.Helper() path := afterSalesDispatchConfigPath() t.Cleanup(func() { _ = os.Remove(path) }) _ = os.Remove(path) }