refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
@@ -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)}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user