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
-2
View File
@@ -17,7 +17,6 @@ func BuildAgentRun(item *models.AgentRun) response.AgentRunResponse {
AIAgentID: item.AIAgentID,
AgentRevisionID: item.AgentRevisionID,
SourceMessageID: item.SourceMessageID,
WorkflowRunID: item.WorkflowRunID,
Status: item.Status,
PromptTokens: item.PromptTokens,
CompletionTokens: item.CompletionTokens,
@@ -69,7 +68,6 @@ func BuildAgentStep(item *models.AgentStep) response.AgentStepResponse {
return response.AgentStepResponse{
ID: item.ID,
AgentRunID: item.AgentRunID,
WorkflowRunID: item.WorkflowRunID,
StepType: item.StepType,
StepCode: item.StepCode,
Status: item.Status,
-232
View File
@@ -1,232 +0,0 @@
package builders
import (
"encoding/json"
"time"
"code.tczkiot.com/wlw/ai-agent/internal/ai/workflow/dsl"
workflowregistry "code.tczkiot.com/wlw/ai-agent/internal/ai/workflow/registry"
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/response"
"code.tczkiot.com/wlw/ai-agent/internal/services"
)
func BuildAIWorkflow(item *models.AIWorkflow) response.AIWorkflowResponse {
if item == nil {
return response.AIWorkflowResponse{}
}
return response.AIWorkflowResponse{
ID: item.ID,
Name: item.Name,
Description: item.Description,
Status: item.Status,
DraftDefinition: parseWorkflowDefinition(item.DraftDefinition),
PublishedVersionID: item.PublishedVersionID,
SortNo: item.SortNo,
CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: item.UpdatedAt.Format("2006-01-02 15:04:05"),
CreateUserName: item.CreateUserName,
UpdateUserName: item.UpdateUserName,
}
}
func BuildAIWorkflowList(list []models.AIWorkflow) []response.AIWorkflowResponse {
ret := make([]response.AIWorkflowResponse, 0, len(list))
for i := range list {
ret = append(ret, BuildAIWorkflow(&list[i]))
}
return ret
}
func BuildAIWorkflowVersion(item *models.AIWorkflowVersion) response.AIWorkflowVersionResponse {
if item == nil {
return response.AIWorkflowVersionResponse{}
}
publishedAt := ""
if item.PublishedAt != nil {
publishedAt = item.PublishedAt.Format("2006-01-02 15:04:05")
}
return response.AIWorkflowVersionResponse{
ID: item.ID,
WorkflowID: item.WorkflowID,
Version: item.Version,
Status: item.Status,
Definition: parseWorkflowDefinition(item.Definition),
DefinitionHash: item.DefinitionHash,
PublishedAt: publishedAt,
PublishedByID: item.PublishedByID,
PublishedByName: item.PublishedByName,
CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: item.UpdatedAt.Format("2006-01-02 15:04:05"),
}
}
func BuildAIWorkflowVersionList(list []models.AIWorkflowVersion) []response.AIWorkflowVersionResponse {
ret := make([]response.AIWorkflowVersionResponse, 0, len(list))
for i := range list {
ret = append(ret, BuildAIWorkflowVersion(&list[i]))
}
return ret
}
func BuildAIWorkflowNodeSpecs(list []workflowregistry.NodeSpec) []response.AIWorkflowNodeSpecResponse {
ret := make([]response.AIWorkflowNodeSpecResponse, 0, len(list))
for _, item := range list {
ret = append(ret, response.AIWorkflowNodeSpecResponse{
Type: item.Type,
Title: item.Title,
Description: item.Description,
Icon: item.Icon,
Category: item.Category,
Executable: item.Executable,
RiskLevel: item.RiskLevel,
Interruptible: item.Interruptible,
RequiresConfirmationPredecessor: item.RequiresConfirmationPredecessor,
ConfigSchema: item.ConfigSchema,
InputSchema: item.InputSchema,
OutputSchema: item.OutputSchema,
DefaultInputs: item.DefaultInputs,
})
}
return ret
}
func BuildAIWorkflowTemplates(list []services.AIWorkflowTemplate) []response.AIWorkflowTemplateResponse {
ret := make([]response.AIWorkflowTemplateResponse, 0, len(list))
for _, item := range list {
ret = append(ret, response.AIWorkflowTemplateResponse{Code: item.Code, Name: item.Name, Description: item.Description, Definition: item.Definition})
}
return ret
}
func BuildAIWorkflowRun(item *models.AIWorkflowRun) response.AIWorkflowRunResponse {
return BuildAIWorkflowRunWithContext(item, nil, nil, nil)
}
func BuildAIWorkflowRunWithContext(item *models.AIWorkflowRun, workflow *models.AIWorkflow, version *models.AIWorkflowVersion, agent *models.AIAgent) response.AIWorkflowRunResponse {
if item == nil {
return response.AIWorkflowRunResponse{}
}
ret := response.AIWorkflowRunResponse{
ID: item.ID,
WorkflowID: item.WorkflowID,
WorkflowVersionID: item.WorkflowVersionID,
ConversationID: item.ConversationID,
AIAgentID: item.AIAgentID,
MessageID: item.MessageID,
Status: item.Status,
StatusName: workflowRunStatusName(item.Status),
StartedAt: formatWorkflowTime(item.StartedAt),
EndedAt: formatWorkflowTimePtr(item.EndedAt),
DurationMS: workflowRunDurationMS(item.StartedAt, item.EndedAt),
InterruptType: item.InterruptType,
InterruptNodeID: item.InterruptNodeID,
ErrorMessage: item.ErrorMessage,
CreatedAt: formatWorkflowTime(item.CreatedAt),
UpdatedAt: formatWorkflowTime(item.UpdatedAt),
}
if workflow != nil {
ret.WorkflowName = workflow.Name
}
if version != nil {
ret.WorkflowVersion = version.Version
}
if agent != nil {
ret.AIAgentName = agent.Name
}
return ret
}
func BuildAIWorkflowRunDetail(item *models.AIWorkflowRun, nodes []models.AIWorkflowNodeRun) response.AIWorkflowRunResponse {
ret := BuildAIWorkflowRun(item)
ret.Nodes = BuildAIWorkflowNodeRunList(nodes)
return ret
}
func BuildAIWorkflowRunDetailWithContext(item *models.AIWorkflowRun, nodes []models.AIWorkflowNodeRun, workflow *models.AIWorkflow, version *models.AIWorkflowVersion, agent *models.AIAgent) response.AIWorkflowRunResponse {
ret := BuildAIWorkflowRunWithContext(item, workflow, version, agent)
if version != nil {
ret.Definition = parseWorkflowDefinition(version.Definition)
}
ret.Nodes = BuildAIWorkflowNodeRunList(nodes)
return ret
}
func BuildAIWorkflowRunList(list []models.AIWorkflowRun) []response.AIWorkflowRunResponse {
ret := make([]response.AIWorkflowRunResponse, 0, len(list))
for i := range list {
ret = append(ret, BuildAIWorkflowRun(&list[i]))
}
return ret
}
func BuildAIWorkflowNodeRun(item *models.AIWorkflowNodeRun) response.AIWorkflowNodeRunResponse {
if item == nil {
return response.AIWorkflowNodeRunResponse{}
}
return response.AIWorkflowNodeRunResponse{
ID: item.ID,
WorkflowRunID: item.WorkflowRunID,
NodeID: item.NodeID,
NodeType: item.NodeType,
Status: item.Status,
StatusName: workflowRunStatusName(item.Status),
InputPreview: item.InputPreview,
OutputPreview: item.OutputPreview,
ErrorMessage: item.ErrorMessage,
StartedAt: formatWorkflowTime(item.StartedAt),
EndedAt: formatWorkflowTimePtr(item.EndedAt),
DurationMS: item.DurationMS,
}
}
func BuildAIWorkflowNodeRunList(list []models.AIWorkflowNodeRun) []response.AIWorkflowNodeRunResponse {
ret := make([]response.AIWorkflowNodeRunResponse, 0, len(list))
for i := range list {
ret = append(ret, BuildAIWorkflowNodeRun(&list[i]))
}
return ret
}
func parseWorkflowDefinition(raw string) dsl.Definition {
var ret dsl.Definition
if raw == "" {
return ret
}
_ = json.Unmarshal([]byte(raw), &ret)
return ret
}
func workflowRunStatusName(status int) string {
switch status {
case 1:
return "completed"
case 2:
return "interrupted"
case 3:
return "failed"
default:
return "unknown"
}
}
func formatWorkflowTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.Format("2006-01-02 15:04:05")
}
func formatWorkflowTimePtr(value *time.Time) string {
if value == nil {
return ""
}
return formatWorkflowTime(*value)
}
func workflowRunDurationMS(startedAt time.Time, endedAt *time.Time) int64 {
if startedAt.IsZero() || endedAt == nil || endedAt.IsZero() {
return 0
}
return endedAt.Sub(startedAt).Milliseconds()
}
@@ -1,161 +0,0 @@
package builders
import (
"encoding/json"
"testing"
"time"
"code.tczkiot.com/wlw/ai-agent/internal/ai/workflow/dsl"
workflowregistry "code.tczkiot.com/wlw/ai-agent/internal/ai/workflow/registry"
"code.tczkiot.com/wlw/ai-agent/internal/models"
)
func TestBuildAIWorkflowNodeSpecsIncludesVariableContracts(t *testing.T) {
specs := BuildAIWorkflowNodeSpecs(workflowregistry.DefaultRegistry().List())
var startFound bool
var sendReplyFound bool
for _, spec := range specs {
switch spec.Type {
case workflowregistry.NodeTypeStart:
startFound = true
if spec.Icon != "PlayCircleIcon" {
t.Fatalf("expected start icon PlayCircleIcon, got %q", spec.Icon)
}
if !hasResponseVariable(spec.OutputSchema, "userMessage") {
t.Fatalf("expected start output userMessage, got %#v", spec.OutputSchema)
}
case workflowregistry.NodeTypeSendReply:
sendReplyFound = true
if !hasResponseVariable(spec.InputSchema, "replyText") {
t.Fatalf("expected send_reply input replyText, got %#v", spec.InputSchema)
}
}
}
if !startFound || !sendReplyFound {
t.Fatalf("expected start and send_reply specs in response")
}
}
func TestBuildAIWorkflowNodeSpecsIncludesConditionValueOptions(t *testing.T) {
specs := BuildAIWorkflowNodeSpecs(workflowregistry.DefaultRegistry().List())
var action *workflowregistry.VariableSpec
for _, spec := range specs {
if spec.Type != workflowregistry.NodeTypeReplyPolicy {
continue
}
for index := range spec.OutputSchema {
if spec.OutputSchema[index].Name == "action" {
action = &spec.OutputSchema[index]
break
}
}
}
if action == nil {
t.Fatalf("expected reply_policy action output")
}
if action.Label != "处理策略" {
t.Fatalf("expected user-facing action label, got %q", action.Label)
}
if !hasResponseVariableOption(action.ValueOptions, "direct_reply", "直接回复客户") {
t.Fatalf("expected direct_reply option with business label, got %#v", action.ValueOptions)
}
if !hasResponseOperator(action.Operators, "eq") || !hasResponseOperator(action.Operators, "neq") {
t.Fatalf("expected action to constrain condition operators, got %#v", action.Operators)
}
}
func TestBuildAIWorkflowRunIncludesAuditDisplayFields(t *testing.T) {
startedAt := time.Date(2026, 6, 23, 10, 0, 0, 0, time.UTC)
endedAt := startedAt.Add(1500 * time.Millisecond)
resp := BuildAIWorkflowRunWithContext(
&models.AIWorkflowRun{
ID: 9,
WorkflowID: 11,
WorkflowVersionID: 22,
AIAgentID: 33,
StartedAt: startedAt,
EndedAt: &endedAt,
Status: 1,
},
&models.AIWorkflow{Name: "售后会话流程"},
&models.AIWorkflowVersion{Version: 3},
&models.AIAgent{Name: "售后 Agent"},
)
if resp.WorkflowName != "售后会话流程" {
t.Fatalf("expected workflow name, got %q", resp.WorkflowName)
}
if resp.WorkflowVersion != 3 {
t.Fatalf("expected workflow version 3, got %d", resp.WorkflowVersion)
}
if resp.AIAgentName != "售后 Agent" {
t.Fatalf("expected agent name, got %q", resp.AIAgentName)
}
if resp.DurationMS != 1500 {
t.Fatalf("expected duration 1500ms, got %d", resp.DurationMS)
}
}
func TestBuildAIWorkflowRunDetailIncludesPublishedDefinitionSnapshot(t *testing.T) {
definition := dsl.Definition{
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Data: dsl.NodeData{Title: "开始"}},
{ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Data: dsl.NodeData{Title: "运行时回复"}},
},
Edges: []dsl.Edge{{SourceNodeID: "start_1", TargetNodeID: "reply_1", SourcePortID: "edge_start_reply"}},
}
buf, err := json.Marshal(definition)
if err != nil {
t.Fatalf("marshal definition: %v", err)
}
resp := BuildAIWorkflowRunDetailWithContext(
&models.AIWorkflowRun{ID: 9, WorkflowVersionID: 22, Status: 1, StartedAt: time.Now()},
nil,
&models.AIWorkflow{Name: "当前 Workflow 草稿不应参与审计图"},
&models.AIWorkflowVersion{Version: 3, Definition: string(buf)},
&models.AIAgent{Name: "售后 Agent"},
)
if resp.Definition.SchemaVersion != dsl.SchemaVersion {
t.Fatalf("expected run detail definition from published version, got %#v", resp.Definition)
}
if len(resp.Definition.Nodes) != 2 || resp.Definition.Nodes[1].Data.Title != "运行时回复" {
t.Fatalf("expected published definition nodes, got %#v", resp.Definition.Nodes)
}
if len(resp.Definition.Edges) != 1 || resp.Definition.Edges[0].SourcePortID != "edge_start_reply" {
t.Fatalf("expected published definition edges, got %#v", resp.Definition.Edges)
}
}
func hasResponseVariable(items []workflowregistry.VariableSpec, name string) bool {
for _, item := range items {
if item.Name == name {
return true
}
}
return false
}
func hasResponseVariableOption(items []workflowregistry.VariableValueOption, value any, label string) bool {
for _, item := range items {
if item.Value == value && item.Label == label {
return true
}
}
return false
}
func hasResponseOperator(items []string, value string) bool {
for _, item := range items {
if item == value {
return true
}
}
return false
}
-32
View File
@@ -1,32 +0,0 @@
package builders
import (
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/response"
"time"
)
func BuildCompany(item *models.Company) *response.CompanyResponse {
if item == nil {
return nil
}
return &response.CompanyResponse{
ID: item.ID,
Name: item.Name,
Code: item.Code,
Status: item.Status,
Remark: item.Remark,
CreatedAt: item.CreatedAt.Format(time.DateTime),
UpdatedAt: item.UpdatedAt.Format(time.DateTime),
}
}
func BuildCompanyList(list []models.Company) []response.CompanyResponse {
results := make([]response.CompanyResponse, 0, len(list))
for _, item := range list {
if company := BuildCompany(&item); company != nil {
results = append(results, *company)
}
}
return results
}
+30 -13
View File
@@ -23,7 +23,9 @@ func BuildConversationWithLocale(item *models.Conversation, locale string) respo
ID: item.ID,
AIAgentID: item.AIAgentID,
ChannelID: item.ChannelID,
CustomerType: item.CustomerType,
CustomerID: item.CustomerID,
CustomerExternalID: item.CustomerExternalID,
CustomerName: item.CustomerName,
Status: item.Status,
ServiceMode: item.ServiceMode,
@@ -47,6 +49,18 @@ func BuildConversationWithLocale(item *models.Conversation, locale string) respo
if identity := services.ConversationService.GetConversationExternalIdentity(item); identity != nil {
ret.CustomerOnline = services.WsService.IsGuestOnline(identity.ExternalID)
}
queueSnapshot := services.ConversationQueueService.GetSnapshot(item)
if queueSnapshot.Queued {
ret.QueueEnteredAt = utils.FormatTimePtr(queueSnapshot.EnteredAt)
ret.QueuePosition = queueSnapshot.Position
ret.QueueAheadCount = queueSnapshot.AheadCount
ret.QueueWaitingCount = queueSnapshot.WaitingCount
ret.QueueWaitSeconds = queueSnapshot.WaitSeconds
ret.QueueEstimatedWaitSeconds = queueSnapshot.EstimatedWaitSeconds
ret.QueueEscalationLevel = queueSnapshot.EscalationLevel
ret.EffectivePriority = queueSnapshot.EffectivePriority
ret.QueueServiceOnline = queueSnapshot.ServiceOnline
}
if item.CurrentAssigneeID > 0 {
if user := services.UserService.Get(item.CurrentAssigneeID); user != nil {
ret.CurrentAssigneeName = user.Nickname
@@ -120,11 +134,11 @@ func BuildMessagesWithLocale(list []models.Message, locale string) []response.Me
return nil
}
agentReadState, customerReadState := services.ConversationReadStateService.GetConversationReadStates(list[0].ConversationID)
aiSenderNames, userSenderNames := collectMessageSenderNameMaps(list)
aiSenders, userSenderNames := collectMessageSenderMaps(list)
agentProfiles := collectAgentProfilesByMessages(list)
ret := make([]response.MessageResponse, 0, len(list))
for i := range list {
ret = append(ret, BuildMessageWithReadStatesAndLocale(&list[i], agentReadState, customerReadState, aiSenderNames, userSenderNames, agentProfiles, locale))
ret = append(ret, BuildMessageWithReadStatesAndLocale(&list[i], agentReadState, customerReadState, aiSenders, userSenderNames, agentProfiles, locale))
}
return ret
}
@@ -138,17 +152,16 @@ func BuildMessageWithLocale(item *models.Message, locale string) response.Messag
return BuildMessageWithReadStatesAndLocale(item, agentReadState, customerReadState, nil, nil, nil, locale)
}
func BuildMessageWithReadStates(item *models.Message, agentReadState, customerReadState *models.ConversationReadState, aiSenderNames, userSenderNames map[int64]string, agentProfiles map[int64]*models.AgentProfile) response.MessageResponse {
return BuildMessageWithReadStatesAndLocale(item, agentReadState, customerReadState, aiSenderNames, userSenderNames, agentProfiles, i18nx.DefaultLocale)
func BuildMessageWithReadStates(item *models.Message, agentReadState, customerReadState *models.ConversationReadState, aiSenders map[int64]*models.AIAgent, userSenderNames map[int64]string, agentProfiles map[int64]*models.AgentProfile) response.MessageResponse {
return BuildMessageWithReadStatesAndLocale(item, agentReadState, customerReadState, aiSenders, userSenderNames, agentProfiles, i18nx.DefaultLocale)
}
func BuildMessageWithReadStatesAndLocale(item *models.Message, agentReadState, customerReadState *models.ConversationReadState, aiSenderNames, userSenderNames map[int64]string, agentProfiles map[int64]*models.AgentProfile, locale string) response.MessageResponse {
func BuildMessageWithReadStatesAndLocale(item *models.Message, agentReadState, customerReadState *models.ConversationReadState, aiSenders map[int64]*models.AIAgent, userSenderNames map[int64]string, agentProfiles map[int64]*models.AgentProfile, locale string) response.MessageResponse {
content, payload := utils.BuildRenderableMessage(item)
ret := response.MessageResponse{
ID: item.ID,
ConversationID: item.ConversationID,
RequestID: item.RequestID,
WorkflowRunID: item.WorkflowRunID,
ClientMsgID: item.ClientMsgID,
SenderType: item.SenderType,
SenderID: item.SenderID,
@@ -168,10 +181,13 @@ func BuildMessageWithReadStatesAndLocale(item *models.Message, agentReadState, c
}
if item.SenderID > 0 {
if item.SenderType == enums.IMSenderTypeAI {
if aiSenderNames != nil {
ret.SenderName = aiSenderNames[item.SenderID]
} else if aiAgent := services.AIAgentService.Get(item.SenderID); aiAgent != nil {
aiAgent := aiSenders[item.SenderID]
if aiAgent == nil {
aiAgent = services.AIAgentService.Get(item.SenderID)
}
if aiAgent != nil {
ret.SenderName = aiAgent.Name
ret.SenderAvatar = strings.TrimSpace(aiAgent.Avatar)
}
} else if item.SenderType == enums.IMSenderTypeAgent {
profile := agentProfiles[item.SenderID]
@@ -240,8 +256,8 @@ func collectAgentProfilesByMessages(list []models.Message) map[int64]*models.Age
return out
}
func collectMessageSenderNameMaps(list []models.Message) (aiNames map[int64]string, userNames map[int64]string) {
aiNames = make(map[int64]string)
func collectMessageSenderMaps(list []models.Message) (aiSenders map[int64]*models.AIAgent, userNames map[int64]string) {
aiSenders = make(map[int64]*models.AIAgent)
userNames = make(map[int64]string)
var aiIDs, userIDs []int64
seenAI := make(map[int64]struct{})
@@ -266,7 +282,8 @@ func collectMessageSenderNameMaps(list []models.Message) (aiNames map[int64]stri
userIDs = append(userIDs, m.SenderID)
}
for _, a := range services.AIAgentService.FindByIds(aiIDs) {
aiNames[a.ID] = a.Name
agent := a
aiSenders[a.ID] = &agent
}
for _, u := range services.UserService.FindByIds(userIDs) {
name := u.Nickname
@@ -275,7 +292,7 @@ func collectMessageSenderNameMaps(list []models.Message) (aiNames map[int64]stri
}
userNames[u.ID] = name
}
return aiNames, userNames
return aiSenders, userNames
}
func isMessageRead(item *models.Message, state *models.ConversationReadState) bool {
+21 -15
View File
@@ -102,21 +102,6 @@ func TestLocalizeRenderableMessageContent(t *testing.T) {
}
}
func TestBuildMessageIncludesWorkflowRunID(t *testing.T) {
resp := BuildMessageWithReadStatesAndLocale(&models.Message{
ID: 1,
ConversationID: 2,
SenderType: enums.IMSenderTypeAI,
MessageType: enums.IMMessageTypeText,
Content: "AI reply",
WorkflowRunID: 9988,
}, nil, nil, nil, nil, nil, i18nx.DefaultLocale)
if resp.WorkflowRunID != 9988 {
t.Fatalf("resp.WorkflowRunID=%d want 9988", resp.WorkflowRunID)
}
}
func TestBuildMessageJSONDoesNotExposeSeqNo(t *testing.T) {
resp := BuildMessageWithReadStatesAndLocale(&models.Message{
ID: 1,
@@ -134,3 +119,24 @@ func TestBuildMessageJSONDoesNotExposeSeqNo(t *testing.T) {
t.Fatalf("message response should not expose seqNo, got %s", raw)
}
}
func TestBuildAIMessageIncludesAgentAvatar(t *testing.T) {
const avatar = "https://cdn.example.com/ai-agent.png"
resp := BuildMessageWithReadStatesAndLocale(&models.Message{
ID: 3,
ConversationID: 2,
SenderType: enums.IMSenderTypeAI,
SenderID: 9,
MessageType: enums.IMMessageTypeText,
Content: "hello",
}, nil, nil, map[int64]*models.AIAgent{
9: {ID: 9, Name: "AI 客服", Avatar: avatar},
}, nil, nil, i18nx.DefaultLocale)
if resp.SenderName != "AI 客服" {
t.Fatalf("sender name = %q, want AI 客服", resp.SenderName)
}
if resp.SenderAvatar != avatar {
t.Fatalf("sender avatar = %q, want %q", resp.SenderAvatar, avatar)
}
}
-39
View File
@@ -1,39 +0,0 @@
package builders
import (
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/response"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
"code.tczkiot.com/wlw/ai-agent/internal/services"
"time"
)
func BuildCustomer(item *models.Customer) *response.CustomerResponse {
if item == nil {
return nil
}
return &response.CustomerResponse{
ID: item.ID,
Name: item.Name,
Gender: item.Gender,
CompanyID: item.CompanyID,
Company: BuildCompany(services.CompanyService.Get(item.CompanyID)),
LastActiveAt: utils.FormatTimePtr(item.LastActiveAt),
PrimaryMobile: item.PrimaryMobile,
PrimaryEmail: item.PrimaryEmail,
Status: item.Status,
Remark: item.Remark,
CreatedAt: item.CreatedAt.Format(time.DateTime),
UpdatedAt: item.UpdatedAt.Format(time.DateTime),
}
}
func BuildCustomerList(list []models.Customer) []response.CustomerResponse {
results := make([]response.CustomerResponse, 0, len(list))
for _, item := range list {
if customer := BuildCustomer(&item); customer != nil {
results = append(results, *customer)
}
}
return results
}
@@ -1,36 +0,0 @@
package builders
import (
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/response"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
"time"
)
func BuildCustomerContactResponse(item *models.CustomerContact) response.CustomerContactResponse {
if item == nil {
return response.CustomerContactResponse{}
}
return response.CustomerContactResponse{
ID: item.ID,
CustomerID: item.CustomerID,
ContactType: item.ContactType,
ContactValue: item.ContactValue,
IsPrimary: item.IsPrimary,
IsVerified: item.IsVerified,
VerifiedAt: utils.FormatTimePtr(item.VerifiedAt),
Source: item.Source,
Status: item.Status,
Remark: item.Remark,
CreatedAt: item.CreatedAt.Format(time.DateTime),
UpdatedAt: item.UpdatedAt.Format(time.DateTime),
}
}
func BuildCustomerContactList(list []models.CustomerContact) []response.CustomerContactResponse {
results := make([]response.CustomerContactResponse, 0, len(list))
for i := range list {
results = append(results, BuildCustomerContactResponse(&list[i]))
}
return results
}
-21
View File
@@ -10,7 +10,6 @@ import (
)
var (
ticketAssignedNotificationPattern = regexp.MustCompile(`^工单 (.+) 已指派给你$`)
conversationAssignedNotificationPattern = regexp.MustCompile(`^会话 #([0-9]+) 已分配给你$`)
)
@@ -58,8 +57,6 @@ func localizeNotificationText(item *models.Notification, locale string) (string,
return title, content
}
switch strings.TrimSpace(item.NotificationType) {
case "ticket_assigned":
return localizeTicketAssignedNotification(title, content)
case "conversation_assigned":
return localizeConversationAssignedNotification(title, content)
default:
@@ -67,22 +64,6 @@ func localizeNotificationText(item *models.Notification, locale string) (string,
}
}
func localizeTicketAssignedNotification(title string, content string) (string, string) {
lines := splitNotificationLines(content)
if len(lines) == 0 {
return localizeNotificationTitle(title), content
}
if matches := ticketAssignedNotificationPattern.FindStringSubmatch(lines[0]); len(matches) == 2 {
lines[0] = i18nx.Getf(i18nx.LocaleEnUS, "notification.ticketAssigned.line", matches[1])
}
for i, line := range lines[1:] {
if reason, ok := strings.CutPrefix(line, "指派原因: "); ok {
lines[i+1] = i18nx.Getf(i18nx.LocaleEnUS, "notification.ticketAssigned.reason", reason)
}
}
return localizeNotificationTitle(title), strings.Join(lines, "\n")
}
func localizeConversationAssignedNotification(title string, content string) (string, string) {
lines := splitNotificationLines(content)
if len(lines) == 0 {
@@ -105,8 +86,6 @@ func localizeConversationAssignedNotification(title string, content string) (str
func localizeNotificationTitle(title string) string {
switch strings.TrimSpace(title) {
case "工单指派提醒":
return i18nx.Getf(i18nx.LocaleEnUS, "notification.ticketAssigned.title")
case "会话转接提醒":
return i18nx.Getf(i18nx.LocaleEnUS, "notification.conversationTransferred.title")
case "会话自动分配提醒":
@@ -4,7 +4,6 @@ import (
"testing"
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
)
func TestBuildNotificationListReturnsEmptySlice(t *testing.T) {
@@ -17,22 +16,3 @@ func TestBuildNotificationListReturnsEmptySlice(t *testing.T) {
t.Fatalf("expected empty slice, got %d items", len(results))
}
}
func TestBuildNotificationLocalizesKnownSystemNotification(t *testing.T) {
result := BuildNotificationWithLocale(&models.Notification{
Title: "工单指派提醒",
Content: "工单 TK-100 已指派给你\n无法登录后台\n指派原因: 优先处理",
NotificationType: "ticket_assigned",
}, i18nx.LocaleEnUS)
if result == nil {
t.Fatalf("expected notification response")
}
if result.Title != "Ticket assigned" {
t.Fatalf("title = %q", result.Title)
}
wantContent := "Ticket TK-100 has been assigned to you.\n无法登录后台\nAssignment reason: 优先处理"
if result.Content != wantContent {
t.Fatalf("content = %q, want %q", result.Content, wantContent)
}
}
-42
View File
@@ -1,42 +0,0 @@
package builders
import (
"encoding/json"
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/response"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
)
func BuildSkillDefinitionResponse(item *models.SkillDefinition) response.SkillDefinitionResponse {
examples := make([]string, 0)
if raw := item.Examples; raw != "" {
_ = json.Unmarshal([]byte(raw), &examples)
}
toolWhitelist := make([]string, 0)
if raw := item.ToolWhitelist; raw != "" {
_ = json.Unmarshal([]byte(raw), &toolWhitelist)
}
return response.SkillDefinitionResponse{
ID: item.ID,
Name: item.Name,
Description: item.Description,
Instruction: item.Instruction,
Examples: examples,
ToolWhitelist: toolWhitelist,
Status: int(item.Status),
StatusName: getSkillStatusName(item.Status),
Remark: item.Remark,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
CreateUserName: item.CreateUserName,
UpdateUserName: item.UpdateUserName,
}
}
func getSkillStatusName(status enums.Status) string {
if label := enums.GetStatusLabel(status); label != "" {
return label
}
return "未知"
}
-75
View File
@@ -1,75 +0,0 @@
package builders
import (
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/response"
"time"
)
func BuildTagResponse(item *models.Tag) response.TagResponse {
if item == nil {
return response.TagResponse{}
}
return response.TagResponse{
ID: item.ID,
ParentID: item.ParentID,
Name: item.Name,
Remark: item.Remark,
SortNo: item.SortNo,
Status: item.Status,
CreatedAt: item.CreatedAt.Format(time.DateTime),
UpdatedAt: item.UpdatedAt.Format(time.DateTime),
}
}
func BuildTagResponses(list []models.Tag) []response.TagResponse {
if len(list) == 0 {
return nil
}
results := make([]response.TagResponse, 0, len(list))
for i := range list {
results = append(results, BuildTagResponse(&list[i]))
}
return results
}
func BuildTagTreeResponses(list []models.Tag) []*response.TagTreeResponse {
if len(list) == 0 {
return nil
}
nodeMap := make(map[int64]*response.TagTreeResponse, len(list))
roots := make([]*response.TagTreeResponse, 0)
for i := range list {
item := &list[i]
nodeMap[item.ID] = &response.TagTreeResponse{
ID: item.ID,
ParentID: item.ParentID,
Name: item.Name,
Remark: item.Remark,
SortNo: item.SortNo,
Status: item.Status,
CreatedAt: item.CreatedAt.Format(time.DateTime),
UpdatedAt: item.UpdatedAt.Format(time.DateTime),
Children: make([]*response.TagTreeResponse, 0),
}
}
for i := range list {
item := &list[i]
node := nodeMap[item.ID]
if item.ParentID == 0 {
roots = append(roots, node)
continue
}
parent, ok := nodeMap[item.ParentID]
if !ok {
roots = append(roots, node)
continue
}
parent.Children = append(parent.Children, node)
}
return roots
}
-191
View File
@@ -1,191 +0,0 @@
package builders
import (
"encoding/json"
"strings"
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/response"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
"code.tczkiot.com/wlw/ai-agent/internal/services"
)
type TicketBuildContext struct {
TagsByTicketID map[int64][]models.Tag
Users map[int64]*services.ExternalUser
Customers map[int64]*models.Customer
}
type TicketDetailBuildContext struct {
Users map[int64]*services.ExternalUser
}
func BuildTicket(item *models.Ticket) *response.TicketResponse {
return BuildTicketWithContext(item, nil)
}
func BuildTicketWithContext(item *models.Ticket, ctx *TicketBuildContext) *response.TicketResponse {
if item == nil {
return nil
}
ret := &response.TicketResponse{
ID: item.ID,
TicketNo: item.TicketNo,
Title: item.Title,
Description: item.Description,
Source: item.Source,
Channel: item.Channel,
CustomerID: item.CustomerID,
ConversationID: item.ConversationID,
Status: item.Status,
CurrentAssigneeID: item.CurrentAssigneeID,
CreatedBy: item.CreateUserID,
CreatedByName: item.CreateUserName,
HandledAt: utils.FormatTimePtr(item.HandledAt),
CreatedAt: utils.FormatTime(item.CreatedAt),
UpdatedAt: utils.FormatTime(item.UpdatedAt),
}
if ctx != nil && ctx.TagsByTicketID != nil {
ret.Tags = BuildTagResponses(ctx.TagsByTicketID[item.ID])
}
if item.CurrentAssigneeID > 0 {
if ctx != nil && ctx.Users != nil {
ret.CurrentAssigneeName = buildTicketUserDisplayName(ctx.Users[item.CurrentAssigneeID])
}
}
if item.CustomerID > 0 {
if ctx != nil && ctx.Customers != nil {
ret.Customer = BuildCustomer(ctx.Customers[item.CustomerID])
}
}
return ret
}
func BuildTicketList(list []models.Ticket) []response.TicketResponse {
return BuildTicketListWithContext(list, nil)
}
func BuildTicketListWithContext(list []models.Ticket, ctx *TicketBuildContext) []response.TicketResponse {
if len(list) == 0 {
return nil
}
results := make([]response.TicketResponse, 0, len(list))
for i := range list {
if item := BuildTicketWithContext(&list[i], ctx); item != nil {
results = append(results, *item)
}
}
return results
}
func BuildTicketProgress(item *models.TicketProgress) *response.TicketProgressResponse {
return BuildTicketProgressWithContext(item, nil)
}
func BuildTicketProgressWithContext(item *models.TicketProgress, ctx *TicketDetailBuildContext) *response.TicketProgressResponse {
if item == nil {
return nil
}
ret := &response.TicketProgressResponse{
ID: item.ID,
TicketID: item.TicketID,
Content: item.Content,
AuthorID: item.AuthorID,
CreatedAt: utils.FormatTime(item.CreatedAt),
}
if item.AuthorID > 0 {
if ctx != nil && ctx.Users != nil {
ret.AuthorName = buildTicketUserDisplayName(ctx.Users[item.AuthorID])
}
}
return ret
}
func BuildTicketProgressList(list []models.TicketProgress) []response.TicketProgressResponse {
return BuildTicketProgressListWithContext(list, nil)
}
func BuildTicketProgressListWithContext(list []models.TicketProgress, ctx *TicketDetailBuildContext) []response.TicketProgressResponse {
if len(list) == 0 {
return nil
}
results := make([]response.TicketProgressResponse, 0, len(list))
for i := range list {
if item := BuildTicketProgressWithContext(&list[i], ctx); item != nil {
results = append(results, *item)
}
}
return results
}
func BuildTicketDetail(aggregate *services.TicketDetailAggregate) *response.TicketDetailResponse {
if aggregate == nil || aggregate.Ticket == nil {
return nil
}
ctx := &TicketBuildContext{
TagsByTicketID: map[int64][]models.Tag{aggregate.Ticket.ID: aggregate.Tags},
Users: aggregate.Users,
Customers: map[int64]*models.Customer{},
}
if aggregate.Customer != nil {
ctx.Customers[aggregate.Customer.ID] = aggregate.Customer
}
ret := &response.TicketDetailResponse{
Ticket: *BuildTicketWithContext(aggregate.Ticket, ctx),
}
ret.Progresses = BuildTicketProgressListWithContext(aggregate.Progresses, &TicketDetailBuildContext{Users: aggregate.Users})
return ret
}
func BuildTicketSummary(summary *services.TicketSummaryAggregate) *response.TicketSummaryResponse {
if summary == nil {
return nil
}
return &response.TicketSummaryResponse{
All: summary.All,
Pending: summary.Pending,
InProgress: summary.InProgress,
Done: summary.Done,
Unassigned: summary.Unassigned,
Mine: summary.Mine,
Stale: summary.Stale,
}
}
func BuildTicketView(item *models.TicketView) *response.TicketViewResponse {
if item == nil {
return nil
}
ret := &response.TicketViewResponse{
ID: item.ID,
Name: item.Name,
SortNo: item.SortNo,
}
if strings.TrimSpace(item.FiltersJSON) != "" {
_ = json.Unmarshal([]byte(item.FiltersJSON), &ret.Filters)
}
return ret
}
func BuildTicketViewList(list []models.TicketView) []response.TicketViewResponse {
if len(list) == 0 {
return nil
}
results := make([]response.TicketViewResponse, 0, len(list))
for i := range list {
if item := BuildTicketView(&list[i]); item != nil {
results = append(results, *item)
}
}
return results
}
func buildTicketUserDisplayName(user *services.ExternalUser) string {
if user == nil {
return ""
}
if user.Nickname != "" {
return user.Nickname
}
return user.Username
}
-123
View File
@@ -1,123 +0,0 @@
package builders
import (
"testing"
"time"
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
"code.tczkiot.com/wlw/ai-agent/internal/services"
)
func TestBuildLightweightTicket(t *testing.T) {
now := time.Date(2026, 5, 2, 12, 30, 0, 0, time.Local)
ticket := &models.Ticket{
ID: 12,
TicketNo: "TK202605020001",
Title: "登录失败",
Description: "客户反馈无法登录",
Source: enums.TicketSourceManual,
Channel: "web",
CustomerID: 3,
ConversationID: 4,
Status: enums.TicketStatusPending,
CurrentAssigneeID: 5,
AuditFields: models.AuditFields{
CreateUserID: 1,
CreateUserName: "admin",
CreatedAt: now,
UpdatedAt: now,
},
}
ctx := &TicketBuildContext{
TagsByTicketID: map[int64][]models.Tag{
12: {{ID: 8, Name: "登录", Status: enums.StatusOk}},
},
Users: map[int64]*services.ExternalUser{
5: {ID: 5, Username: "agent", Nickname: "客服"},
},
Customers: map[int64]*models.Customer{
3: {ID: 3, Name: "客户"},
},
}
out := BuildTicketWithContext(ticket, ctx)
if out == nil {
t.Fatalf("expected ticket response")
}
if out.ID != ticket.ID || out.TicketNo != ticket.TicketNo || out.Status != ticket.Status {
t.Fatalf("unexpected ticket response: %+v", out)
}
if out.CurrentAssigneeName != "客服" {
t.Fatalf("expected assignee name, got %q", out.CurrentAssigneeName)
}
if len(out.Tags) != 1 || out.Tags[0].ID != 8 {
t.Fatalf("expected tag response, got %+v", out.Tags)
}
if out.Customer == nil || out.Customer.ID != 3 {
t.Fatalf("expected customer response, got %+v", out.Customer)
}
}
func TestBuildTicketWithoutContextLeavesOptionalLookupsEmpty(t *testing.T) {
now := time.Date(2026, 5, 2, 12, 30, 0, 0, time.Local)
ticket := &models.Ticket{
ID: 12,
TicketNo: "TK202605020001",
Title: "登录失败",
Description: "客户反馈无法登录",
CustomerID: 3,
CurrentAssigneeID: 5,
Status: enums.TicketStatusPending,
AuditFields: models.AuditFields{
CreateUserID: 1,
CreateUserName: "admin",
CreatedAt: now,
UpdatedAt: now,
},
}
out := BuildTicket(ticket)
if out == nil {
t.Fatalf("expected ticket response")
}
if out.Tags != nil {
t.Fatalf("expected tags to stay empty without context, got %+v", out.Tags)
}
if out.Customer != nil {
t.Fatalf("expected customer to stay empty without context, got %+v", out.Customer)
}
if out.CurrentAssigneeName != "" {
t.Fatalf("expected assignee name to stay empty without context, got %q", out.CurrentAssigneeName)
}
}
func TestBuildTicketProgress(t *testing.T) {
now := time.Date(2026, 5, 2, 12, 30, 0, 0, time.Local)
progress := &models.TicketProgress{
ID: 1,
TicketID: 2,
Content: "已联系客户",
AuthorID: 3,
CreatedAt: now,
}
ctx := &TicketDetailBuildContext{
Users: map[int64]*services.ExternalUser{
3: {ID: 3, Username: "agent", Nickname: "客服"},
},
}
out := BuildTicketProgressWithContext(progress, ctx)
if out == nil {
t.Fatalf("expected progress response")
}
if out.ID != progress.ID || out.TicketID != progress.TicketID || out.Content != progress.Content {
t.Fatalf("unexpected progress response: %+v", out)
}
if out.AuthorName != "客服" {
t.Fatalf("expected author name, got %q", out.AuthorName)
}
if out.CreatedAt == "" {
t.Fatalf("expected createdAt to be formatted")
}
}