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,372 @@
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()
}