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