Files
qiweimanager-master/helper/after_sales_dispatch.go

874 lines
28 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}