Initial qiwei secondary development handoff
This commit is contained in:
858
after_sales.go
Normal file
858
after_sales.go
Normal file
@@ -0,0 +1,858 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
type AfterSalesIssue struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
RoomName string `json:"roomName"`
|
||||
SourceClientID int32 `json:"sourceClientId"`
|
||||
SourceAccountUserID string `json:"sourceAccountUserId"`
|
||||
SourceAccountName string `json:"sourceAccountName"`
|
||||
CustomerUserID string `json:"customerUserId"`
|
||||
CustomerName string `json:"customerName"`
|
||||
IssueContent string `json:"issueContent"`
|
||||
ImagePaths []string `json:"imagePaths"`
|
||||
ImageRefs []string `json:"imageRefs"`
|
||||
FileAttachments []AfterSalesFileAttachment `json:"fileAttachments"`
|
||||
AISuggestion string `json:"aiSuggestion"`
|
||||
Status string `json:"status"`
|
||||
SourceMessageIDs []string `json:"sourceMessageIds"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
CollectBatchID string `json:"collectBatchId"`
|
||||
AIConfidence float64 `json:"aiConfidence"`
|
||||
AISuggestionEdited bool `json:"aiSuggestionEdited"`
|
||||
AssignedEngineerID string `json:"assignedEngineerId"`
|
||||
AssignedEngineerName string `json:"assignedEngineerName"`
|
||||
DispatchStatus string `json:"dispatchStatus"`
|
||||
DispatchReason string `json:"dispatchReason"`
|
||||
DispatchRuleID string `json:"dispatchRuleId"`
|
||||
DispatchConfidence float64 `json:"dispatchConfidence"`
|
||||
DispatchSource string `json:"dispatchSource"`
|
||||
NotifyStatus string `json:"notifyStatus"`
|
||||
LastNotifiedAt int64 `json:"lastNotifiedAt"`
|
||||
NotifyError string `json:"notifyError"`
|
||||
NotifyCount int `json:"notifyCount"`
|
||||
ResolutionContent string `json:"resolutionContent"`
|
||||
ResolvedAt string `json:"resolvedAt"`
|
||||
KnowledgeArchivedAt string `json:"knowledgeArchivedAt"`
|
||||
KnowledgeSourcePath string `json:"knowledgeSourcePath"`
|
||||
}
|
||||
|
||||
type AfterSalesFileAttachment struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Ref string `json:"ref"`
|
||||
Content string `json:"content"`
|
||||
ExtractStatus string `json:"extractStatus"`
|
||||
SourceMessageID string `json:"sourceMessageId"`
|
||||
}
|
||||
|
||||
type AfterSalesHistoryImportRequest struct {
|
||||
ConversationID string `json:"conversationId"`
|
||||
RoomName string `json:"roomName"`
|
||||
RawText string `json:"rawText"`
|
||||
}
|
||||
|
||||
type AfterSalesKnowledgeArchive struct {
|
||||
ID string `json:"id"`
|
||||
FileName string `json:"fileName"`
|
||||
Path string `json:"path"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
IssueCount int `json:"issueCount"`
|
||||
IssueIDs []string `json:"issueIds,omitempty"`
|
||||
MissingFile bool `json:"missingFile,omitempty"`
|
||||
DisplayTime string `json:"displayTime,omitempty"`
|
||||
}
|
||||
|
||||
type AfterSalesKnowledgeCase struct {
|
||||
IssueID string `json:"issueId"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ResolvedAt string `json:"resolvedAt"`
|
||||
KnowledgeArchivedAt string `json:"knowledgeArchivedAt"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
RoomName string `json:"roomName"`
|
||||
CustomerUserID string `json:"customerUserId"`
|
||||
CustomerName string `json:"customerName"`
|
||||
IssueContent string `json:"issueContent"`
|
||||
AISuggestion string `json:"aiSuggestion"`
|
||||
ResolutionContent string `json:"resolutionContent"`
|
||||
AssignedEngineerID string `json:"assignedEngineerId"`
|
||||
AssignedEngineerName string `json:"assignedEngineerName"`
|
||||
ImageCount int `json:"imageCount"`
|
||||
MarkdownPath string `json:"markdownPath"`
|
||||
MissingMarkdown bool `json:"missingMarkdown,omitempty"`
|
||||
}
|
||||
|
||||
type AfterSalesKnowledgeSummary struct {
|
||||
PendingCount int `json:"pendingCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
ArchiveCount int `json:"archiveCount"`
|
||||
}
|
||||
|
||||
type afterSalesKnowledgeManifest struct {
|
||||
Archives []AfterSalesKnowledgeArchive `json:"archives"`
|
||||
}
|
||||
|
||||
// GetIssues returns all locally collected after-sales issues.
|
||||
func (a *App) GetIssues() []AfterSalesIssue {
|
||||
issues, err := a.fetchAfterSalesIssues()
|
||||
if err != nil {
|
||||
globalLogger.Warn("闂備礁鍚嬮崕鎶藉床閼艰翰浜归柛銉墮閼歌銇勯弬鍨倯闁稿﹦鏁诲濠氬磼閵堝懏鐝濆銈忕秬婵倝骞嗛弮鍫濈闁绘劖鍨濋弶顓㈡煟? %v", err)
|
||||
return []AfterSalesIssue{}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
// SaveIssue creates or updates one after-sales issue.
|
||||
func (a *App) SaveIssue(issue AfterSalesIssue) (bool, string) {
|
||||
result, err := a.postHelperJSON("/api/after-sales/issues/save", issue)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
return helperResultOK(result)
|
||||
}
|
||||
|
||||
// DeleteIssue removes one after-sales issue.
|
||||
func (a *App) DeleteIssue(id string) (bool, string) {
|
||||
result, err := a.postHelperJSON("/api/after-sales/issues/delete", map[string]interface{}{"id": id})
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
return helperResultOK(result)
|
||||
}
|
||||
|
||||
// ResolveAfterSalesIssue marks one issue resolved and saves it as a knowledge case.
|
||||
func (a *App) ResolveAfterSalesIssue(issueId string, resolutionContent string) interface{} {
|
||||
result, err := a.postHelperJSON("/api/after-sales/issues/resolve", map[string]interface{}{
|
||||
"issueId": issueId,
|
||||
"resolutionContent": resolutionContent,
|
||||
})
|
||||
if err != nil {
|
||||
if result != nil {
|
||||
return result
|
||||
}
|
||||
return map[string]interface{}{"success": false, "message": err.Error()}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ListAfterSalesKnowledgeCases returns resolved after-sales knowledge cases.
|
||||
func (a *App) ListAfterSalesKnowledgeCases() interface{} {
|
||||
result, err := a.getHelperJSON("/api/after-sales/knowledge/cases")
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": err.Error(), "data": []AfterSalesKnowledgeCase{}}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// UpdateAfterSalesKnowledgeCase updates the final resolution of a knowledge case.
|
||||
func (a *App) UpdateAfterSalesKnowledgeCase(issueId string, resolutionContent string) interface{} {
|
||||
result, err := a.postHelperJSON("/api/after-sales/knowledge/cases/update", map[string]interface{}{
|
||||
"issueId": issueId,
|
||||
"resolutionContent": resolutionContent,
|
||||
})
|
||||
if err != nil {
|
||||
if result != nil {
|
||||
return result
|
||||
}
|
||||
return map[string]interface{}{"success": false, "message": err.Error()}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// RevealAfterSalesKnowledgeCase opens the generated Markdown file for one case.
|
||||
func (a *App) RevealAfterSalesKnowledgeCase(issueId string) (bool, string) {
|
||||
result, err := a.postHelperJSON("/api/after-sales/knowledge/cases/reveal", map[string]interface{}{"issueId": issueId})
|
||||
if err != nil {
|
||||
if result != nil {
|
||||
return helperResultOK(result)
|
||||
}
|
||||
return false, err.Error()
|
||||
}
|
||||
return helperResultOK(result)
|
||||
}
|
||||
|
||||
// ExportIssuesToExcel asks the user for an xlsx path and writes the current issues.
|
||||
func (a *App) ExportIssuesToExcel() (bool, string) {
|
||||
issues, err := a.fetchAfterSalesIssues()
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defaultName := fmt.Sprintf("after_sales_issues_%s.xlsx", time.Now().Format("2006-01-02_1504"))
|
||||
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||
Title: "Export after-sales issues",
|
||||
DefaultFilename: defaultName,
|
||||
Filters: []runtime.FileFilter{{
|
||||
DisplayName: "Excel workbook (*.xlsx)",
|
||||
Pattern: "*.xlsx",
|
||||
}},
|
||||
CanCreateDirectories: true,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return false, "export canceled"
|
||||
}
|
||||
if strings.ToLower(filepath.Ext(path)) != ".xlsx" {
|
||||
path += ".xlsx"
|
||||
}
|
||||
if err := writeAfterSalesIssuesExcel(path, issues); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
return true, path
|
||||
}
|
||||
|
||||
// ListAfterSalesKnowledgeArchives returns all saved after-sales Excel archives.
|
||||
func (a *App) ListAfterSalesKnowledgeArchives() interface{} {
|
||||
manifest, err := readAfterSalesKnowledgeManifest()
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": err.Error(), "data": []AfterSalesKnowledgeArchive{}}
|
||||
}
|
||||
archives := append([]AfterSalesKnowledgeArchive(nil), manifest.Archives...)
|
||||
for i := range archives {
|
||||
archives[i].MissingFile = !fileExists(archives[i].Path)
|
||||
archives[i].DisplayTime = formatAfterSalesExcelTime(archives[i].CreatedAt)
|
||||
}
|
||||
sortAfterSalesKnowledgeArchives(archives)
|
||||
return map[string]interface{}{"success": true, "message": "ok", "data": archives}
|
||||
}
|
||||
|
||||
// GetPendingAfterSalesArchiveSummary reports how many issues have not been saved
|
||||
// into the local Excel knowledge archive yet.
|
||||
func (a *App) GetPendingAfterSalesArchiveSummary() interface{} {
|
||||
issues, err := a.fetchAfterSalesIssues()
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": err.Error(), "data": AfterSalesKnowledgeSummary{}}
|
||||
}
|
||||
manifest, err := readAfterSalesKnowledgeManifest()
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": err.Error(), "data": AfterSalesKnowledgeSummary{}}
|
||||
}
|
||||
summary := AfterSalesKnowledgeSummary{
|
||||
PendingCount: len(filterPendingAfterSalesArchiveIssues(issues, manifest)),
|
||||
TotalCount: len(issues),
|
||||
ArchiveCount: len(manifest.Archives),
|
||||
}
|
||||
return map[string]interface{}{"success": true, "message": "ok", "data": summary}
|
||||
}
|
||||
|
||||
// ArchivePendingAfterSalesIssues writes one new timestamped Excel archive for
|
||||
// issues that have not been saved to the knowledge archive yet.
|
||||
func (a *App) ArchivePendingAfterSalesIssues() interface{} {
|
||||
archive, archived, err := a.archivePendingAfterSalesIssues()
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": err.Error(), "data": archive}
|
||||
}
|
||||
if !archived {
|
||||
return map[string]interface{}{"success": true, "message": "no pending issues", "data": archive}
|
||||
}
|
||||
return map[string]interface{}{"success": true, "message": "saved to knowledge archive", "data": archive}
|
||||
}
|
||||
|
||||
func (a *App) confirmArchivePendingAfterSalesBeforeClose(ctx context.Context) bool {
|
||||
summaryResult := a.GetPendingAfterSalesArchiveSummary()
|
||||
summaryMap, _ := summaryResult.(map[string]interface{})
|
||||
if ok, _ := summaryMap["success"].(bool); !ok {
|
||||
return false
|
||||
}
|
||||
summary, ok := summaryMap["data"].(AfterSalesKnowledgeSummary)
|
||||
if !ok || summary.PendingCount <= 0 {
|
||||
return false
|
||||
}
|
||||
message := fmt.Sprintf("There are %d after-sales issues not saved to the knowledge archive. Save them before exit?", summary.PendingCount)
|
||||
choice, err := runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
|
||||
Type: runtime.QuestionDialog,
|
||||
Title: "Save after-sales issues",
|
||||
Message: message,
|
||||
DefaultButton: "Yes",
|
||||
})
|
||||
if err != nil || choice != "Yes" {
|
||||
confirmExit, confirmErr := runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
|
||||
Type: runtime.QuestionDialog,
|
||||
Title: "Confirm exit",
|
||||
Message: "Exit without saving after-sales issues to the knowledge archive? Choose No to return to the app.",
|
||||
DefaultButton: "No",
|
||||
})
|
||||
return confirmErr != nil || confirmExit != "Yes"
|
||||
}
|
||||
archive, archived, err := a.archivePendingAfterSalesIssues()
|
||||
if err != nil {
|
||||
_, _ = runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
|
||||
Type: runtime.ErrorDialog,
|
||||
Title: "Save failed",
|
||||
Message: "Saving to the knowledge archive failed; exit canceled: " + err.Error(),
|
||||
})
|
||||
return true
|
||||
}
|
||||
if archived {
|
||||
globalLogger.Info("Saved after-sales knowledge archive before exit: %s (%d issues)", archive.Path, archive.IssueCount)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RevealAfterSalesKnowledgeArchive opens an archive file or the archive folder.
|
||||
func (a *App) RevealAfterSalesKnowledgeArchive(path string) (bool, string) {
|
||||
path = strings.Trim(strings.TrimSpace(path), "\"'")
|
||||
if path == "" {
|
||||
path = afterSalesKnowledgeDir()
|
||||
}
|
||||
if err := os.MkdirAll(afterSalesKnowledgeDir(), 0755); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(afterSalesKnowledgeDir(), filepath.Clean(path))
|
||||
}
|
||||
if !isPathInside(path, afterSalesKnowledgeDir()) {
|
||||
return false, "can only open files inside the after-sales knowledge directory"
|
||||
}
|
||||
target := path
|
||||
if !fileExists(target) {
|
||||
target = afterSalesKnowledgeDir()
|
||||
}
|
||||
cmd := exec.Command("explorer.exe", target)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
return true, "opened"
|
||||
}
|
||||
|
||||
// RevealAfterSalesAttachment opens a locally saved after-sales attachment.
|
||||
func (a *App) RevealAfterSalesAttachment(path string) (bool, string) {
|
||||
path = strings.Trim(strings.TrimSpace(path), "\"'")
|
||||
if path == "" {
|
||||
return false, "empty attachment path"
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
return false, "attachment path must be absolute"
|
||||
}
|
||||
if !fileExists(path) {
|
||||
return false, "attachment file is missing"
|
||||
}
|
||||
if !isAllowedAfterSalesAttachmentPath(path) {
|
||||
return false, "can only open files saved by this app"
|
||||
}
|
||||
cmd := exec.Command("explorer.exe", path)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
return true, "opened"
|
||||
}
|
||||
|
||||
// TriggerManualCollect starts one asynchronous after-sales issue collection.
|
||||
func (a *App) TriggerManualCollect(conversationID string) (bool, string) {
|
||||
result, err := a.postHelperJSON("/api/after-sales/collect", map[string]interface{}{"conversationId": conversationID})
|
||||
if err != nil {
|
||||
if result != nil {
|
||||
return helperResultOK(result)
|
||||
}
|
||||
return false, err.Error()
|
||||
}
|
||||
return helperResultOK(result)
|
||||
}
|
||||
|
||||
// ImportAfterSalesHistory imports copied WeCom chat history and runs collection.
|
||||
func (a *App) ImportAfterSalesHistory(req AfterSalesHistoryImportRequest) (bool, string) {
|
||||
result, err := a.postHelperJSON("/api/after-sales/import-history", req)
|
||||
if err != nil {
|
||||
if result != nil {
|
||||
return helperResultOK(result)
|
||||
}
|
||||
return false, err.Error()
|
||||
}
|
||||
return helperResultOK(result)
|
||||
}
|
||||
|
||||
// PrepareWeComHistoryCopy activates WeCom and sends the copy shortcut.
|
||||
// The frontend owns clipboard reads so the UI can show progress and timeouts.
|
||||
func (a *App) PrepareWeComHistoryCopy() (bool, string) {
|
||||
return a.prepareWeComHistoryCopy()
|
||||
}
|
||||
|
||||
// SyncCurrentWeComChatHistory is kept for older frontends. New UI should call
|
||||
// PrepareWeComHistoryCopy, read clipboard text, then ImportAfterSalesHistory.
|
||||
func (a *App) SyncCurrentWeComChatHistory(req AfterSalesHistoryImportRequest) (bool, string) {
|
||||
req.ConversationID = strings.TrimSpace(req.ConversationID)
|
||||
req.RoomName = strings.TrimSpace(req.RoomName)
|
||||
if req.ConversationID == "" || strings.EqualFold(req.ConversationID, "all") {
|
||||
return false, "please select a specific group before syncing history"
|
||||
}
|
||||
if req.RawText == "" {
|
||||
return false, "raw history text is empty"
|
||||
}
|
||||
result, err := a.postHelperJSON("/api/after-sales/import-history", req)
|
||||
if err != nil {
|
||||
if result != nil {
|
||||
return helperResultOK(result)
|
||||
}
|
||||
return false, err.Error()
|
||||
}
|
||||
return helperResultOK(result)
|
||||
}
|
||||
|
||||
// SetAutoCollectTask persists the hourly collection switch.
|
||||
func (a *App) SetAutoCollectTask(enabled bool) (bool, string) {
|
||||
result, err := a.postHelperJSON("/api/after-sales/auto-collect", map[string]interface{}{"enabled": enabled})
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
return helperResultOK(result)
|
||||
}
|
||||
|
||||
// GetAfterSalesIssueStatus returns collection status.
|
||||
func (a *App) GetAfterSalesIssueStatus() interface{} {
|
||||
result, err := a.getHelperJSON("/api/after-sales/status")
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": err.Error()}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAfterSalesImageData returns a local after-sales image as a browser-safe data URL.
|
||||
func (a *App) GetAfterSalesImageData(path string) (string, error) {
|
||||
path = strings.Trim(strings.TrimSpace(path), "\"'")
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("image path is empty")
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Clean(path)
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp":
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported image type: %s", ext)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", fmt.Errorf("image file is empty")
|
||||
}
|
||||
mimeType := mime.TypeByExtension(ext)
|
||||
if mimeType == "" {
|
||||
mimeType = http.DetectContentType(data)
|
||||
}
|
||||
if !strings.HasPrefix(mimeType, "image/") {
|
||||
return "", fmt.Errorf("not an image file")
|
||||
}
|
||||
return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
func (a *App) fetchAfterSalesIssues() ([]AfterSalesIssue, error) {
|
||||
result, err := a.getHelperJSON("/api/after-sales/issues")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok, _ := result["success"].(bool); !ok {
|
||||
return nil, fmt.Errorf("%v", result["message"])
|
||||
}
|
||||
data, err := json.Marshal(result["data"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var issues []AfterSalesIssue
|
||||
if err := json.Unmarshal(data, &issues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if issues == nil {
|
||||
issues = []AfterSalesIssue{}
|
||||
}
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
func (a *App) archivePendingAfterSalesIssues() (AfterSalesKnowledgeArchive, bool, error) {
|
||||
issues, err := a.fetchAfterSalesIssues()
|
||||
if err != nil {
|
||||
return AfterSalesKnowledgeArchive{}, false, err
|
||||
}
|
||||
manifest, err := readAfterSalesKnowledgeManifest()
|
||||
if err != nil {
|
||||
return AfterSalesKnowledgeArchive{}, false, err
|
||||
}
|
||||
pending := filterPendingAfterSalesArchiveIssues(issues, manifest)
|
||||
if len(pending) == 0 {
|
||||
return AfterSalesKnowledgeArchive{}, false, nil
|
||||
}
|
||||
sort.Slice(pending, func(i, j int) bool {
|
||||
return pending[i].CreatedAt > pending[j].CreatedAt
|
||||
})
|
||||
if err := os.MkdirAll(afterSalesKnowledgeDir(), 0755); err != nil {
|
||||
return AfterSalesKnowledgeArchive{}, false, err
|
||||
}
|
||||
now := time.Now().Local()
|
||||
path := nextAfterSalesKnowledgeExcelPath(now)
|
||||
if err := writeAfterSalesIssuesExcel(path, pending); err != nil {
|
||||
return AfterSalesKnowledgeArchive{}, false, err
|
||||
}
|
||||
archive := AfterSalesKnowledgeArchive{
|
||||
ID: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)),
|
||||
FileName: filepath.Base(path),
|
||||
Path: path,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
IssueCount: len(pending),
|
||||
IssueIDs: afterSalesIssueIDs(pending),
|
||||
DisplayTime: now.Format("2006-01-02 15:04"),
|
||||
}
|
||||
manifest.Archives = append(manifest.Archives, archive)
|
||||
sortAfterSalesKnowledgeArchives(manifest.Archives)
|
||||
if err := writeAfterSalesKnowledgeManifest(manifest); err != nil {
|
||||
return AfterSalesKnowledgeArchive{}, false, err
|
||||
}
|
||||
return archive, true, nil
|
||||
}
|
||||
|
||||
func filterPendingAfterSalesArchiveIssues(issues []AfterSalesIssue, manifest afterSalesKnowledgeManifest) []AfterSalesIssue {
|
||||
archived := make(map[string]bool)
|
||||
for _, archive := range manifest.Archives {
|
||||
for _, id := range archive.IssueIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
archived[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
pending := make([]AfterSalesIssue, 0)
|
||||
for _, issue := range issues {
|
||||
if strings.TrimSpace(issue.ID) == "" || archived[issue.ID] {
|
||||
continue
|
||||
}
|
||||
pending = append(pending, issue)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
func readAfterSalesKnowledgeManifest() (afterSalesKnowledgeManifest, error) {
|
||||
var manifest afterSalesKnowledgeManifest
|
||||
data, err := os.ReadFile(afterSalesKnowledgeManifestPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return manifest, nil
|
||||
}
|
||||
return manifest, err
|
||||
}
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return manifest, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return manifest, err
|
||||
}
|
||||
for i := range manifest.Archives {
|
||||
if manifest.Archives[i].ID == "" {
|
||||
manifest.Archives[i].ID = strings.TrimSuffix(manifest.Archives[i].FileName, filepath.Ext(manifest.Archives[i].FileName))
|
||||
}
|
||||
if manifest.Archives[i].Path == "" && manifest.Archives[i].FileName != "" {
|
||||
manifest.Archives[i].Path = filepath.Join(afterSalesKnowledgeDir(), manifest.Archives[i].FileName)
|
||||
}
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func writeAfterSalesKnowledgeManifest(manifest afterSalesKnowledgeManifest) error {
|
||||
if err := os.MkdirAll(afterSalesKnowledgeDir(), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := afterSalesKnowledgeManifestPath() + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, afterSalesKnowledgeManifestPath())
|
||||
}
|
||||
|
||||
func afterSalesKnowledgeDir() string {
|
||||
base, err := os.Executable()
|
||||
if err != nil {
|
||||
if wd, wdErr := os.Getwd(); wdErr == nil {
|
||||
base = wd
|
||||
}
|
||||
}
|
||||
if base != "" && filepath.Ext(base) != "" {
|
||||
base = filepath.Dir(base)
|
||||
}
|
||||
return filepath.Join(base, "config", "after_sales_knowledge")
|
||||
}
|
||||
|
||||
func afterSalesKnowledgeManifestPath() string {
|
||||
return filepath.Join(afterSalesKnowledgeDir(), "manifest.json")
|
||||
}
|
||||
|
||||
func nextAfterSalesKnowledgeExcelPath(now time.Time) string {
|
||||
baseName := fmt.Sprintf("after_sales_knowledge_%s", now.Format("2006-01-02_1504"))
|
||||
path := filepath.Join(afterSalesKnowledgeDir(), baseName+".xlsx")
|
||||
if !fileExists(path) {
|
||||
return path
|
||||
}
|
||||
secondName := fmt.Sprintf("after_sales_knowledge_%s", now.Format("2006-01-02_150405"))
|
||||
path = filepath.Join(afterSalesKnowledgeDir(), secondName+".xlsx")
|
||||
if !fileExists(path) {
|
||||
return path
|
||||
}
|
||||
for i := 2; ; i++ {
|
||||
path = filepath.Join(afterSalesKnowledgeDir(), fmt.Sprintf("%s_%02d.xlsx", secondName, i))
|
||||
if !fileExists(path) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func afterSalesIssueIDs(issues []AfterSalesIssue) []string {
|
||||
ids := make([]string, 0, len(issues))
|
||||
for _, issue := range issues {
|
||||
if id := strings.TrimSpace(issue.ID); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func sortAfterSalesKnowledgeArchives(archives []AfterSalesKnowledgeArchive) {
|
||||
sort.Slice(archives, func(i, j int) bool {
|
||||
if archives[i].CreatedAt != archives[j].CreatedAt {
|
||||
return archives[i].CreatedAt > archives[j].CreatedAt
|
||||
}
|
||||
return archives[i].FileName > archives[j].FileName
|
||||
})
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func isPathInside(path string, root string) bool {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(absRoot, absPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel))
|
||||
}
|
||||
|
||||
func isAllowedAfterSalesAttachmentPath(path string) bool {
|
||||
roots := make([]string, 0, 6)
|
||||
if wd, err := os.Getwd(); err == nil && strings.TrimSpace(wd) != "" {
|
||||
roots = append(roots, wd, filepath.Join(wd, "temp"))
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil && strings.TrimSpace(exe) != "" {
|
||||
exeDir := filepath.Dir(exe)
|
||||
roots = append(roots, exeDir, filepath.Join(exeDir, "temp"), filepath.Join(filepath.Dir(exeDir), "temp"))
|
||||
}
|
||||
if tmp := os.TempDir(); strings.TrimSpace(tmp) != "" {
|
||||
roots = append(roots, tmp)
|
||||
}
|
||||
cleaned := filepath.Clean(path)
|
||||
for _, root := range roots {
|
||||
if strings.TrimSpace(root) != "" && isPathInside(cleaned, root) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func helperResultOK(result map[string]interface{}) (bool, string) {
|
||||
if ok, _ := result["success"].(bool); ok {
|
||||
if msg := strings.TrimSpace(fmt.Sprint(result["message"])); msg != "" && msg != "<nil>" {
|
||||
return true, msg
|
||||
}
|
||||
return true, "success"
|
||||
}
|
||||
msg := strings.TrimSpace(fmt.Sprint(result["message"]))
|
||||
if msg == "" || msg == "<nil>" {
|
||||
msg = "operation failed"
|
||||
}
|
||||
return false, msg
|
||||
}
|
||||
|
||||
func writeAfterSalesIssuesExcel(path string, issues []AfterSalesIssue) error {
|
||||
file := excelize.NewFile()
|
||||
sheet := "AfterSalesIssues"
|
||||
defaultSheet := file.GetSheetName(0)
|
||||
if defaultSheet != sheet {
|
||||
if err := file.SetSheetName(defaultSheet, sheet); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
headers := []string{"Created At", "问题来源账号", "Group", "Customer", "Issue Content", "Images", "Files", "File Content", "AI Suggestion", "Status", "Engineer", "Dispatch Status", "Notify Status"}
|
||||
for i, header := range headers {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
_ = file.SetCellValue(sheet, cell, header)
|
||||
}
|
||||
for row, issue := range issues {
|
||||
values := []interface{}{
|
||||
formatAfterSalesExcelTime(issue.CreatedAt),
|
||||
displayAfterSalesSourceAccount(issue),
|
||||
issue.RoomName,
|
||||
displayAfterSalesCustomerName(issue.CustomerName),
|
||||
issue.IssueContent,
|
||||
strings.Join(append(append([]string{}, issue.ImagePaths...), issue.ImageRefs...), "\n"),
|
||||
formatAfterSalesIssueFiles(issue.FileAttachments, false),
|
||||
formatAfterSalesIssueFiles(issue.FileAttachments, true),
|
||||
issue.AISuggestion,
|
||||
afterSalesStatusLabel(issue.Status),
|
||||
displayAfterSalesEngineerName(issue),
|
||||
afterSalesDispatchStatusLabel(issue.DispatchStatus),
|
||||
afterSalesNotifyStatusLabel(issue.NotifyStatus),
|
||||
}
|
||||
for col, value := range values {
|
||||
cell, _ := excelize.CoordinatesToCellName(col+1, row+2)
|
||||
_ = file.SetCellValue(sheet, cell, value)
|
||||
}
|
||||
}
|
||||
_ = file.SetColWidth(sheet, "A", "A", 18)
|
||||
_ = file.SetColWidth(sheet, "B", "D", 24)
|
||||
_ = file.SetColWidth(sheet, "E", "I", 42)
|
||||
_ = file.SetColWidth(sheet, "J", "M", 14)
|
||||
return file.SaveAs(path)
|
||||
}
|
||||
|
||||
func formatAfterSalesIssueFiles(files []AfterSalesFileAttachment, includeContent bool) string {
|
||||
if len(files) == 0 {
|
||||
return ""
|
||||
}
|
||||
lines := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
name := strings.TrimSpace(file.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(filepath.Base(file.Path))
|
||||
}
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(file.Ref)
|
||||
}
|
||||
if name == "" {
|
||||
name = "attachment"
|
||||
}
|
||||
line := name
|
||||
if strings.TrimSpace(file.Path) != "" {
|
||||
line += " | " + strings.TrimSpace(file.Path)
|
||||
} else if strings.TrimSpace(file.Ref) != "" {
|
||||
line += " | " + strings.TrimSpace(file.Ref)
|
||||
}
|
||||
if strings.TrimSpace(file.ExtractStatus) != "" {
|
||||
line += " | " + strings.TrimSpace(file.ExtractStatus)
|
||||
}
|
||||
if includeContent && strings.TrimSpace(file.Content) != "" {
|
||||
line += "\n" + truncateAfterSalesExcelText(file.Content, 1200)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return strings.Join(lines, "\n\n")
|
||||
}
|
||||
|
||||
func truncateAfterSalesExcelText(text string, limit int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if limit <= 0 || len([]rune(text)) <= limit {
|
||||
return text
|
||||
}
|
||||
runes := []rune(text)
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
func displayAfterSalesCustomerName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "unknown customer"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func displayAfterSalesSourceAccount(issue AfterSalesIssue) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if name := strings.TrimSpace(issue.SourceAccountName); name != "" {
|
||||
parts = append(parts, name)
|
||||
}
|
||||
if userID := strings.TrimSpace(issue.SourceAccountUserID); userID != "" && userID != strings.TrimSpace(issue.SourceAccountName) {
|
||||
parts = append(parts, userID)
|
||||
}
|
||||
if issue.SourceClientID != 0 {
|
||||
parts = append(parts, fmt.Sprintf("client %d", issue.SourceClientID))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "-"
|
||||
}
|
||||
return strings.Join(parts, " / ")
|
||||
}
|
||||
|
||||
func formatAfterSalesExcelTime(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return t.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func afterSalesStatusLabel(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "resolved":
|
||||
return "resolved"
|
||||
case "ignored":
|
||||
return "ignored"
|
||||
default:
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
func displayAfterSalesEngineerName(issue AfterSalesIssue) string {
|
||||
if strings.TrimSpace(issue.AssignedEngineerName) != "" {
|
||||
return strings.TrimSpace(issue.AssignedEngineerName)
|
||||
}
|
||||
return strings.TrimSpace(issue.AssignedEngineerID)
|
||||
}
|
||||
|
||||
func afterSalesDispatchStatusLabel(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "assigned":
|
||||
return "assigned"
|
||||
case "suggested":
|
||||
return "suggested"
|
||||
default:
|
||||
return "unassigned"
|
||||
}
|
||||
}
|
||||
|
||||
func afterSalesNotifyStatusLabel(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "sent":
|
||||
return "sent"
|
||||
case "failed":
|
||||
return "failed"
|
||||
default:
|
||||
return "not_sent"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user