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
+123 -151
View File
@@ -84,12 +84,15 @@ func (s *conversationService) Updates(id int64, columns map[string]interface{})
return repositories.ConversationRepository.Updates(sqls.DB(), id, columns)
}
func (s *conversationService) getLatestNotFinishedByCustomerID(db *gorm.DB, customerID int64) *models.Conversation {
if customerID <= 0 {
func (s *conversationService) getLatestNotFinishedByExternalUser(db *gorm.DB, externalUser openidentity.ExternalUser, channelID int64) *models.Conversation {
externalID := strings.TrimSpace(externalUser.ExternalID)
if externalID == "" || channelID <= 0 {
return nil
}
cnd := sqls.NewCnd()
cnd.Eq("customer_id", customerID)
cnd.Eq("channel_id", channelID)
cnd.Eq("customer_type", externalCustomerType(externalUser))
cnd.Eq("customer_external_id", externalID)
cnd.In("status", []enums.IMConversationStatus{
enums.IMConversationStatusAIServing,
enums.IMConversationStatusPending,
@@ -113,40 +116,67 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha
var conversation *models.Conversation
var welcomeMessage *models.Message
created := false
reconfigured := false
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
customerID, err := CustomerService.EnsureExternalCustomer(ctx, externalUser)
if err != nil {
return err
customerType := externalCustomerType(externalUser)
customerID := externalUser.SubjectID
customerName := strings.TrimSpace(externalUser.ExternalName)
existing := s.getLatestNotFinishedByExternalUser(ctx.Tx, externalUser, channelID)
// A conversation already being handled by a human keeps its original
// service contract. If the channel was switched to another Agent/mode,
// start a new conversation with the latest config instead of silently
// reusing the stale human conversation.
if existing != nil && (existing.CurrentAssigneeID > 0 || existing.HandoffAt != nil) &&
(existing.AIAgentID != aiAgentID || existing.ServiceMode != serviceMode) {
existing = nil
}
customerName := s.getCustomerName(ctx.Tx, customerID)
if existing := s.getLatestNotFinishedByCustomerID(ctx.Tx, customerID); existing != nil {
if existing != nil {
conversation = existing
updates := make(map[string]any)
if customerName != "" && existing.CustomerName != customerName {
if err := repositories.ConversationRepository.Updates(ctx.Tx, existing.ID, map[string]any{
"customer_name": customerName,
"updated_at": time.Now(),
}); err != nil {
updates["customer_name"] = customerName
conversation.CustomerName = customerName
}
// A channel binding may change after a conversation was created. Keep an
// unassigned conversation aligned with the latest channel/Agent config,
// while never taking a conversation away from a human or a handoff flow.
if existing.CurrentAssigneeID == 0 && existing.HandoffAt == nil &&
(existing.ChannelID != channelID || existing.AIAgentID != aiAgentID || existing.ServiceMode != serviceMode) {
updates["channel_id"] = channelID
updates["ai_agent_id"] = aiAgentID
updates["service_mode"] = serviceMode
updates["status"] = s.resolveInitialStatus(serviceMode)
conversation.ChannelID = channelID
conversation.AIAgentID = aiAgentID
conversation.ServiceMode = serviceMode
conversation.Status = s.resolveInitialStatus(serviceMode)
reconfigured = true
}
if len(updates) > 0 {
updates["updated_at"] = time.Now()
if err := repositories.ConversationRepository.Updates(ctx.Tx, existing.ID, updates); err != nil {
return err
}
conversation.CustomerName = customerName
}
return nil
}
created = true
now := time.Now()
conversation = &models.Conversation{
AIAgentID: aiAgentID,
ChannelID: channelID,
CustomerID: customerID,
CustomerName: customerName,
Status: s.resolveInitialStatus(serviceMode),
ServiceMode: serviceMode,
Priority: 0,
CurrentAssigneeID: 0,
CurrentTeamID: 0,
LastMessageAt: now,
LastActiveAt: now,
AuditFields: utils.BuildAuditFields(nil),
AIAgentID: aiAgentID,
ChannelID: channelID,
CustomerType: customerType,
CustomerID: customerID,
CustomerExternalID: strings.TrimSpace(externalUser.ExternalID),
CustomerName: customerName,
Status: s.resolveInitialStatus(serviceMode),
ServiceMode: serviceMode,
Priority: 0,
CurrentAssigneeID: 0,
CurrentTeamID: 0,
LastMessageAt: now,
LastActiveAt: now,
AuditFields: utils.BuildAuditFields(nil),
}
if err := ctx.Tx.Create(conversation).Error; err != nil {
return err
@@ -158,8 +188,9 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha
return err
}
if aiAgent != nil {
welcomeMessage, err = MessageService.createAIWelcomeMessage(ctx, conversation, aiAgent, now)
return err
var welcomeErr error
welcomeMessage, welcomeErr = MessageService.createAIWelcomeMessage(ctx, conversation, aiAgent, now)
return welcomeErr
}
return nil
}); err != nil {
@@ -169,7 +200,10 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha
return nil, errorsx.BusinessErrorI18n(1, "error.conversation.createFailed")
}
if !created {
return conversation, nil
if reconfigured {
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationUpdated)
}
return s.Get(conversation.ID), nil
}
// 推送会话创建事件
@@ -204,6 +238,7 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
return errorsx.InvalidParamI18n("error.e0276")
}
var assignedEvent events.ConversationAssignedEvent
var previousTeamID int64
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
conversation := repositories.ConversationRepository.Get(ctx.Tx, req.ConversationID)
if conversation == nil {
@@ -212,6 +247,7 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
if conversation.Status != enums.IMConversationStatusPending {
return errorsx.InvalidParamI18n("error.e0135")
}
previousTeamID = conversation.CurrentTeamID
now := time.Now()
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, req.ConversationID, now); err != nil {
return err
@@ -221,6 +257,7 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
}
if err := repositories.ConversationRepository.Updates(ctx.Tx, req.ConversationID, map[string]any{
"current_assignee_id": req.AssigneeID,
"current_team_id": targetProfile.TeamID,
"status": enums.IMConversationStatusActive,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
@@ -229,11 +266,12 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
return err
}
if err := ConversationEventLogService.CreateEvent(ctx, req.ConversationID, enums.IMEventTypeAssign, enums.IMSenderTypeAgent, operator.UserID, "会话已分配", s.buildEventPayload(map[string]any{
"fromStatus": conversation.Status,
"toStatus": enums.IMConversationStatusActive,
"fromAssigneeId": conversation.CurrentAssigneeID,
"toAssigneeId": req.AssigneeID,
"reason": strings.TrimSpace(req.Reason),
"from_status": conversation.Status,
"to_status": enums.IMConversationStatusActive,
"from_assignee_id": conversation.CurrentAssigneeID,
"to_assignee_id": req.AssigneeID,
"to_team_id": targetProfile.TeamID,
"reason": strings.TrimSpace(req.Reason),
})); err != nil {
return err
}
@@ -252,6 +290,7 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
if conversation := s.Get(req.ConversationID); conversation != nil {
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationAssigned)
}
ConversationQueueService.PublishPoolUpdates(previousTeamID)
eventbus.PublishAsync(context.Background(), assignedEvent)
return nil
}
@@ -272,15 +311,25 @@ func (s *conversationService) AutoAssignConversation(conversationID int64, opera
return errorsx.InvalidParamI18n("error.e0190")
}
aiAgent := AIAgentService.Get(conversation.AIAgentID)
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
return errorsx.InvalidParamI18n("error.e0003")
if conversation.AIAgentID > 0 {
aiAgent := AIAgentService.Get(conversation.AIAgentID)
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
return errorsx.InvalidParamI18n("error.e0003")
}
result, err := ConversationHumanDispatchService.DispatchPendingConversation(conversationID, *aiAgent)
if err != nil {
return err
}
if result == nil || result.Decision == HandoffDecisionOffHours {
return errorsx.InvalidParamI18n("error.e0194")
}
return nil
}
result, err := ConversationHumanDispatchService.DispatchPendingConversation(conversationID, *aiAgent)
result, err := ConversationDispatchService.DispatchConversation(conversationID)
if err != nil {
return err
}
if result == nil || result.Decision == HandoffDecisionOffHours {
if result == nil {
return errorsx.InvalidParamI18n("error.e0194")
}
return nil
@@ -332,11 +381,11 @@ func (s *conversationService) TransferConversation(conversationID, toUserID int6
return err
}
if err := ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAgent, operator.UserID, "会话已转接", s.buildEventPayload(map[string]any{
"fromStatus": conversation.Status,
"toStatus": enums.IMConversationStatusActive,
"fromAssigneeId": conversation.CurrentAssigneeID,
"toAssigneeId": toUserID,
"reason": strings.TrimSpace(reason),
"from_status": conversation.Status,
"to_status": enums.IMConversationStatusActive,
"from_assignee_id": conversation.CurrentAssigneeID,
"to_assignee_id": toUserID,
"reason": strings.TrimSpace(reason),
})); err != nil {
return err
}
@@ -416,6 +465,8 @@ func (s *conversationService) CloseCustomerConversation(conversationID int64, ex
}
func (s *conversationService) closeConversation(conversationID int64, senderType enums.IMSenderType, closeReason string, operator *dto.AuthPrincipal) error {
var queuedTeamID int64
var wasQueued bool
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
if conversation == nil {
@@ -429,6 +480,8 @@ func (s *conversationService) closeConversation(conversationID int64, senderType
conversation.Status != enums.IMConversationStatusActive {
return errorsx.InvalidParamI18n("error.e0197")
}
wasQueued = conversation.Status == enums.IMConversationStatusPending && conversation.CurrentAssigneeID == 0
queuedTeamID = conversation.CurrentTeamID
var (
now = time.Now()
eventDesc = "会话已关闭"
@@ -466,11 +519,11 @@ func (s *conversationService) closeConversation(conversationID int64, senderType
return err
}
return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeClose, senderType, operatorID, eventDesc, s.buildEventPayload(map[string]any{
"fromStatus": conversation.Status,
"toStatus": enums.IMConversationStatusClosed,
"fromAssigneeId": conversation.CurrentAssigneeID,
"toAssigneeId": conversation.CurrentAssigneeID,
"closeReason": closeReason,
"from_status": conversation.Status,
"to_status": enums.IMConversationStatusClosed,
"from_assignee_id": conversation.CurrentAssigneeID,
"to_assignee_id": conversation.CurrentAssigneeID,
"close_reason": closeReason,
}))
}); err != nil {
return err
@@ -478,6 +531,9 @@ func (s *conversationService) closeConversation(conversationID int64, senderType
if conversation := s.Get(conversationID); conversation != nil {
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationClosed)
}
if wasQueued {
ConversationQueueService.PublishPoolUpdates(queuedTeamID)
}
return nil
}
@@ -681,14 +737,11 @@ func (s *conversationService) IsCustomerConversationOwner(conversation *models.C
return false
}
extID := strings.TrimSpace(externalUser.ExternalID)
if extID == "" || strings.TrimSpace(string(externalUser.ExternalSource)) == "" || conversation.CustomerID <= 0 {
if extID == "" || strings.TrimSpace(string(externalUser.ExternalSource)) == "" {
return false
}
identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), externalUser.ExternalSource, extID)
if identity == nil {
return false
}
return identity.CustomerID == conversation.CustomerID
return conversation.CustomerType == externalCustomerType(externalUser) &&
strings.TrimSpace(conversation.CustomerExternalID) == extID
}
func (s *conversationService) BuildConversationSummary(conversation *models.Conversation) string {
@@ -701,14 +754,11 @@ func (s *conversationService) BuildConversationSummary(conversation *models.Conv
return strings.TrimSpace(conversation.CustomerName)
}
func (s *conversationService) getCustomerName(db *gorm.DB, customerID int64) string {
if customerID <= 0 {
return ""
func externalCustomerType(externalUser openidentity.ExternalUser) string {
if externalUser.SubjectType != "" {
return string(externalUser.SubjectType)
}
if customer := repositories.CustomerRepository.Get(db, customerID); customer != nil {
return strings.TrimSpace(customer.Name)
}
return ""
return string(externalUser.ExternalSource)
}
func (s *conversationService) canCloseConversation(conversation *models.Conversation, operator *dto.AuthPrincipal) bool {
@@ -751,101 +801,23 @@ func (s *conversationService) buildEventPayload(payload map[string]any) string {
return string(data)
}
// LinkConversationCustomer 将会话绑定到指定客户。
func (s *conversationService) LinkConversationCustomer(conversationID, customerID int64, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
if conversationID <= 0 || customerID <= 0 {
return errorsx.InvalidParamI18n("error.e0133")
}
cust := CustomerService.Get(customerID)
if cust == nil || cust.Status == enums.StatusDeleted {
return errorsx.InvalidParamI18n("error.e0155")
}
conv := s.Get(conversationID)
if conv == nil {
return errorsx.InvalidParamI18n("error.e0116")
}
if conv.Status == enums.IMConversationStatusClosed {
return errorsx.InvalidParamI18n("error.e0183")
}
if !s.canLinkConversationCustomer(conv, operator) {
return errorsx.ForbiddenI18n("error.e0224")
}
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
current := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
if current == nil {
return errorsx.InvalidParamI18n("error.e0116")
}
now := time.Now()
return repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{
"customer_id": customerID,
"customer_name": strings.TrimSpace(cust.Name),
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": now,
})
})
if err != nil {
return err
}
if updated := s.Get(conversationID); updated != nil {
WsService.PublishConversationChanged(updated, enums.IMRealtimeEventConversationUpdated)
}
return nil
}
func (s *conversationService) GetConversationExternalIdentity(conversation *models.Conversation) *models.CustomerIdentity {
if conversation == nil || conversation.CustomerID <= 0 {
func (s *conversationService) GetConversationExternalIdentity(conversation *models.Conversation) *openidentity.ExternalUser {
if conversation == nil || strings.TrimSpace(conversation.CustomerExternalID) == "" {
return nil
}
identities := repositories.CustomerIdentityRepository.FindByCustomerID(sqls.DB(), conversation.CustomerID)
if len(identities) == 0 {
return nil
external := &openidentity.ExternalUser{
ExternalID: strings.TrimSpace(conversation.CustomerExternalID),
ExternalName: strings.TrimSpace(conversation.CustomerName),
SubjectID: conversation.CustomerID,
}
if channel := ChannelService.Get(conversation.ChannelID); channel != nil {
expected := externalSourceForChannelType(channel.ChannelType)
if strings.TrimSpace(string(expected)) != "" {
for i := range identities {
if identities[i].ExternalSource == expected {
return &identities[i]
}
}
}
}
return &identities[0]
}
func externalSourceForChannelType(channelType string) enums.ExternalSource {
switch strings.TrimSpace(channelType) {
case enums.ChannelTypeWxWorkKF:
return enums.ExternalSourceWxWorkKF
case enums.ChannelTypeWeb:
return enums.ExternalSourceGuest
switch conversation.CustomerType {
case string(identity.SubjectCard), string(identity.SubjectDevice), string(identity.SubjectMallUser):
external.ExternalSource = enums.ExternalSourceUser
external.SubjectType = identity.SubjectType(conversation.CustomerType)
default:
return ""
}
}
func (s *conversationService) canLinkConversationCustomer(conv *models.Conversation, operator *dto.AuthPrincipal) bool {
if conv == nil || operator == nil {
return false
}
if s.isAdmin(operator) {
return true
}
switch conv.Status {
case enums.IMConversationStatusAIServing:
return true
case enums.IMConversationStatusPending:
return true
case enums.IMConversationStatusActive:
return conv.CurrentAssigneeID == 0 || conv.CurrentAssigneeID == operator.UserID
default:
return false
external.ExternalSource = enums.ExternalSource(conversation.CustomerType)
}
return external
}
func (s *conversationService) resolveInitialStatus(serviceMode enums.IMConversationServiceMode) enums.IMConversationStatus {