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
+40 -13
View File
@@ -6,7 +6,7 @@ import (
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/response"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/httpx"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/openidentity"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
"encoding/json"
@@ -20,7 +20,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/mlogclub/simple/web"
)
var WsService = newWsService()
@@ -45,7 +44,7 @@ func newWsService() *wsService {
func (s *wsService) HandleDashboardWS(ctx *gin.Context) {
principal := AuthService.GetAuthPrincipal(ctx)
if principal == nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonErrorCode(errorsx.CodeAuthUnauthorized, i18nx.T(ctx, "error.auth.expired")))
httpx.AbortJSON(ctx, http.StatusUnauthorized, errorsx.UnauthorizedI18n("error.auth.expired"))
return
}
if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleAdmin); err != nil {
@@ -58,7 +57,7 @@ func (s *wsService) HandleDashboardWS(ctx *gin.Context) {
func (s *wsService) HandleDashboardNotificationWS(ctx *gin.Context) {
principal := AuthService.GetAuthPrincipal(ctx)
if principal == nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonErrorCode(errorsx.CodeAuthUnauthorized, i18nx.T(ctx, "error.auth.expired")))
httpx.AbortJSON(ctx, http.StatusUnauthorized, errorsx.UnauthorizedI18n("error.auth.expired"))
return
}
if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleNotification); err != nil {
@@ -71,7 +70,7 @@ func (s *wsService) HandleDashboardNotificationWS(ctx *gin.Context) {
func (s *wsService) HandleOpenWS(ctx *gin.Context) {
channel := ChannelService.GetEnabledChannel(ctx)
if channel == nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, web.JsonErrorCode(errorsx.CodeInvalidParam, i18nx.T(ctx, "error.e0209")))
httpx.AbortJSON(ctx, http.StatusBadRequest, errorsx.InvalidParamI18n("error.e0209"))
return
}
@@ -81,9 +80,13 @@ func (s *wsService) HandleOpenWS(ctx *gin.Context) {
)
if principal == nil {
var err error
external, err = SubjectService.CurrentExternal(ctx.Request.Context())
external, err = SubjectService.ResolveExternal(
ctx.Request.Context(),
strings.TrimSpace(ctx.Query("external_id")),
strings.TrimSpace(ctx.Query("external_name")),
)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err))
httpx.AbortJSON(ctx, http.StatusUnauthorized, err)
return
}
}
@@ -95,7 +98,7 @@ func (s *wsService) HandleOpenWS(ctx *gin.Context) {
}
func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalUser, role string) error {
conn, err := s.upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
conn, err := s.upgrader.Upgrade(ctx.Writer, ctx.Request, websocketUpgradeHeader(ctx.Request))
if err != nil {
return err
}
@@ -133,7 +136,7 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
"connId", session.ID,
"role", session.Role,
"userId", logUserID,
"externalId", logExternalID,
"external_id", logExternalID,
"terminalType", session.TerminalType,
"topicCount", len(session.Topics),
"sessionCount", sessionCount,
@@ -155,6 +158,20 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
return nil
}
// websocketUpgradeHeader echoes the bearer subprotocol selected by the host
// authentication middleware. Browsers reject an upgrade when they request a
// subprotocol and the server does not return the selected value.
func websocketUpgradeHeader(req *http.Request) http.Header {
for _, protocol := range websocket.Subprotocols(req) {
if strings.HasPrefix(strings.ToLower(protocol), "bearer.") {
header := make(http.Header)
header.Set("Sec-WebSocket-Protocol", protocol)
return header
}
}
return nil
}
func (s *wsService) readPump(session *ClientSession) {
defer s.closeSession(session)
@@ -259,7 +276,7 @@ func (s *wsService) closeSession(session *ClientSession) {
"connId", session.ID,
"role", session.Role,
"userId", discUserID,
"externalId", discExternalID,
"external_id", discExternalID,
"terminalType", session.TerminalType,
"sessionCount", remaining,
)
@@ -318,7 +335,6 @@ func (s *wsService) buildRealtimeMessage(item *models.Message) response.MessageR
ID: item.ID,
ConversationID: item.ConversationID,
RequestID: item.RequestID,
WorkflowRunID: item.WorkflowRunID,
ClientMsgID: item.ClientMsgID,
SenderType: item.SenderType,
SenderID: item.SenderID,
@@ -348,6 +364,7 @@ func (s *wsService) fillRealtimeMessageSender(ret *response.MessageResponse, ite
case enums.IMSenderTypeAI:
if aiAgent := AIAgentService.Get(item.SenderID); aiAgent != nil {
ret.SenderName = aiAgent.Name
ret.SenderAvatar = strings.TrimSpace(aiAgent.Avatar)
}
case enums.IMSenderTypeAgent:
if profile := AgentProfileService.GetByUserID(item.SenderID); profile != nil {
@@ -412,6 +429,7 @@ func (s *wsService) PublishConversationChanged(conversation *models.Conversation
return
}
agentReadState, customerReadState := ConversationReadStateService.GetConversationReadStates(conversation.ID)
queueSnapshot := ConversationQueueService.GetSnapshot(conversation)
event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeConversationChangedEvent{
Type: eventType,
@@ -431,6 +449,15 @@ func (s *wsService) PublishConversationChanged(conversation *models.Conversation
CustomerLastReadAt: readStateAt(customerReadState),
AgentLastReadMessageID: readStateMessageID(agentReadState),
AgentLastReadAt: readStateAt(agentReadState),
QueueEnteredAt: utils.FormatTimePtr(queueSnapshot.EnteredAt),
QueuePosition: queueSnapshot.Position,
QueueAheadCount: queueSnapshot.AheadCount,
QueueWaitingCount: queueSnapshot.WaitingCount,
QueueWaitSeconds: queueSnapshot.WaitSeconds,
QueueEstimatedWaitSeconds: queueSnapshot.EstimatedWaitSeconds,
QueueEscalationLevel: queueSnapshot.EscalationLevel,
EffectivePriority: queueSnapshot.EffectivePriority,
QueueServiceOnline: queueSnapshot.ServiceOnline,
},
})
s.PublishToTopics(s.routeConversationTopics(conversation), event)
@@ -502,7 +529,7 @@ func (s *wsService) PublishToTopics(topics []string, event RealtimeEvent) {
return
}
payload, err := json.Marshal(event)
payload, err := marshalRealtimeEvent(event)
if err != nil {
slog.Error("marshal realtime event failed", "error", err, "type", event.Type)
return
@@ -564,7 +591,7 @@ func (s *wsService) defaultTopics(session *ClientSession) []string {
}
return []string{s.adminTopic(session.Principal.UserID), realtimeTopicAdminAll}
default:
// 开放 IM:仅 External、无 AuthPrincipal 的访客连接必须仍能订阅 guest:{externalId},否则收不到推送。
// 开放 IM:仅 External、无 AuthPrincipal 的访客连接必须仍能订阅 guest:{external_id},否则收不到推送。
if session.External != nil && strings.TrimSpace(session.External.ExternalID) != "" {
return []string{s.guestTopic(session.External.ExternalID)}
}