Initial qiwei secondary development handoff
This commit is contained in:
392
helper/after_sales_store.go
Normal file
392
helper/after_sales_store.go
Normal file
@@ -0,0 +1,392 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user