Files
qiweimanager-master/helper/after_sales_store.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
}