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,873 @@
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
}