package main import ( "encoding/json" "fmt" "reflect" "sort" "strings" "time" "qiweimanager/config" ) const ( afterSalesDispatchUnassigned = "unassigned" afterSalesDispatchSuggested = "suggested" afterSalesDispatchAssigned = "assigned" afterSalesNotifyNotSent = "not_sent" afterSalesNotifySent = "sent" afterSalesNotifyFailed = "failed" defaultDispatchNotifyCooldownSeconds = 300 defaultDispatchMinConfidence = 0.75 dispatchSourceAI = "ai_suggested" dispatchSourceManual = "manual_assigned" dispatchSourceRule = "rule_suggested" ) type AfterSalesEngineer struct { UserID string `json:"userId"` Name string `json:"name"` Description string `json:"description"` Remark string `json:"remark"` Enabled bool `json:"enabled"` } type AfterSalesDispatchRule struct { ID string `json:"id"` Name string `json:"name"` EngineerUserID string `json:"engineerUserId"` EngineerName string `json:"engineerName"` ConversationIDs []string `json:"conversationIds"` CustomerNames []string `json:"customerNames"` ProductKeywords []string `json:"productKeywords"` IssueKeywords []string `json:"issueKeywords"` Enabled bool `json:"enabled"` } type AfterSalesDispatchConfig struct { Engineers []AfterSalesEngineer `json:"engineers"` Rules []AfterSalesDispatchRule `json:"rules"` NotifyTemplate string `json:"notifyTemplate"` NotifyCooldownSeconds int `json:"notifyCooldownSeconds"` AutoNotifyEnabled bool `json:"autoNotifyEnabled"` AutoNotifyMinConfidence float64 `json:"autoNotifyMinConfidence"` } type AfterSalesDispatchSummary struct { Pending int `json:"pending"` Unassigned int `json:"unassigned"` Assigned int `json:"assigned"` Sent int `json:"sent"` Failed int `json:"failed"` TodayNew int `json:"todayNew"` NotSent int `json:"notSent"` } type AfterSalesDispatchQueue struct { Issues []AfterSalesIssue `json:"issues"` Summary AfterSalesDispatchSummary `json:"summary"` Config AfterSalesDispatchConfig `json:"config"` } type AfterSalesNotifyResult struct { IssueID string `json:"issueId"` Success bool `json:"success"` Message string `json:"message"` } type afterSalesDispatchMatch struct { Rule AfterSalesDispatchRule Priority int MatchedLabel string } type afterSalesDispatchAIChoice struct { EngineerUserID string `json:"engineer_user_id"` Reason string `json:"reason"` Confidence float64 `json:"confidence"` } var ( afterSalesDispatchMatcher = callAfterSalesDispatchAI afterSalesDispatchAIConfigSource = currentAfterSalesDispatchAIConfig ) func defaultAfterSalesDispatchConfig() AfterSalesDispatchConfig { return AfterSalesDispatchConfig{ Engineers: []AfterSalesEngineer{}, Rules: []AfterSalesDispatchRule{}, NotifyTemplate: "", NotifyCooldownSeconds: defaultDispatchNotifyCooldownSeconds, AutoNotifyEnabled: false, AutoNotifyMinConfidence: defaultDispatchMinConfidence, } } func afterSalesDispatchConfigPath() string { return resolveAutoReplyPath("config/after_sales_dispatch_config.json") } func readAfterSalesDispatchConfig() (AfterSalesDispatchConfig, error) { cfg := defaultAfterSalesDispatchConfig() if err := readJSONFile(afterSalesDispatchConfigPath(), &cfg); err != nil { return cfg, err } normalizeAfterSalesDispatchConfig(&cfg) return cfg, nil } func saveAfterSalesDispatchConfig(cfg AfterSalesDispatchConfig) error { normalizeAfterSalesDispatchConfig(&cfg) return atomicWriteJSON(afterSalesDispatchConfigPath(), cfg) } func normalizeAfterSalesDispatchConfig(cfg *AfterSalesDispatchConfig) { if cfg == nil { return } if cfg.NotifyCooldownSeconds <= 0 { cfg.NotifyCooldownSeconds = defaultDispatchNotifyCooldownSeconds } if cfg.AutoNotifyMinConfidence <= 0 || cfg.AutoNotifyMinConfidence > 1 { cfg.AutoNotifyMinConfidence = defaultDispatchMinConfidence } for i := range cfg.Engineers { cfg.Engineers[i].UserID = strings.TrimSpace(cfg.Engineers[i].UserID) cfg.Engineers[i].Name = strings.TrimSpace(cfg.Engineers[i].Name) cfg.Engineers[i].Description = strings.TrimSpace(cfg.Engineers[i].Description) cfg.Engineers[i].Remark = strings.TrimSpace(cfg.Engineers[i].Remark) if !cfg.Engineers[i].Enabled && cfg.Engineers[i].UserID != "" { cfg.Engineers[i].Enabled = true } } cfg.Engineers = uniqueAfterSalesEngineers(cfg.Engineers) for i := range cfg.Rules { rule := &cfg.Rules[i] rule.ID = strings.TrimSpace(rule.ID) if rule.ID == "" { rule.ID = newAfterSalesID() } rule.Name = strings.TrimSpace(rule.Name) rule.EngineerUserID = strings.TrimSpace(rule.EngineerUserID) rule.EngineerName = strings.TrimSpace(rule.EngineerName) rule.ConversationIDs = uniqueNonEmptyStrings(rule.ConversationIDs) rule.CustomerNames = uniqueNonEmptyStrings(rule.CustomerNames) rule.ProductKeywords = uniqueNonEmptyStrings(rule.ProductKeywords) rule.IssueKeywords = uniqueNonEmptyStrings(rule.IssueKeywords) if !rule.Enabled && rule.EngineerUserID != "" { rule.Enabled = true } } } func uniqueAfterSalesEngineers(items []AfterSalesEngineer) []AfterSalesEngineer { seen := make(map[string]bool) result := make([]AfterSalesEngineer, 0, len(items)) for _, item := range items { item.UserID = strings.TrimSpace(item.UserID) if item.UserID == "" || seen[item.UserID] { continue } seen[item.UserID] = true result = append(result, item) } return result } func normalizeAfterSalesDispatchFields(issue *AfterSalesIssue) { if issue == nil { return } issue.AssignedEngineerID = strings.TrimSpace(issue.AssignedEngineerID) issue.AssignedEngineerName = strings.TrimSpace(issue.AssignedEngineerName) issue.DispatchStatus = normalizeAfterSalesDispatchStatus(issue.DispatchStatus, issue.AssignedEngineerID) issue.NotifyStatus = normalizeAfterSalesNotifyStatus(issue.NotifyStatus) issue.DispatchReason = strings.TrimSpace(issue.DispatchReason) issue.DispatchRuleID = strings.TrimSpace(issue.DispatchRuleID) issue.DispatchSource = strings.TrimSpace(issue.DispatchSource) if issue.DispatchConfidence < 0 { issue.DispatchConfidence = 0 } if issue.DispatchConfidence > 1 { issue.DispatchConfidence = 1 } issue.NotifyError = strings.TrimSpace(issue.NotifyError) if issue.NotifyStatus == afterSalesNotifySent { issue.NotifyError = "" } } func normalizeAfterSalesDispatchStatus(status string, engineerID string) string { switch strings.ToLower(strings.TrimSpace(status)) { case afterSalesDispatchSuggested: if strings.TrimSpace(engineerID) != "" { return afterSalesDispatchSuggested } case afterSalesDispatchAssigned: if strings.TrimSpace(engineerID) != "" { return afterSalesDispatchAssigned } } return afterSalesDispatchUnassigned } func normalizeAfterSalesNotifyStatus(status string) string { switch strings.ToLower(strings.TrimSpace(status)) { case afterSalesNotifySent: return afterSalesNotifySent case afterSalesNotifyFailed: return afterSalesNotifyFailed default: return afterSalesNotifyNotSent } } func (e *AfterSalesIssueEngine) dispatchQueue() (AfterSalesDispatchQueue, error) { cfg, err := readAfterSalesDispatchConfig() if err != nil { return AfterSalesDispatchQueue{}, err } e.refreshDispatchAssignments(cfg) e.mu.Lock() issues := append([]AfterSalesIssue(nil), e.issues...) e.mu.Unlock() sort.Slice(issues, func(i, j int) bool { return issues[i].CreatedAt > issues[j].CreatedAt }) return AfterSalesDispatchQueue{ Issues: issues, Summary: summarizeAfterSalesDispatch(issues), Config: cfg, }, nil } func (e *AfterSalesIssueEngine) refreshDispatchAssignments(cfg AfterSalesDispatchConfig) { candidates := e.dispatchAssignmentCandidates(cfg) for _, issue := range candidates { choice, err := dispatchIssueWithAI(cfg, issue) e.applyDispatchChoice(issue.ID, cfg, choice, err) } if cfg.AutoNotifyEnabled { for _, issueID := range e.autoNotifyCandidateIDs(cfg) { e.notifyEngineer(issueID) } } } func (e *AfterSalesIssueEngine) refreshDispatchAssignmentsAsync() { go func() { cfg, err := readAfterSalesDispatchConfig() if err != nil { if globalLogger != nil { globalLogger.Warn("[工程师派单] 加载配置失败: %v", err) } return } e.refreshDispatchAssignments(cfg) }() } func (e *AfterSalesIssueEngine) dispatchAssignmentCandidates(cfg AfterSalesDispatchConfig) []AfterSalesIssue { e.mu.Lock() defer e.mu.Unlock() changed := e.applyLegacyDispatchSuggestionsLocked(cfg) if changed { _ = e.saveIssuesLocked() } result := make([]AfterSalesIssue, 0) for _, issue := range e.issues { if shouldDispatchWithAI(issue, cfg) { result = append(result, issue) } } return result } func shouldDispatchWithAI(issue AfterSalesIssue, cfg AfterSalesDispatchConfig) bool { if issue.Status != afterSalesIssueStatusPending { return false } if issue.DispatchSource == dispatchSourceManual || issue.DispatchStatus == afterSalesDispatchAssigned { return false } if issue.NotifyStatus == afterSalesNotifySent { return false } if len(enabledDispatchEngineers(cfg)) == 0 { return false } return true } func enabledDispatchEngineers(cfg AfterSalesDispatchConfig) []AfterSalesEngineer { result := make([]AfterSalesEngineer, 0, len(cfg.Engineers)) for _, engineer := range cfg.Engineers { if !engineer.Enabled || strings.TrimSpace(engineer.UserID) == "" { continue } if strings.TrimSpace(engineer.Description) == "" { continue } result = append(result, engineer) } return result } func dispatchIssueWithAI(cfg AfterSalesDispatchConfig, issue AfterSalesIssue) (afterSalesDispatchAIChoice, error) { aiCfg, err := afterSalesDispatchAIConfigSource() if err != nil { return afterSalesDispatchAIChoice{}, err } return afterSalesDispatchMatcher(aiCfg, issue, enabledDispatchEngineers(cfg)) } func currentAfterSalesDispatchAIConfig() (config.AIConfig, error) { appConfig := config.GetGlobalConfig() if appConfig == nil { return config.AIConfig{}, fmt.Errorf("AI 配置未加载") } appConfig.ApplyDefaults() aiCfg := appConfig.AutoReplyConfig.AI if strings.TrimSpace(aiCfg.BaseURL) == "" || strings.TrimSpace(aiCfg.Model) == "" { return config.AIConfig{}, fmt.Errorf("AI 配置未完整") } return aiCfg, nil } func (e *AfterSalesIssueEngine) applyDispatchChoice(issueID string, cfg AfterSalesDispatchConfig, choice afterSalesDispatchAIChoice, matchErr error) { e.mu.Lock() defer e.mu.Unlock() for i := range e.issues { issue := &e.issues[i] if issue.ID != issueID { continue } before := *issue normalizeAfterSalesDispatchFields(issue) if !shouldDispatchWithAI(*issue, cfg) { return } if matchErr != nil { clearDispatchSuggestion(issue, "AI匹配失败: "+matchErr.Error()) } else if choice.Confidence < cfg.AutoNotifyMinConfidence || strings.TrimSpace(choice.EngineerUserID) == "" { clearDispatchSuggestion(issue, strings.TrimSpace(firstNonEmpty(choice.Reason, "AI置信度低,需人工确认"))) issue.DispatchConfidence = choice.Confidence issue.DispatchSource = dispatchSourceAI } else { issue.AssignedEngineerID = strings.TrimSpace(choice.EngineerUserID) issue.AssignedEngineerName = dispatchEngineerName(choice.EngineerUserID, "", cfg) issue.DispatchStatus = afterSalesDispatchSuggested issue.DispatchReason = strings.TrimSpace(choice.Reason) if issue.DispatchReason == "" { issue.DispatchReason = "AI根据工程师职责说明匹配" } issue.DispatchRuleID = "" issue.DispatchConfidence = choice.Confidence issue.DispatchSource = dispatchSourceAI } if !reflect.DeepEqual(*issue, before) { issue.UpdatedAt = time.Now().Local().Format(time.RFC3339) _ = e.saveIssuesLocked() } return } } func clearDispatchSuggestion(issue *AfterSalesIssue, reason string) { issue.AssignedEngineerID = "" issue.AssignedEngineerName = "" issue.DispatchStatus = afterSalesDispatchUnassigned issue.DispatchReason = strings.TrimSpace(reason) issue.DispatchRuleID = "" } func (e *AfterSalesIssueEngine) autoNotifyCandidateIDs(cfg AfterSalesDispatchConfig) []string { e.mu.Lock() defer e.mu.Unlock() result := make([]string, 0) for _, issue := range e.issues { if issue.Status != afterSalesIssueStatusPending { continue } if issue.NotifyStatus != afterSalesNotifyNotSent { continue } if issue.DispatchSource != dispatchSourceAI || issue.DispatchConfidence < cfg.AutoNotifyMinConfidence { continue } if strings.TrimSpace(issue.AssignedEngineerID) == "" { continue } result = append(result, issue.ID) } return result } func summarizeAfterSalesDispatch(issues []AfterSalesIssue) AfterSalesDispatchSummary { var summary AfterSalesDispatchSummary today := time.Now().Local().Format("2006-01-02") for _, issue := range issues { if issue.Status != afterSalesIssueStatusPending { continue } summary.Pending++ if strings.HasPrefix(issue.CreatedAt, today) { summary.TodayNew++ } switch normalizeAfterSalesDispatchStatus(issue.DispatchStatus, issue.AssignedEngineerID) { case afterSalesDispatchAssigned, afterSalesDispatchSuggested: summary.Assigned++ default: summary.Unassigned++ } switch normalizeAfterSalesNotifyStatus(issue.NotifyStatus) { case afterSalesNotifySent: summary.Sent++ case afterSalesNotifyFailed: summary.Failed++ default: summary.NotSent++ } } return summary } func (e *AfterSalesIssueEngine) applyLegacyDispatchSuggestionsLocked(cfg AfterSalesDispatchConfig) bool { changed := false for i := range e.issues { issue := &e.issues[i] before := *issue normalizeAfterSalesDispatchFields(issue) if issue.Status == afterSalesIssueStatusPending && issue.DispatchStatus != afterSalesDispatchAssigned && issue.DispatchSource != dispatchSourceManual && len(enabledDispatchEngineers(cfg)) == 0 { if match, ok := matchAfterSalesDispatchRule(*issue, cfg); ok { issue.AssignedEngineerID = strings.TrimSpace(match.Rule.EngineerUserID) issue.AssignedEngineerName = dispatchEngineerName(match.Rule.EngineerUserID, match.Rule.EngineerName, cfg) issue.DispatchStatus = afterSalesDispatchSuggested issue.DispatchReason = match.MatchedLabel issue.DispatchRuleID = match.Rule.ID issue.DispatchConfidence = 1 issue.DispatchSource = dispatchSourceRule } else if issue.DispatchStatus == afterSalesDispatchSuggested { issue.AssignedEngineerID = "" issue.AssignedEngineerName = "" issue.DispatchStatus = afterSalesDispatchUnassigned issue.DispatchReason = "" issue.DispatchRuleID = "" issue.DispatchConfidence = 0 issue.DispatchSource = "" } } if !reflect.DeepEqual(*issue, before) { changed = true } } return changed } func matchAfterSalesDispatchRule(issue AfterSalesIssue, cfg AfterSalesDispatchConfig) (afterSalesDispatchMatch, bool) { best := afterSalesDispatchMatch{} found := false for _, rule := range cfg.Rules { if !rule.Enabled || strings.TrimSpace(rule.EngineerUserID) == "" { continue } if priority, label := dispatchRuleMatchPriority(issue, rule); priority > 0 { if !found || priority > best.Priority { best = afterSalesDispatchMatch{Rule: rule, Priority: priority, MatchedLabel: label} found = true } } } return best, found } func callAfterSalesDispatchAI(aiCfg config.AIConfig, issue AfterSalesIssue, engineers []AfterSalesEngineer) (afterSalesDispatchAIChoice, error) { if len(engineers) == 0 { return afterSalesDispatchAIChoice{}, fmt.Errorf("未配置工程师职责说明") } aiCfg.MaxTokens = maxInt(aiCfg.MaxTokens, 700) aiCfg.TimeoutSeconds = maxInt(aiCfg.TimeoutSeconds, 20) systemPrompt := `你是售后问题派单助手。请根据售后问题内容和工程师职责说明,选择最应该处理该问题的一位工程师。 规则: 1. 只能从给定工程师列表中选择 user_id。 2. 如果没有明确匹配,engineer_user_id 输出空字符串,confidence 不高于 0.5。 3. confidence 是 0 到 1 的数字,越确定越接近 1。 4. 只输出 JSON 对象,不要 markdown,不要解释文字。 JSON 字段:engineer_user_id, reason, confidence。` userPrompt := buildAfterSalesDispatchPrompt(issue, engineers) var result *AIResult var err error switch strings.ToLower(strings.TrimSpace(aiCfg.Provider)) { case "local", "ollama": result, err = callOllamaChat(aiCfg, systemPrompt, userPrompt) default: result, err = callOpenAICompatibleChat(aiCfg, systemPrompt, userPrompt) } if err != nil { return afterSalesDispatchAIChoice{}, err } choice, err := parseAfterSalesDispatchAIResponse(result.Answer) if err != nil { return afterSalesDispatchAIChoice{}, err } if choice.EngineerUserID != "" && !dispatchEngineerExists(choice.EngineerUserID, engineers) { return afterSalesDispatchAIChoice{}, fmt.Errorf("AI 返回了未配置的工程师: %s", choice.EngineerUserID) } return choice, nil } func buildAfterSalesDispatchPrompt(issue AfterSalesIssue, engineers []AfterSalesEngineer) string { var b strings.Builder b.WriteString("售后问题:\n") b.WriteString("问题ID:") b.WriteString(issue.ID) b.WriteString("\n群聊:") b.WriteString(firstNonEmpty(issue.RoomName, issue.ConversationID)) b.WriteString("\n客户:") b.WriteString(normalizeAfterSalesDisplayName(issue.CustomerName)) b.WriteString("\n问题描述:") b.WriteString(strings.TrimSpace(issue.IssueContent)) b.WriteString("\nAI建议:") b.WriteString(strings.TrimSpace(issue.AISuggestion)) b.WriteString("\n图片数量:") b.WriteString(fmt.Sprintf("%d", len(issue.ImagePaths)+len(issue.ImageRefs))) b.WriteString("\n\n工程师列表:\n") for _, engineer := range engineers { b.WriteString("- user_id=") b.WriteString(engineer.UserID) b.WriteString(" name=") b.WriteString(firstNonEmpty(engineer.Name, engineer.UserID)) if engineer.Remark != "" { b.WriteString(" remark=") b.WriteString(engineer.Remark) } b.WriteString("\n 职责说明:") b.WriteString(engineer.Description) b.WriteString("\n") } b.WriteString("\n请选择最匹配的一位工程师。无法明确判断时返回空 engineer_user_id。") return b.String() } func parseAfterSalesDispatchAIResponse(text string) (afterSalesDispatchAIChoice, error) { text = strings.TrimSpace(stripJSONMarkdownFence(text)) if text == "" { return afterSalesDispatchAIChoice{}, fmt.Errorf("AI 返回为空") } start := strings.Index(text, "{") end := strings.LastIndex(text, "}") if start < 0 || end < start { return afterSalesDispatchAIChoice{}, fmt.Errorf("AI 未返回 JSON 对象: %s", truncateText(text, 200)) } var choice afterSalesDispatchAIChoice if err := json.Unmarshal([]byte(text[start:end+1]), &choice); err != nil { return afterSalesDispatchAIChoice{}, fmt.Errorf("解析派单 AI JSON 失败: %w", err) } choice.EngineerUserID = strings.TrimSpace(choice.EngineerUserID) choice.Reason = strings.TrimSpace(choice.Reason) if choice.Confidence < 0 { choice.Confidence = 0 } if choice.Confidence > 1 { choice.Confidence = 1 } return choice, nil } func dispatchEngineerExists(userID string, engineers []AfterSalesEngineer) bool { userID = strings.TrimSpace(userID) for _, engineer := range engineers { if strings.TrimSpace(engineer.UserID) == userID { return true } } return false } func dispatchRuleMatchPriority(issue AfterSalesIssue, rule AfterSalesDispatchRule) (int, string) { if containsAnyNormalized(rule.ConversationIDs, issue.ConversationID) || containsAnyText(rule.CustomerNames, issue.CustomerName) { return 300, dispatchRuleLabel(rule, "群聊/客户") } productText := strings.Join([]string{issue.RoomName, issue.IssueContent, issue.AISuggestion}, "\n") if containsAnyText(rule.ProductKeywords, productText) { return 200, dispatchRuleLabel(rule, "产品关键词") } if containsAnyText(rule.IssueKeywords, strings.Join([]string{issue.IssueContent, issue.AISuggestion}, "\n")) { return 100, dispatchRuleLabel(rule, "问题关键词") } return 0, "" } func dispatchRuleLabel(rule AfterSalesDispatchRule, kind string) string { name := strings.TrimSpace(rule.Name) if name == "" { name = strings.TrimSpace(rule.ID) } if name == "" { return kind } return kind + ": " + name } func containsAnyNormalized(items []string, value string) bool { value = strings.TrimSpace(value) if value == "" { return false } for _, item := range items { if strings.TrimSpace(item) == value { return true } } return false } func containsAnyText(needles []string, haystack string) bool { haystack = strings.ToLower(strings.TrimSpace(haystack)) if haystack == "" { return false } for _, needle := range needles { needle = strings.ToLower(strings.TrimSpace(needle)) if needle != "" && strings.Contains(haystack, needle) { return true } } return false } func dispatchEngineerName(userID string, fallback string, cfg AfterSalesDispatchConfig) string { userID = strings.TrimSpace(userID) for _, engineer := range cfg.Engineers { if strings.TrimSpace(engineer.UserID) == userID && strings.TrimSpace(engineer.Name) != "" { return strings.TrimSpace(engineer.Name) } } return strings.TrimSpace(fallback) } func (e *AfterSalesIssueEngine) assignEngineer(issueID string, engineerID string) error { cfg, err := readAfterSalesDispatchConfig() if err != nil { return err } issueID = strings.TrimSpace(issueID) engineerID = strings.TrimSpace(engineerID) if issueID == "" { return fmt.Errorf("issueId为空") } e.mu.Lock() defer e.mu.Unlock() for i := range e.issues { if e.issues[i].ID != issueID { continue } e.issues[i].AssignedEngineerID = engineerID e.issues[i].AssignedEngineerName = dispatchEngineerName(engineerID, "", cfg) e.issues[i].DispatchRuleID = "" if engineerID == "" { e.issues[i].DispatchStatus = afterSalesDispatchUnassigned e.issues[i].DispatchReason = "" e.issues[i].DispatchConfidence = 0 e.issues[i].DispatchSource = "" } else { e.issues[i].DispatchStatus = afterSalesDispatchAssigned e.issues[i].DispatchReason = "人工指定" e.issues[i].DispatchConfidence = 1 e.issues[i].DispatchSource = dispatchSourceManual } e.issues[i].UpdatedAt = time.Now().Local().Format(time.RFC3339) normalizeAfterSalesDispatchFields(&e.issues[i]) return e.saveIssuesLocked() } return fmt.Errorf("问题不存在") } func (e *AfterSalesIssueEngine) notifyEngineer(issueID string) AfterSalesNotifyResult { cfg, err := readAfterSalesDispatchConfig() if err != nil { return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: err.Error()} } issueID = strings.TrimSpace(issueID) if issueID == "" { return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "issueId为空"} } var issue AfterSalesIssue e.mu.Lock() index := -1 for i := range e.issues { if e.issues[i].ID == issueID { index = i issue = e.issues[i] break } } if index < 0 { e.mu.Unlock() return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "问题不存在"} } normalizeAfterSalesDispatchFields(&issue) if issue.Status != afterSalesIssueStatusPending { e.mu.Unlock() return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "问题已处理或非待处理,不能通知"} } if strings.TrimSpace(issue.AssignedEngineerID) == "" { e.issues[index].NotifyStatus = afterSalesNotifyFailed e.issues[index].NotifyError = "未分配工程师" e.issues[index].UpdatedAt = time.Now().Local().Format(time.RFC3339) _ = e.saveIssuesLocked() e.mu.Unlock() return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "未分配工程师"} } if issue.NotifyStatus == afterSalesNotifySent && issue.LastNotifiedAt > 0 && cfg.NotifyCooldownSeconds > 0 { if time.Since(time.Unix(issue.LastNotifiedAt, 0)) < time.Duration(cfg.NotifyCooldownSeconds)*time.Second { e.mu.Unlock() return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "已通知过,冷却时间内不重复推送"} } } e.mu.Unlock() clientID, conversationID, err := resolveEngineerConversation(issue.AssignedEngineerID) if err == nil { err = sendAutoReplyText(clientID, conversationID, renderAfterSalesDispatchNotification(issue, cfg)) } e.mu.Lock() defer e.mu.Unlock() for i := range e.issues { if e.issues[i].ID != issueID { continue } e.issues[i].NotifyCount++ e.issues[i].UpdatedAt = time.Now().Local().Format(time.RFC3339) if err != nil { e.issues[i].NotifyStatus = afterSalesNotifyFailed e.issues[i].NotifyError = err.Error() _ = e.saveIssuesLocked() return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: err.Error()} } e.issues[i].NotifyStatus = afterSalesNotifySent e.issues[i].NotifyError = "" e.issues[i].LastNotifiedAt = time.Now().Unix() _ = e.saveIssuesLocked() return AfterSalesNotifyResult{IssueID: issueID, Success: true, Message: "sent"} } return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "问题不存在"} } func (e *AfterSalesIssueEngine) batchNotifyEngineers(issueIDs []string) map[string]interface{} { if len(uniqueNonEmptyStrings(issueIDs)) == 0 { issueIDs = e.notifyAllAssignedPendingIDs() } results := make([]AfterSalesNotifyResult, 0, len(issueIDs)) successCount := 0 for _, issueID := range uniqueNonEmptyStrings(issueIDs) { result := e.notifyEngineer(issueID) if result.Success { successCount++ } results = append(results, result) } return map[string]interface{}{ "success": successCount == len(results), "message": fmt.Sprintf("已成功推送 %d/%d 条", successCount, len(results)), "data": map[string]interface{}{ "successCount": successCount, "totalCount": len(results), "results": results, }, } } func (e *AfterSalesIssueEngine) notifyAllAssignedPendingIDs() []string { e.mu.Lock() defer e.mu.Unlock() result := make([]string, 0) for _, issue := range e.issues { if issue.Status != afterSalesIssueStatusPending { continue } if strings.TrimSpace(issue.AssignedEngineerID) == "" { continue } if normalizeAfterSalesNotifyStatus(issue.NotifyStatus) != afterSalesNotifyNotSent { continue } result = append(result, issue.ID) } return result } func resolveEngineerConversation(engineerID string) (uint32, string, error) { engineerID = strings.TrimSpace(engineerID) if engineerID == "" { return 0, "", fmt.Errorf("工程师 userId 为空") } clientIDs := identityRefreshClientIDs() if len(clientIDs) == 0 { return 0, "", fmt.Errorf("没有活跃企微账号,无法发送通知") } for _, clientID := range clientIDs { robotID := strings.TrimSpace(getClientUserID(clientID)) if robotID == "" { continue } if strings.HasPrefix(engineerID, "S:") { return clientID, engineerID, nil } return clientID, fmt.Sprintf("S:%s_%s", robotID, engineerID), nil } return 0, "", fmt.Errorf("无法推导工程师私信会话,缺少当前接管账号ID") } func renderAfterSalesDispatchNotification(issue AfterSalesIssue, cfg AfterSalesDispatchConfig) string { template := strings.TrimSpace(cfg.NotifyTemplate) if template == "" { template = defaultAfterSalesDispatchTemplate() } replacements := map[string]string{ "{issueId}": issue.ID, "{roomName}": fallbackString(issue.RoomName, issue.ConversationID), "{customerName}": normalizeAfterSalesDisplayName(issue.CustomerName), "{issueContent}": strings.TrimSpace(issue.IssueContent), "{aiSuggestion}": strings.TrimSpace(issue.AISuggestion), "{createdAt}": formatDispatchIssueTime(issue.CreatedAt), "{engineerName}": fallbackString(issue.AssignedEngineerName, issue.AssignedEngineerID), "{engineerUserId}": issue.AssignedEngineerID, "{imageCount}": fmt.Sprintf("%d", len(issue.ImagePaths)+len(issue.ImageRefs)), "{dispatchReason}": issue.DispatchReason, } for key, value := range replacements { template = strings.ReplaceAll(template, key, value) } return template } func defaultAfterSalesDispatchTemplate() string { return "【售后问题待处理】\n" + "客户/群聊:{customerName} / {roomName}\n" + "提出时间:{createdAt}\n" + "问题ID:{issueId}\n" + "匹配原因:{dispatchReason}\n\n" + "问题描述:\n{issueContent}\n\n" + "AI建议:\n{aiSuggestion}\n\n" + "相关图片:{imageCount} 张" } func formatDispatchIssueTime(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } if t, err := time.Parse(time.RFC3339, value); err == nil { return t.Local().Format("2006-01-02 15:04") } return value }