refactor: 将客服后端重构为宿主可嵌入模块

- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
t
2026-08-28 22:23:13 +08:00
parent 6845c728f8
commit 18c9354095
377 changed files with 13199 additions and 22881 deletions
@@ -11,49 +11,14 @@ import (
"code.tczkiot.com/wlw/ai-agent/internal/pkg/eventbus"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
"code.tczkiot.com/wlw/ai-agent/internal/services"
"github.com/mlogclub/simple/common/strs"
)
func init() {
eventbus.
Register[events.TicketAssignedEvent]().
Subscribe(handleTicketAssignedInAppNotification)
eventbus.
Register[events.ConversationAssignedEvent]().
Subscribe(handleConversationAssignedInAppNotification)
}
func handleTicketAssignedInAppNotification(ctx context.Context, event events.TicketAssignedEvent) error {
if event.TicketID <= 0 || event.ToUserID <= 0 {
return nil
}
ticket := services.TicketService.Get(event.TicketID)
if ticket == nil {
return nil
}
content := i18nx.Getf(i18nx.DefaultLocale, "notification.ticketAssigned.line", strs.DefaultIfBlank(ticket.TicketNo, fmt.Sprintf("#%d", ticket.ID)))
if title := strings.TrimSpace(ticket.Title); title != "" {
content = content + "\n" + title
}
if reason := strings.TrimSpace(event.Reason); reason != "" {
content = content + "\n" + i18nx.Getf(i18nx.DefaultLocale, "notification.ticketAssigned.reason", reason)
}
_, err := services.NotificationService.CreateAndPush(request.CreateNotificationRequest{
RecipientUserID: event.ToUserID,
Title: i18nx.Get("notification.ticketAssigned.title"),
Content: content,
NotificationType: "ticket_assigned",
BizType: "ticket",
BizID: ticket.ID,
ActionURL: fmt.Sprintf("/dashboard/tickets?ticketId=%d", ticket.ID),
})
if err != nil {
slog.Error("create ticket assigned in-app notification failed", "error", err, "ticketId", event.TicketID, "toUserId", event.ToUserID)
}
return nil
}
func handleConversationAssignedInAppNotification(ctx context.Context, event events.ConversationAssignedEvent) error {
if event.ConversationID <= 0 || event.ToUserID <= 0 {
return nil
@@ -80,7 +45,7 @@ func handleConversationAssignedInAppNotification(ctx context.Context, event even
NotificationType: "conversation_assigned",
BizType: "conversation",
BizID: conversation.ID,
ActionURL: fmt.Sprintf("/dashboard/conversations?conversationId=%d", conversation.ID),
ActionURL: fmt.Sprintf("/dashboard/conversations?conversation_id=%d", conversation.ID),
})
if err != nil {
slog.Error("create conversation assigned in-app notification failed", "error", err, "conversationId", event.ConversationID, "toUserId", event.ToUserID)
@@ -16,47 +16,6 @@ import (
"gorm.io/gorm/schema"
)
func TestTicketAssignedInAppNotification(t *testing.T) {
setupNotificationEventHandlerTestDB(t)
ticket := &models.Ticket{
TicketNo: "TK202604280001",
Title: "退款处理",
Source: enums.TicketSourceManual,
Status: enums.TicketStatusPending,
CurrentAssigneeID: 11,
AuditFields: models.AuditFields{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
if err := repositories.TicketRepository.Create(sqls.DB(), ticket); err != nil {
t.Fatalf("create ticket error = %v", err)
}
if err := handleTicketAssignedInAppNotification(context.Background(), events.TicketAssignedEvent{
TicketID: ticket.ID,
FromUserID: 0,
ToUserID: 11,
OperatorID: 1,
Reason: "需要人工跟进",
}); err != nil {
t.Fatalf("handler error = %v", err)
}
list := repositories.NotificationRepository.Find(sqls.DB(), sqls.NewCnd().Eq("recipient_user_id", 11))
if len(list) != 1 {
t.Fatalf("expected 1 notification, got %d", len(list))
}
got := list[0]
if got.NotificationType != "ticket_assigned" || got.BizType != "ticket" || got.BizID != ticket.ID {
t.Fatalf("unexpected notification: %+v", got)
}
if got.ActionURL != "/dashboard/tickets?ticketId=1" {
t.Fatalf("unexpected action url: %q", got.ActionURL)
}
}
func TestConversationAssignedInAppNotification(t *testing.T) {
setupNotificationEventHandlerTestDB(t)
@@ -92,7 +51,7 @@ func TestConversationAssignedInAppNotification(t *testing.T) {
if got.NotificationType != "conversation_assigned" || got.BizType != "conversation" || got.BizID != conversation.ID {
t.Fatalf("unexpected notification: %+v", got)
}
if got.ActionURL != "/dashboard/conversations?conversationId=1" {
if got.ActionURL != "/dashboard/conversations?conversation_id=1" {
t.Fatalf("unexpected action url: %q", got.ActionURL)
}
}
@@ -115,7 +74,7 @@ func setupNotificationEventHandlerTestDB(t *testing.T) *gorm.DB {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(&models.Notification{}, &models.Ticket{}, &models.Conversation{}); err != nil {
if err := db.AutoMigrate(&models.Notification{}, &models.Conversation{}); err != nil {
t.Fatalf("auto migrate error = %v", err)
}
sqls.SetDB(db)
@@ -1,51 +0,0 @@
package event_handlers
import (
"code.tczkiot.com/wlw/ai-agent/internal/events"
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/eventbus"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
"code.tczkiot.com/wlw/ai-agent/internal/services"
"context"
"fmt"
"strings"
"time"
"github.com/mlogclub/simple/common/strs"
)
func init() {
eventbus.
Register[events.TicketAssignedEvent]().
Subscribe(handleTicketAssignedNotify)
}
func handleTicketAssignedNotify(ctx context.Context, event events.TicketAssignedEvent) error {
if event.TicketID <= 0 || event.ToUserID <= 0 {
return nil
}
ticket := services.TicketService.Get(event.TicketID)
if ticket == nil {
return nil
}
content := buildTicketAssignedNotifyBody(ticket, event.ToUserID, event.Reason)
return services.WxWorkNotifyService.SendTextToAssigneeOrDefault(event.ToUserID, i18nx.Get("notification.ticketAssigned.title"), content)
}
func buildTicketAssignedNotifyBody(ticket *models.Ticket, assigneeID int64, reason string) string {
if ticket == nil {
return ""
}
lines := []string{
i18nx.Getf(i18nx.DefaultLocale, "notification.ticketAssigned.wxwork.no", strs.DefaultIfBlank(ticket.TicketNo, fmt.Sprintf("#%d", ticket.ID))),
i18nx.Getf(i18nx.DefaultLocale, "notification.ticketAssigned.wxwork.title", strs.DefaultIfBlank(ticket.Title, "-")),
i18nx.Getf(i18nx.DefaultLocale, "notification.ticketAssigned.wxwork.status", enums.GetTicketStatusLabel(ticket.Status)),
i18nx.Getf(i18nx.DefaultLocale, "notification.assignee", resolveNotifyUserLabel(assigneeID)),
}
if strings.TrimSpace(reason) != "" {
lines = append(lines, i18nx.Getf(i18nx.DefaultLocale, "notification.ticketAssigned.reason", strings.TrimSpace(reason)))
}
lines = append(lines, i18nx.Getf(i18nx.DefaultLocale, "notification.time", time.Now().Format("2006-01-02 15:04:05")))
return strings.Join(lines, "\n")
}
@@ -1,50 +0,0 @@
package event_handlers
import (
"code.tczkiot.com/wlw/ai-agent/internal/events"
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/eventbus"
"code.tczkiot.com/wlw/ai-agent/internal/services"
"context"
"fmt"
"strings"
"time"
"github.com/mlogclub/simple/common/strs"
)
func init() {
eventbus.
Register[events.TicketCreatedEvent]().
Subscribe(handleTicketCreatedNotify)
}
func handleTicketCreatedNotify(ctx context.Context, event events.TicketCreatedEvent) error {
if event.TicketID <= 0 {
return nil
}
ticket := services.TicketService.Get(event.TicketID)
if ticket == nil {
return nil
}
content := buildTicketCreatedNotifyBody(ticket)
return services.WxWorkNotifyService.SendTextToAssigneeOrDefault(ticket.CurrentAssigneeID, "工单创建提醒", content)
}
func buildTicketCreatedNotifyBody(ticket *models.Ticket) string {
if ticket == nil {
return ""
}
lines := []string{
fmt.Sprintf("工单号: %s", strs.DefaultIfBlank(ticket.TicketNo, fmt.Sprintf("#%d", ticket.ID))),
fmt.Sprintf("工单标题: %s", strs.DefaultIfBlank(ticket.Title, "-")),
fmt.Sprintf("工单来源: %s", strs.DefaultIfBlank(string(ticket.Source), "-")),
fmt.Sprintf("当前状态: %s", enums.GetTicketStatusLabel(ticket.Status)),
}
if ticket.CurrentAssigneeID > 0 {
lines = append(lines, fmt.Sprintf("处理人: %s", resolveNotifyUserLabel(ticket.CurrentAssigneeID)))
}
lines = append(lines, fmt.Sprintf("时间: %s", time.Now().Format("2006-01-02 15:04:05")))
return strings.Join(lines, "\n")
}