393 lines
11 KiB
Go
393 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type AfterSalesIssueEngine struct {
|
|
mu sync.Mutex
|
|
issues []AfterSalesIssue
|
|
messages []AfterSalesMessage
|
|
state AfterSalesCollectState
|
|
}
|
|
|
|
var afterSalesIssueEngine *AfterSalesIssueEngine
|
|
|
|
func initAfterSalesIssueEngine() {
|
|
engine := &AfterSalesIssueEngine{}
|
|
if err := engine.load(); err != nil && globalLogger != nil {
|
|
globalLogger.Warn("[售后问题库] 加载本地数据失败: %v", err)
|
|
}
|
|
engine.updateStateMessageCountLocked()
|
|
afterSalesIssueEngine = engine
|
|
go engine.autoCollectLoop()
|
|
}
|
|
|
|
func getAfterSalesIssueEngine() *AfterSalesIssueEngine {
|
|
if afterSalesIssueEngine == nil {
|
|
initAfterSalesIssueEngine()
|
|
}
|
|
return afterSalesIssueEngine
|
|
}
|
|
|
|
func (e *AfterSalesIssueEngine) load() error {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
var errs []string
|
|
if err := readJSONFile(afterSalesIssuesPath(), &e.issues); err != nil {
|
|
errs = append(errs, err.Error())
|
|
}
|
|
if err := readJSONFile(afterSalesStatePath(), &e.state); err != nil {
|
|
errs = append(errs, err.Error())
|
|
}
|
|
if err := readJSONFile(afterSalesMessageBufferPath(), &e.messages); err != nil {
|
|
errs = append(errs, err.Error())
|
|
}
|
|
e.normalizeIssuesLocked()
|
|
if e.repairIssuesLocked() {
|
|
_ = e.saveIssuesLocked()
|
|
}
|
|
e.trimMessagesLocked(time.Now())
|
|
e.updateStateMessageCountLocked()
|
|
if len(errs) > 0 {
|
|
return errors.New(strings.Join(errs, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *AfterSalesIssueEngine) snapshotIssues() []AfterSalesIssue {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
result := append([]AfterSalesIssue(nil), e.issues...)
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].CreatedAt > result[j].CreatedAt
|
|
})
|
|
return result
|
|
}
|
|
|
|
func (e *AfterSalesIssueEngine) snapshotStatus() AfterSalesCollectState {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
e.updateStateMessageCountLocked()
|
|
return e.state
|
|
}
|
|
|
|
func (e *AfterSalesIssueEngine) saveIssue(issue AfterSalesIssue) error {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
now := time.Now().Local().Format(time.RFC3339)
|
|
issue.ID = strings.TrimSpace(issue.ID)
|
|
if issue.ID == "" {
|
|
issue.ID = newAfterSalesID()
|
|
}
|
|
if strings.TrimSpace(issue.CreatedAt) == "" {
|
|
issue.CreatedAt = now
|
|
}
|
|
issue.UpdatedAt = now
|
|
issue.Status = normalizeAfterSalesStatus(issue.Status)
|
|
issue.CustomerName = normalizeAfterSalesDisplayName(issue.CustomerName)
|
|
issue.ImagePaths = uniqueNonEmptyStrings(issue.ImagePaths)
|
|
issue.ImageRefs = uniqueNonEmptyStrings(issue.ImageRefs)
|
|
issue.FileAttachments = normalizeAfterSalesFileAttachments(issue.FileAttachments)
|
|
issue.SourceMessageIDs = uniqueNonEmptyStrings(issue.SourceMessageIDs)
|
|
issue.SourceAccountUserID = strings.TrimSpace(issue.SourceAccountUserID)
|
|
issue.SourceAccountName = strings.TrimSpace(issue.SourceAccountName)
|
|
normalizeAfterSalesDispatchFields(&issue)
|
|
if issue.Fingerprint == "" {
|
|
issue.Fingerprint = afterSalesFingerprint(issue.ConversationID, issue.CustomerUserID, issue.IssueContent)
|
|
}
|
|
|
|
for i := range e.issues {
|
|
if e.issues[i].ID == issue.ID {
|
|
if strings.TrimSpace(issue.AISuggestion) != strings.TrimSpace(e.issues[i].AISuggestion) {
|
|
issue.AISuggestionEdited = true
|
|
}
|
|
if issue.CollectBatchID == "" {
|
|
issue.CollectBatchID = e.issues[i].CollectBatchID
|
|
}
|
|
if issue.Fingerprint == "" {
|
|
issue.Fingerprint = e.issues[i].Fingerprint
|
|
}
|
|
if issue.SourceClientID == 0 {
|
|
issue.SourceClientID = e.issues[i].SourceClientID
|
|
}
|
|
if strings.TrimSpace(issue.SourceAccountUserID) == "" {
|
|
issue.SourceAccountUserID = e.issues[i].SourceAccountUserID
|
|
}
|
|
if strings.TrimSpace(issue.SourceAccountName) == "" {
|
|
issue.SourceAccountName = e.issues[i].SourceAccountName
|
|
}
|
|
e.issues[i] = issue
|
|
return e.saveIssuesLocked()
|
|
}
|
|
}
|
|
e.issues = append(e.issues, issue)
|
|
return e.saveIssuesLocked()
|
|
}
|
|
|
|
func (e *AfterSalesIssueEngine) deleteIssue(id string) bool {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return false
|
|
}
|
|
next := e.issues[:0]
|
|
deleted := false
|
|
for _, issue := range e.issues {
|
|
if issue.ID == id {
|
|
deleted = true
|
|
continue
|
|
}
|
|
next = append(next, issue)
|
|
}
|
|
e.issues = next
|
|
if deleted {
|
|
if err := e.saveIssuesLocked(); err != nil && globalLogger != nil {
|
|
globalLogger.Warn("[售后问题库] 删除后保存失败: %v", err)
|
|
}
|
|
}
|
|
return deleted
|
|
}
|
|
|
|
func (e *AfterSalesIssueEngine) setAutoCollectEnabled(enabled bool) error {
|
|
e.mu.Lock()
|
|
e.state.AutoCollectEnabled = enabled
|
|
e.updateStateMessageCountLocked()
|
|
err := e.saveStateLocked()
|
|
e.mu.Unlock()
|
|
return err
|
|
}
|
|
|
|
func (e *AfterSalesIssueEngine) normalizeIssuesLocked() {
|
|
for i := range e.issues {
|
|
e.issues[i].Status = normalizeAfterSalesStatus(e.issues[i].Status)
|
|
e.issues[i].CustomerName = normalizeAfterSalesDisplayName(e.issues[i].CustomerName)
|
|
e.issues[i].ImagePaths = uniqueNonEmptyStrings(e.issues[i].ImagePaths)
|
|
e.issues[i].ImageRefs = uniqueNonEmptyStrings(e.issues[i].ImageRefs)
|
|
e.issues[i].FileAttachments = normalizeAfterSalesFileAttachments(e.issues[i].FileAttachments)
|
|
e.issues[i].SourceMessageIDs = uniqueNonEmptyStrings(e.issues[i].SourceMessageIDs)
|
|
e.issues[i].SourceAccountUserID = strings.TrimSpace(e.issues[i].SourceAccountUserID)
|
|
e.issues[i].SourceAccountName = strings.TrimSpace(e.issues[i].SourceAccountName)
|
|
normalizeAfterSalesDispatchFields(&e.issues[i])
|
|
if e.issues[i].ID == "" {
|
|
e.issues[i].ID = newAfterSalesID()
|
|
}
|
|
if e.issues[i].CreatedAt == "" {
|
|
e.issues[i].CreatedAt = time.Now().Local().Format(time.RFC3339)
|
|
}
|
|
if e.issues[i].UpdatedAt == "" {
|
|
e.issues[i].UpdatedAt = e.issues[i].CreatedAt
|
|
}
|
|
if e.issues[i].Fingerprint == "" {
|
|
e.issues[i].Fingerprint = afterSalesFingerprint(e.issues[i].ConversationID, e.issues[i].CustomerUserID, e.issues[i].IssueContent)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *AfterSalesIssueEngine) repairIssuesLocked() bool {
|
|
changed := false
|
|
messageByID := make(map[string]AfterSalesMessage)
|
|
for _, msg := range e.messages {
|
|
if strings.TrimSpace(msg.MessageID) != "" {
|
|
messageByID[msg.MessageID] = msg
|
|
}
|
|
}
|
|
for i := range e.issues {
|
|
issue := &e.issues[i]
|
|
conversationID := strings.TrimSpace(issue.ConversationID)
|
|
roomName := strings.TrimSpace(issue.RoomName)
|
|
if conversationID != "" && (roomName == "" || roomName == conversationID || strings.HasPrefix(roomName, "R:")) {
|
|
if resolved := getAutoReplyEngine().ResolveGroupName(conversationID); resolved != "" {
|
|
issue.RoomName = resolved
|
|
changed = true
|
|
}
|
|
}
|
|
if issue.SourceClientID == 0 || strings.TrimSpace(issue.SourceAccountUserID) == "" || strings.TrimSpace(issue.SourceAccountName) == "" {
|
|
for _, id := range issue.SourceMessageIDs {
|
|
msg, ok := messageByID[id]
|
|
if !ok || msg.ClientID == 0 {
|
|
continue
|
|
}
|
|
if issue.SourceClientID == 0 {
|
|
issue.SourceClientID = msg.ClientID
|
|
changed = true
|
|
}
|
|
userID, name := getAutoReplyEngine().sourceAccountForClient(msg.ClientID)
|
|
if strings.TrimSpace(issue.SourceAccountUserID) == "" && userID != "" {
|
|
issue.SourceAccountUserID = userID
|
|
changed = true
|
|
}
|
|
if strings.TrimSpace(issue.SourceAccountName) == "" && name != "" {
|
|
issue.SourceAccountName = name
|
|
changed = true
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
paths := append([]string(nil), issue.ImagePaths...)
|
|
for _, id := range issue.SourceMessageIDs {
|
|
if msg, ok := messageByID[id]; ok && msg.ImagePath != "" {
|
|
paths = append(paths, msg.ImagePath)
|
|
}
|
|
}
|
|
if len(paths) == 0 && len(issue.ImageRefs) > 0 {
|
|
for _, ref := range issue.ImageRefs {
|
|
if path := resolveAfterSalesImageRef(ref); path != "" {
|
|
paths = append(paths, path)
|
|
}
|
|
}
|
|
}
|
|
paths = uniqueExistingImagePaths(paths)
|
|
if !sameStringSlice(issue.ImagePaths, paths) {
|
|
issue.ImagePaths = paths
|
|
changed = true
|
|
}
|
|
|
|
files := append([]AfterSalesFileAttachment(nil), issue.FileAttachments...)
|
|
for _, id := range issue.SourceMessageIDs {
|
|
if msg, ok := messageByID[id]; ok {
|
|
files = append(files, collectCandidateFileAttachments([]string{id}, nil, map[string]AfterSalesMessage{id: msg})...)
|
|
}
|
|
}
|
|
files = normalizeAfterSalesFileAttachments(files)
|
|
if !sameAfterSalesFileAttachments(issue.FileAttachments, files) {
|
|
issue.FileAttachments = files
|
|
changed = true
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
func sameAfterSalesFileAttachments(a []AfterSalesFileAttachment, b []AfterSalesFileAttachment) bool {
|
|
a = normalizeAfterSalesFileAttachments(a)
|
|
b = normalizeAfterSalesFileAttachments(b)
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (e *AfterSalesIssueEngine) saveIssuesLocked() error {
|
|
return atomicWriteJSON(afterSalesIssuesPath(), e.issues)
|
|
}
|
|
|
|
func (e *AfterSalesIssueEngine) saveStateLocked() error {
|
|
return atomicWriteJSON(afterSalesStatePath(), e.state)
|
|
}
|
|
|
|
func (e *AfterSalesIssueEngine) saveMessagesLocked() error {
|
|
return atomicWriteJSON(afterSalesMessageBufferPath(), e.messages)
|
|
}
|
|
|
|
func (e *AfterSalesIssueEngine) updateStateMessageCountLocked() {
|
|
e.state.MessageBufferCount = len(e.messages)
|
|
}
|
|
|
|
func afterSalesIssuesPath() string {
|
|
return resolveAutoReplyPath("config/after_sales_issues.json")
|
|
}
|
|
|
|
func afterSalesStatePath() string {
|
|
return resolveAutoReplyPath("config/after_sales_collect_state.json")
|
|
}
|
|
|
|
func afterSalesMessageBufferPath() string {
|
|
return resolveAutoReplyPath("config/after_sales_message_buffer.json")
|
|
}
|
|
|
|
func readJSONFile(path string, target interface{}) error {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
if len(strings.TrimSpace(string(data))) == 0 {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(data, target); err != nil {
|
|
return fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func atomicWriteJSON(path string, value interface{}) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(value, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|
|
|
|
func normalizeAfterSalesStatus(status string) string {
|
|
switch strings.ToLower(strings.TrimSpace(status)) {
|
|
case afterSalesIssueStatusResolved:
|
|
return afterSalesIssueStatusResolved
|
|
case afterSalesIssueStatusIgnored:
|
|
return afterSalesIssueStatusIgnored
|
|
default:
|
|
return afterSalesIssueStatusPending
|
|
}
|
|
}
|
|
|
|
func normalizeAfterSalesDisplayName(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || strings.EqualFold(name, "unknown") {
|
|
return "未知客户"
|
|
}
|
|
return name
|
|
}
|
|
|
|
func uniqueNonEmptyStrings(items []string) []string {
|
|
seen := make(map[string]struct{})
|
|
result := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
item = strings.TrimSpace(item)
|
|
if item == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[item]; exists {
|
|
continue
|
|
}
|
|
seen[item] = struct{}{}
|
|
result = append(result, item)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func sameStringSlice(a []string, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|