Initial qiwei secondary development handoff

This commit is contained in:
2026-06-23 21:11:20 +08:00
commit 858cb68f4f
207 changed files with 52782 additions and 0 deletions

View File

@@ -0,0 +1,410 @@
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)
}