refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
"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/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/openidentity"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
)
|
||||
|
||||
var CustomerQuickActionService = &customerQuickActionService{}
|
||||
|
||||
type customerQuickActionService struct {
|
||||
mu sync.RWMutex
|
||||
actions map[string]contract.CustomerQuickAction
|
||||
}
|
||||
|
||||
func SetCustomerQuickActions(actions []contract.CustomerQuickAction) error {
|
||||
registered := make(map[string]contract.CustomerQuickAction, len(actions))
|
||||
for _, action := range actions {
|
||||
action.Code = strings.TrimSpace(action.Code)
|
||||
action.Title = strings.TrimSpace(action.Title)
|
||||
action.Description = strings.TrimSpace(action.Description)
|
||||
action.Message = strings.TrimSpace(action.Message)
|
||||
if action.Code == "" || action.Title == "" || action.Message == "" {
|
||||
return fmt.Errorf("ai-agent: customer quick action code, title and message are required")
|
||||
}
|
||||
if action.Execute == nil {
|
||||
return fmt.Errorf("ai-agent: customer quick action executor is required: %s", action.Code)
|
||||
}
|
||||
if _, exists := registered[action.Code]; exists {
|
||||
return fmt.Errorf("ai-agent: duplicate customer quick action code: %s", action.Code)
|
||||
}
|
||||
registered[action.Code] = action
|
||||
}
|
||||
|
||||
CustomerQuickActionService.mu.Lock()
|
||||
CustomerQuickActionService.actions = registered
|
||||
CustomerQuickActionService.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *customerQuickActionService) ListForConversation(ctx context.Context, conversation *models.Conversation) ([]contract.CustomerQuickAction, error) {
|
||||
if conversation == nil {
|
||||
return nil, nil
|
||||
}
|
||||
conversation = resolveQuickActionConversation(ctx, conversation)
|
||||
s.mu.RLock()
|
||||
actions := make([]contract.CustomerQuickAction, 0, len(s.actions))
|
||||
for _, action := range s.actions {
|
||||
if quickActionSupportsCustomerType(action, conversation.CustomerType) {
|
||||
actions = append(actions, action)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
businessContext := quickActionBusinessContext(ctx, conversation)
|
||||
ret := make([]contract.CustomerQuickAction, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
if action.Available != nil {
|
||||
available, err := action.Available(ctx, businessContext)
|
||||
if err != nil {
|
||||
slog.Warn("check customer quick action availability failed", "code", action.Code, "conversation_id", conversation.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if !available {
|
||||
continue
|
||||
}
|
||||
}
|
||||
ret = append(ret, action)
|
||||
}
|
||||
sort.Slice(ret, func(i, j int) bool {
|
||||
if ret[i].Sort == ret[j].Sort {
|
||||
return ret[i].Code < ret[j].Code
|
||||
}
|
||||
return ret[i].Sort < ret[j].Sort
|
||||
})
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *customerQuickActionService) ExecuteAndRecord(
|
||||
ctx context.Context,
|
||||
conversationID int64,
|
||||
code string,
|
||||
clientMsgID string,
|
||||
external openidentity.ExternalUser,
|
||||
requestID string,
|
||||
) (*models.Message, *models.Message, error) {
|
||||
conversation := ConversationService.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return nil, nil, errorsx.InvalidParamI18n("error.e0116")
|
||||
}
|
||||
if !ConversationService.IsCustomerConversationOwner(conversation, external) {
|
||||
return nil, nil, errorsx.ForbiddenI18n("error.e0222")
|
||||
}
|
||||
conversation = resolveQuickActionConversation(ctx, conversation)
|
||||
action, ok := s.resolve(code, conversation.CustomerType)
|
||||
if !ok {
|
||||
return nil, nil, errorsx.InvalidParam("customer quick action is unavailable")
|
||||
}
|
||||
businessContext := quickActionBusinessContext(ctx, conversation)
|
||||
if action.Available != nil {
|
||||
available, err := action.Available(ctx, businessContext)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !available {
|
||||
return nil, nil, errorsx.InvalidParam("customer quick action is currently unavailable")
|
||||
}
|
||||
}
|
||||
if action.TriggerAI {
|
||||
customerMessage, err := MessageService.SendCustomerMessageWithContextAndRequestID(ctx,
|
||||
conversation.ID, clientMsgID, enums.IMMessageTypeText, action.Message, "", external, requestID,
|
||||
)
|
||||
return customerMessage, nil, err
|
||||
}
|
||||
|
||||
reply, err := action.Execute(ctx, businessContext)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
reply = strings.TrimSpace(reply)
|
||||
if reply == "" {
|
||||
return nil, nil, errorsx.InvalidParam("customer quick action returned an empty reply")
|
||||
}
|
||||
|
||||
customerMessage, err := MessageService.SendCustomerMessageWithoutAIReplyWithContextAndRequestID(ctx,
|
||||
conversation.ID,
|
||||
clientMsgID,
|
||||
enums.IMMessageTypeText,
|
||||
action.Message,
|
||||
"",
|
||||
external,
|
||||
requestID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
replyClientMsgID := strs.UUID()
|
||||
if value := strings.TrimSpace(clientMsgID); value != "" {
|
||||
if len(value) > 96 {
|
||||
value = value[:96]
|
||||
}
|
||||
replyClientMsgID = value + "_auto_reply"
|
||||
}
|
||||
replyMessage, err := MessageService.SendAutomaticServiceMessageWithRequestID(
|
||||
conversation.ID,
|
||||
replyClientMsgID,
|
||||
reply,
|
||||
requestID,
|
||||
)
|
||||
if err != nil {
|
||||
return customerMessage, nil, err
|
||||
}
|
||||
return customerMessage, replyMessage, nil
|
||||
}
|
||||
|
||||
// ExecuteMatchedReply executes a deterministic quick action for an already
|
||||
// recorded customer message. It is used by the AI reply pipeline so explicit
|
||||
// read commands do not depend on a model deciding whether to call a tool.
|
||||
func (s *customerQuickActionService) ExecuteMatchedReply(
|
||||
ctx context.Context,
|
||||
conversation *models.Conversation,
|
||||
content string,
|
||||
requestID string,
|
||||
sourceMessageID int64,
|
||||
) (bool, error) {
|
||||
if conversation == nil || strings.TrimSpace(content) == "" {
|
||||
return false, nil
|
||||
}
|
||||
actions := s.matchingDeterministicActions(content, conversation.CustomerType)
|
||||
if len(actions) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return s.executeDeterministicReplies(ctx, conversation, actions, requestID, sourceMessageID)
|
||||
}
|
||||
|
||||
func (s *customerQuickActionService) ExecuteSelectedReply(
|
||||
ctx context.Context,
|
||||
conversation *models.Conversation,
|
||||
selection int,
|
||||
requestID string,
|
||||
sourceMessageID int64,
|
||||
) (matched bool, aiMessage string, err error) {
|
||||
if conversation == nil || selection <= 0 {
|
||||
return false, "", nil
|
||||
}
|
||||
actions, err := s.ListForConversation(ctx, conversation)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if selection > len(actions) {
|
||||
return false, "", nil
|
||||
}
|
||||
action := actions[selection-1]
|
||||
return s.executeActionReply(ctx, conversation, action, requestID, sourceMessageID)
|
||||
}
|
||||
|
||||
// ExecuteActionReply executes a registered quick action by code for an already
|
||||
// recorded customer message. It is used for deterministic conversational
|
||||
// choices whose display order is not the main quick-action menu order.
|
||||
func (s *customerQuickActionService) ExecuteActionReply(
|
||||
ctx context.Context,
|
||||
conversation *models.Conversation,
|
||||
code string,
|
||||
requestID string,
|
||||
sourceMessageID int64,
|
||||
) (matched bool, aiMessage string, err error) {
|
||||
if conversation == nil || strings.TrimSpace(code) == "" {
|
||||
return false, "", nil
|
||||
}
|
||||
actions, err := s.ListForConversation(ctx, conversation)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
for _, action := range actions {
|
||||
if action.Code == strings.TrimSpace(code) {
|
||||
return s.executeActionReply(ctx, conversation, action, requestID, sourceMessageID)
|
||||
}
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
func (s *customerQuickActionService) executeActionReply(
|
||||
ctx context.Context,
|
||||
conversation *models.Conversation,
|
||||
action contract.CustomerQuickAction,
|
||||
requestID string,
|
||||
sourceMessageID int64,
|
||||
) (matched bool, aiMessage string, err error) {
|
||||
if action.TriggerAI {
|
||||
return true, action.Message, nil
|
||||
}
|
||||
matched, err = s.executeDeterministicReplies(
|
||||
ctx, conversation, []contract.CustomerQuickAction{action}, requestID, sourceMessageID,
|
||||
)
|
||||
return matched, "", err
|
||||
}
|
||||
|
||||
func (s *customerQuickActionService) executeDeterministicReplies(
|
||||
ctx context.Context,
|
||||
conversation *models.Conversation,
|
||||
actions []contract.CustomerQuickAction,
|
||||
requestID string,
|
||||
sourceMessageID int64,
|
||||
) (bool, error) {
|
||||
businessContext := quickActionBusinessContext(ctx, conversation)
|
||||
replies := make([]string, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
if action.Available != nil {
|
||||
available, err := action.Available(ctx, businessContext)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if !available {
|
||||
continue
|
||||
}
|
||||
}
|
||||
reply, err := action.Execute(ctx, businessContext)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
reply = strings.TrimSpace(reply)
|
||||
if reply == "" {
|
||||
return true, errorsx.InvalidParam("customer quick action returned an empty reply")
|
||||
}
|
||||
replies = append(replies, reply)
|
||||
}
|
||||
if len(replies) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
clientMsgID := fmt.Sprintf("matched_action_%d", sourceMessageID)
|
||||
_, err := MessageService.SendAutomaticServiceMessageWithRequestID(
|
||||
conversation.ID,
|
||||
clientMsgID,
|
||||
strings.Join(replies, "\n\n"),
|
||||
requestID,
|
||||
)
|
||||
return true, err
|
||||
}
|
||||
|
||||
func (s *customerQuickActionService) matchingDeterministicActions(content, customerType string) []contract.CustomerQuickAction {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
matched := make([]contract.CustomerQuickAction, 0, 1)
|
||||
for _, action := range s.actions {
|
||||
if action.TriggerAI || action.MatchIntent == nil || !quickActionSupportsCustomerType(action, customerType) {
|
||||
continue
|
||||
}
|
||||
if action.MatchIntent(content) {
|
||||
matched = append(matched, action)
|
||||
}
|
||||
}
|
||||
// Free text must only bypass the Agent when it is one short, unambiguous
|
||||
// lookup. Negations, explanations and compound requests need conversational
|
||||
// reasoning; returning one or more keyword templates here would silently
|
||||
// discard the customer's actual intent. Menu selections and explicit action
|
||||
// codes use separate deterministic entry points and are unaffected.
|
||||
if len(matched) != 1 || !isHighConfidenceDeterministicQuickActionMessage(content) {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(matched, func(i, j int) bool {
|
||||
if matched[i].Sort == matched[j].Sort {
|
||||
return matched[i].Code < matched[j].Code
|
||||
}
|
||||
return matched[i].Sort < matched[j].Sort
|
||||
})
|
||||
return matched
|
||||
}
|
||||
|
||||
func isHighConfidenceDeterministicQuickActionMessage(content string) bool {
|
||||
text := strings.TrimSpace(content)
|
||||
if text == "" || len([]rune(text)) > 28 {
|
||||
return false
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"不是", "而是", "不要", "别", "搞错", "说错",
|
||||
"为什么", "怎么", "如何", "能否", "可以吗", "咨询", "原因",
|
||||
"另外", "还有", "顺便", "同时", "并且", "而且", "以及",
|
||||
"\n", ";", ";",
|
||||
} {
|
||||
if strings.Contains(text, marker) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func quickActionBusinessContext(ctx context.Context, conversation *models.Conversation) contract.BusinessReadContext {
|
||||
if conversation == nil {
|
||||
return contract.BusinessReadContext{}
|
||||
}
|
||||
businessContext := contract.BusinessReadContext{
|
||||
ConversationID: conversation.ID,
|
||||
CustomerType: conversation.CustomerType,
|
||||
CustomerID: conversation.CustomerID,
|
||||
CustomerExternalID: conversation.CustomerExternalID,
|
||||
CustomerName: conversation.CustomerName,
|
||||
}
|
||||
if proof, ok := contract.CustomerAccessProofFromContext(ctx); ok {
|
||||
businessContext.AccessProof = &proof
|
||||
businessContext.RequestMessageID = proof.MessageID
|
||||
businessContext.RequestID = proof.RequestID
|
||||
}
|
||||
return businessContext
|
||||
}
|
||||
|
||||
func (s *customerQuickActionService) resolve(code, customerType string) (contract.CustomerQuickAction, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
action, ok := s.actions[strings.TrimSpace(code)]
|
||||
if !ok || !quickActionSupportsCustomerType(action, customerType) {
|
||||
return contract.CustomerQuickAction{}, false
|
||||
}
|
||||
return action, true
|
||||
}
|
||||
|
||||
func quickActionSupportsCustomerType(action contract.CustomerQuickAction, customerType string) bool {
|
||||
if len(action.CustomerTypes) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range action.CustomerTypes {
|
||||
if strings.EqualFold(strings.TrimSpace(candidate), strings.TrimSpace(customerType)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user