373 lines
11 KiB
Go
373 lines
11 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
var afterSalesKnowledgeRebuildHook = func() {
|
||
if _, err := getAutoReplyEngine().rebuildKnowledgeIndex(); err != nil && globalLogger != nil {
|
||
globalLogger.Warn("[售后知识库] 重建自动客服知识索引失败: %v", err)
|
||
}
|
||
}
|
||
|
||
func (e *AfterSalesIssueEngine) resolveIssue(issueID string, resolutionContent string) (AfterSalesKnowledgeCase, error) {
|
||
issueID = strings.TrimSpace(issueID)
|
||
resolutionContent = strings.TrimSpace(resolutionContent)
|
||
if issueID == "" {
|
||
return AfterSalesKnowledgeCase{}, fmt.Errorf("issueId为空")
|
||
}
|
||
if resolutionContent == "" {
|
||
return AfterSalesKnowledgeCase{}, fmt.Errorf("请填写最终处理方案")
|
||
}
|
||
|
||
now := time.Now().Local().Format(time.RFC3339)
|
||
e.mu.Lock()
|
||
index := -1
|
||
for i := range e.issues {
|
||
if e.issues[i].ID == issueID {
|
||
index = i
|
||
break
|
||
}
|
||
}
|
||
if index < 0 {
|
||
e.mu.Unlock()
|
||
return AfterSalesKnowledgeCase{}, fmt.Errorf("问题不存在")
|
||
}
|
||
|
||
issue := e.issues[index]
|
||
normalizeAfterSalesDispatchFields(&issue)
|
||
issue.Status = afterSalesIssueStatusResolved
|
||
issue.ResolutionContent = resolutionContent
|
||
if strings.TrimSpace(issue.ResolvedAt) == "" {
|
||
issue.ResolvedAt = now
|
||
}
|
||
issue.UpdatedAt = now
|
||
|
||
knowledgeCase, err := upsertAfterSalesKnowledgeCase(issue, now)
|
||
if err != nil {
|
||
e.mu.Unlock()
|
||
return AfterSalesKnowledgeCase{}, err
|
||
}
|
||
issue.KnowledgeArchivedAt = knowledgeCase.KnowledgeArchivedAt
|
||
issue.KnowledgeSourcePath = knowledgeCase.MarkdownPath
|
||
e.issues[index] = issue
|
||
if err := e.saveIssuesLocked(); err != nil {
|
||
e.mu.Unlock()
|
||
return AfterSalesKnowledgeCase{}, err
|
||
}
|
||
e.mu.Unlock()
|
||
|
||
triggerAfterSalesKnowledgeRebuild()
|
||
return knowledgeCase, nil
|
||
}
|
||
|
||
func listAfterSalesKnowledgeCases() ([]AfterSalesKnowledgeCase, error) {
|
||
store, err := readAfterSalesKnowledgeCasesFile()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
cases := append([]AfterSalesKnowledgeCase(nil), store.Cases...)
|
||
for i := range cases {
|
||
cases[i] = normalizeAfterSalesKnowledgeCase(cases[i])
|
||
cases[i].MissingMarkdown = strings.TrimSpace(cases[i].MarkdownPath) == "" || !fileExists(cases[i].MarkdownPath)
|
||
}
|
||
sort.Slice(cases, func(i, j int) bool {
|
||
if cases[i].ResolvedAt != cases[j].ResolvedAt {
|
||
return cases[i].ResolvedAt > cases[j].ResolvedAt
|
||
}
|
||
return cases[i].IssueID > cases[j].IssueID
|
||
})
|
||
return cases, nil
|
||
}
|
||
|
||
func (e *AfterSalesIssueEngine) syncResolvedKnowledgeCases() error {
|
||
now := time.Now().Local().Format(time.RFC3339)
|
||
store, err := readAfterSalesKnowledgeCasesFile()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
caseByIssueID := make(map[string]int)
|
||
for i, item := range store.Cases {
|
||
if id := strings.TrimSpace(item.IssueID); id != "" {
|
||
caseByIssueID[id] = i
|
||
}
|
||
}
|
||
|
||
e.mu.Lock()
|
||
changedIssues := false
|
||
changedCases := false
|
||
for i := range e.issues {
|
||
issue := &e.issues[i]
|
||
if strings.TrimSpace(issue.ID) == "" || normalizeAfterSalesStatus(issue.Status) != afterSalesIssueStatusResolved {
|
||
continue
|
||
}
|
||
if _, exists := caseByIssueID[issue.ID]; exists && strings.TrimSpace(issue.KnowledgeSourcePath) != "" {
|
||
continue
|
||
}
|
||
resolution := strings.TrimSpace(issue.ResolutionContent)
|
||
if resolution == "" {
|
||
resolution = strings.TrimSpace(issue.AISuggestion)
|
||
}
|
||
if resolution == "" {
|
||
resolution = strings.TrimSpace(issue.IssueContent)
|
||
}
|
||
if resolution == "" {
|
||
continue
|
||
}
|
||
if strings.TrimSpace(issue.ResolvedAt) == "" {
|
||
issue.ResolvedAt = firstNonEmpty(issue.UpdatedAt, now)
|
||
changedIssues = true
|
||
}
|
||
if strings.TrimSpace(issue.ResolutionContent) == "" {
|
||
issue.ResolutionContent = resolution
|
||
changedIssues = true
|
||
}
|
||
if strings.TrimSpace(issue.KnowledgeArchivedAt) == "" {
|
||
issue.KnowledgeArchivedAt = now
|
||
changedIssues = true
|
||
}
|
||
issue.KnowledgeSourcePath = afterSalesKnowledgeMarkdownPath(issue.ID)
|
||
changedIssues = true
|
||
|
||
knowledgeCase := knowledgeCaseFromIssue(*issue, issue.KnowledgeArchivedAt)
|
||
if err := writeAfterSalesKnowledgeMarkdown(knowledgeCase); err != nil {
|
||
e.mu.Unlock()
|
||
return err
|
||
}
|
||
if idx, exists := caseByIssueID[issue.ID]; exists {
|
||
store.Cases[idx] = knowledgeCase
|
||
} else {
|
||
store.Cases = append(store.Cases, knowledgeCase)
|
||
caseByIssueID[issue.ID] = len(store.Cases) - 1
|
||
}
|
||
changedCases = true
|
||
}
|
||
if changedCases {
|
||
sortAfterSalesKnowledgeCases(store.Cases)
|
||
if err := writeAfterSalesKnowledgeCasesFile(store); err != nil {
|
||
e.mu.Unlock()
|
||
return err
|
||
}
|
||
}
|
||
if changedIssues {
|
||
if err := e.saveIssuesLocked(); err != nil {
|
||
e.mu.Unlock()
|
||
return err
|
||
}
|
||
}
|
||
e.mu.Unlock()
|
||
|
||
if changedCases {
|
||
triggerAfterSalesKnowledgeRebuild()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func upsertAfterSalesKnowledgeCase(issue AfterSalesIssue, archivedAt string) (AfterSalesKnowledgeCase, error) {
|
||
issue.ID = strings.TrimSpace(issue.ID)
|
||
if issue.ID == "" {
|
||
return AfterSalesKnowledgeCase{}, fmt.Errorf("issueId为空")
|
||
}
|
||
if strings.TrimSpace(issue.ResolutionContent) == "" {
|
||
return AfterSalesKnowledgeCase{}, fmt.Errorf("请填写最终处理方案")
|
||
}
|
||
if strings.TrimSpace(issue.ResolvedAt) == "" {
|
||
issue.ResolvedAt = archivedAt
|
||
}
|
||
|
||
knowledgeCase := knowledgeCaseFromIssue(issue, archivedAt)
|
||
if err := writeAfterSalesKnowledgeMarkdown(knowledgeCase); err != nil {
|
||
return AfterSalesKnowledgeCase{}, err
|
||
}
|
||
|
||
store, err := readAfterSalesKnowledgeCasesFile()
|
||
if err != nil {
|
||
return AfterSalesKnowledgeCase{}, err
|
||
}
|
||
replaced := false
|
||
for i := range store.Cases {
|
||
if store.Cases[i].IssueID == knowledgeCase.IssueID {
|
||
store.Cases[i] = knowledgeCase
|
||
replaced = true
|
||
break
|
||
}
|
||
}
|
||
if !replaced {
|
||
store.Cases = append(store.Cases, knowledgeCase)
|
||
}
|
||
sort.Slice(store.Cases, func(i, j int) bool {
|
||
if store.Cases[i].ResolvedAt != store.Cases[j].ResolvedAt {
|
||
return store.Cases[i].ResolvedAt > store.Cases[j].ResolvedAt
|
||
}
|
||
return store.Cases[i].IssueID > store.Cases[j].IssueID
|
||
})
|
||
if err := writeAfterSalesKnowledgeCasesFile(store); err != nil {
|
||
return AfterSalesKnowledgeCase{}, err
|
||
}
|
||
return knowledgeCase, nil
|
||
}
|
||
|
||
func knowledgeCaseFromIssue(issue AfterSalesIssue, archivedAt string) AfterSalesKnowledgeCase {
|
||
knowledgeCase := AfterSalesKnowledgeCase{
|
||
IssueID: issue.ID,
|
||
CreatedAt: issue.CreatedAt,
|
||
UpdatedAt: issue.UpdatedAt,
|
||
ResolvedAt: issue.ResolvedAt,
|
||
KnowledgeArchivedAt: archivedAt,
|
||
ConversationID: issue.ConversationID,
|
||
RoomName: issue.RoomName,
|
||
CustomerUserID: issue.CustomerUserID,
|
||
CustomerName: issue.CustomerName,
|
||
IssueContent: issue.IssueContent,
|
||
AISuggestion: issue.AISuggestion,
|
||
ResolutionContent: issue.ResolutionContent,
|
||
AssignedEngineerID: issue.AssignedEngineerID,
|
||
AssignedEngineerName: issue.AssignedEngineerName,
|
||
ImageCount: len(issue.ImagePaths) + len(issue.ImageRefs),
|
||
MarkdownPath: afterSalesKnowledgeMarkdownPath(issue.ID),
|
||
}
|
||
return normalizeAfterSalesKnowledgeCase(knowledgeCase)
|
||
}
|
||
|
||
func writeAfterSalesKnowledgeMarkdown(knowledgeCase AfterSalesKnowledgeCase) error {
|
||
if err := os.MkdirAll(filepath.Dir(knowledgeCase.MarkdownPath), 0755); err != nil {
|
||
return err
|
||
}
|
||
return os.WriteFile(knowledgeCase.MarkdownPath, []byte(renderAfterSalesKnowledgeMarkdown(knowledgeCase)), 0644)
|
||
}
|
||
|
||
func sortAfterSalesKnowledgeCases(cases []AfterSalesKnowledgeCase) {
|
||
sort.Slice(cases, func(i, j int) bool {
|
||
if cases[i].ResolvedAt != cases[j].ResolvedAt {
|
||
return cases[i].ResolvedAt > cases[j].ResolvedAt
|
||
}
|
||
return cases[i].IssueID > cases[j].IssueID
|
||
})
|
||
}
|
||
|
||
func normalizeAfterSalesKnowledgeCase(item AfterSalesKnowledgeCase) AfterSalesKnowledgeCase {
|
||
item.IssueID = strings.TrimSpace(item.IssueID)
|
||
item.CustomerName = normalizeAfterSalesDisplayName(item.CustomerName)
|
||
if strings.TrimSpace(item.MarkdownPath) == "" && item.IssueID != "" {
|
||
item.MarkdownPath = afterSalesKnowledgeMarkdownPath(item.IssueID)
|
||
}
|
||
return item
|
||
}
|
||
|
||
func readAfterSalesKnowledgeCasesFile() (afterSalesKnowledgeCasesFile, error) {
|
||
var store afterSalesKnowledgeCasesFile
|
||
if err := readJSONFile(afterSalesKnowledgeCasesPath(), &store); err != nil {
|
||
return store, err
|
||
}
|
||
for i := range store.Cases {
|
||
store.Cases[i] = normalizeAfterSalesKnowledgeCase(store.Cases[i])
|
||
}
|
||
return store, nil
|
||
}
|
||
|
||
func writeAfterSalesKnowledgeCasesFile(store afterSalesKnowledgeCasesFile) error {
|
||
return atomicWriteJSON(afterSalesKnowledgeCasesPath(), store)
|
||
}
|
||
|
||
func afterSalesKnowledgeCasesPath() string {
|
||
return resolveAutoReplyPath("config/after_sales_knowledge/cases.json")
|
||
}
|
||
|
||
func afterSalesKnowledgeMarkdownDir() string {
|
||
return resolveAutoReplyPath("config/knowledge/after_sales_cases")
|
||
}
|
||
|
||
func afterSalesKnowledgeMarkdownPath(issueID string) string {
|
||
return filepath.Join(afterSalesKnowledgeMarkdownDir(), safeAfterSalesKnowledgeFileID(issueID)+".md")
|
||
}
|
||
|
||
func safeAfterSalesKnowledgeFileID(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
var builder strings.Builder
|
||
for _, r := range value {
|
||
switch {
|
||
case r >= 'a' && r <= 'z':
|
||
builder.WriteRune(r)
|
||
case r >= 'A' && r <= 'Z':
|
||
builder.WriteRune(r)
|
||
case r >= '0' && r <= '9':
|
||
builder.WriteRune(r)
|
||
case r == '-' || r == '_':
|
||
builder.WriteRune(r)
|
||
}
|
||
}
|
||
if builder.Len() == 0 {
|
||
return fmt.Sprintf("issue-%d", time.Now().UnixNano())
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
func renderAfterSalesKnowledgeMarkdown(item AfterSalesKnowledgeCase) string {
|
||
var builder strings.Builder
|
||
writeMarkdownLine := func(label string, value string) {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
value = "-"
|
||
}
|
||
builder.WriteString(label)
|
||
builder.WriteString(value)
|
||
builder.WriteString("\n")
|
||
}
|
||
builder.WriteString("# 售后已处理案例\n\n")
|
||
writeMarkdownLine("问题ID:", item.IssueID)
|
||
writeMarkdownLine("群聊:", item.RoomName)
|
||
writeMarkdownLine("客户:", item.CustomerName)
|
||
writeMarkdownLine("负责人:", displayAfterSalesKnowledgeEngineerName(item))
|
||
writeMarkdownLine("提出时间:", item.CreatedAt)
|
||
writeMarkdownLine("处理时间:", item.ResolvedAt)
|
||
writeMarkdownLine("图片数量:", fmt.Sprintf("%d", item.ImageCount))
|
||
builder.WriteString("\n## 问题\n\n")
|
||
builder.WriteString(strings.TrimSpace(item.IssueContent))
|
||
builder.WriteString("\n\n## 最终处理方案\n\n")
|
||
builder.WriteString(strings.TrimSpace(item.ResolutionContent))
|
||
builder.WriteString("\n\n## AI建议\n\n")
|
||
aiSuggestion := strings.TrimSpace(item.AISuggestion)
|
||
if aiSuggestion == "" {
|
||
aiSuggestion = "-"
|
||
}
|
||
builder.WriteString(aiSuggestion)
|
||
builder.WriteString("\n")
|
||
return builder.String()
|
||
}
|
||
|
||
func displayAfterSalesKnowledgeEngineerName(item AfterSalesKnowledgeCase) string {
|
||
if name := strings.TrimSpace(item.AssignedEngineerName); name != "" {
|
||
return name
|
||
}
|
||
if id := strings.TrimSpace(item.AssignedEngineerID); id != "" {
|
||
return id
|
||
}
|
||
return "-"
|
||
}
|
||
|
||
func revealAfterSalesKnowledgeCase(issueID string) (bool, string) {
|
||
issueID = strings.TrimSpace(issueID)
|
||
if issueID == "" {
|
||
return false, "issueId为空"
|
||
}
|
||
path := afterSalesKnowledgeMarkdownPath(issueID)
|
||
if !fileExists(path) {
|
||
return false, "知识案例文件不存在"
|
||
}
|
||
cmd := exec.Command("explorer.exe", path)
|
||
if err := cmd.Start(); err != nil {
|
||
return false, err.Error()
|
||
}
|
||
return true, "opened"
|
||
}
|
||
|
||
func triggerAfterSalesKnowledgeRebuild() {
|
||
go afterSalesKnowledgeRebuildHook()
|
||
}
|