chore: import clean Agent Desk snapshot (2/7)

This commit is contained in:
2026-10-16 11:57:21 +00:00
parent 79176541c7
commit 89c5231c4c
461 changed files with 60092 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/services"
)
func BuildAgentProfileList(items []models.AgentProfile) []response.AgentProfileResponse {
if len(items) == 0 {
return []response.AgentProfileResponse{}
}
userIDs := make([]int64, 0, len(items))
teamIDs := make([]int64, 0, len(items))
for _, item := range items {
if item.UserID > 0 {
userIDs = append(userIDs, item.UserID)
}
if item.TeamID > 0 {
teamIDs = append(teamIDs, item.TeamID)
}
}
users := services.UserService.FindByIds(userIDs)
teams := services.AgentTeamService.FindByIds(teamIDs)
userMap := make(map[int64]*models.User, len(users))
for i := range users {
userMap[users[i].ID] = &users[i]
}
teamMap := make(map[int64]*models.AgentTeam, len(teams))
for i := range teams {
teamMap[teams[i].ID] = &teams[i]
}
results := make([]response.AgentProfileResponse, 0, len(items))
for _, item := range items {
if result := doBuildAgentProfileResponse(&item, userMap[item.UserID], teamMap[item.TeamID]); result != nil {
results = append(results, *result)
}
}
return results
}
func BuildAgentProfileResponse(item *models.AgentProfile) *response.AgentProfileResponse {
user := services.UserService.Get(item.UserID)
team := services.AgentTeamService.Get(item.TeamID)
return doBuildAgentProfileResponse(item, user, team)
}
func doBuildAgentProfileResponse(item *models.AgentProfile, user *models.User, team *models.AgentTeam) *response.AgentProfileResponse {
if item == nil {
return nil
}
ret := &response.AgentProfileResponse{
ID: item.ID,
UserID: item.UserID,
TeamID: item.TeamID,
AgentCode: item.AgentCode,
DisplayName: item.DisplayName,
Avatar: item.Avatar,
ServiceStatus: item.ServiceStatus,
MaxConcurrentCount: item.MaxConcurrentCount,
PriorityLevel: item.PriorityLevel,
AutoAssignEnabled: item.AutoAssignEnabled,
ReceiveOfflineMessage: item.ReceiveOfflineMessage,
LastOnlineAt: utils.FormatTimePtr(item.LastOnlineAt),
LastStatusAt: utils.FormatTimePtr(item.LastStatusAt),
Remark: item.Remark,
}
if user != nil {
ret.Username = user.Username
ret.Nickname = user.Nickname
}
if team != nil {
ret.TeamName = team.Name
}
return ret
}

View File

@@ -0,0 +1,29 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
)
func BuildAgentRevision(item *models.AgentRevision) response.AgentRevisionResponse {
if item == nil {
return response.AgentRevisionResponse{}
}
publishedAt := ""
if item.PublishedAt != nil {
publishedAt = item.PublishedAt.Format("2006-01-02 15:04:05")
}
return response.AgentRevisionResponse{
ID: item.ID, AgentID: item.AgentID, Revision: item.Revision,
Status: item.Status, DefinitionHash: item.DefinitionHash, PublishedAt: publishedAt,
PublishedByID: item.PublishedByID, PublishedByName: item.PublishedByName,
}
}
func BuildAgentRevisionList(items []models.AgentRevision) []response.AgentRevisionResponse {
ret := make([]response.AgentRevisionResponse, 0, len(items))
for i := range items {
ret = append(ret, BuildAgentRevision(&items[i]))
}
return ret
}

View File

@@ -0,0 +1,144 @@
package builders
import (
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
)
func BuildAgentRun(item *models.AgentRun) response.AgentRunResponse {
if item == nil {
return response.AgentRunResponse{}
}
return response.AgentRunResponse{
ID: item.ID,
ConversationID: item.ConversationID,
AIAgentID: item.AIAgentID,
AgentRevisionID: item.AgentRevisionID,
SourceMessageID: item.SourceMessageID,
WorkflowRunID: item.WorkflowRunID,
Status: item.Status,
PromptTokens: item.PromptTokens,
CompletionTokens: item.CompletionTokens,
StartedAt: formatAgentRunTime(item.StartedAt),
EndedAt: formatAgentRunTimePtr(item.EndedAt),
DurationMS: agentRunDurationMS(item.StartedAt, item.EndedAt),
ErrorMessage: item.ErrorMessage,
TraceData: item.TraceData,
CreatedAt: formatAgentRunTime(item.CreatedAt),
UpdatedAt: formatAgentRunTime(item.UpdatedAt),
}
}
func BuildAgentRunDetail(item *models.AgentRun, steps []models.AgentStep, toolCalls []models.AgentToolCall, feedback *models.AgentRunQualityFeedback) response.AgentRunResponse {
ret := BuildAgentRun(item)
ret.Steps = BuildAgentStepList(steps)
ret.ToolCalls = BuildAgentToolCallList(toolCalls)
ret.QualityFeedback = BuildAgentRunQualityFeedback(feedback)
return ret
}
func BuildAgentRunQualityFeedback(item *models.AgentRunQualityFeedback) *response.AgentRunQualityFeedbackResponse {
if item == nil {
return nil
}
return &response.AgentRunQualityFeedbackResponse{
ID: item.ID,
AgentRunID: item.AgentRunID,
ResolutionStatus: item.ResolutionStatus,
EvidenceStatus: item.EvidenceStatus,
Comment: item.Comment,
UpdateUserName: item.UpdateUserName,
UpdatedAt: formatAgentRunTime(item.UpdatedAt),
}
}
func BuildAgentRunList(list []models.AgentRun) []response.AgentRunResponse {
ret := make([]response.AgentRunResponse, 0, len(list))
for i := range list {
ret = append(ret, BuildAgentRun(&list[i]))
}
return ret
}
func BuildAgentStep(item *models.AgentStep) response.AgentStepResponse {
if item == nil {
return response.AgentStepResponse{}
}
return response.AgentStepResponse{
ID: item.ID,
AgentRunID: item.AgentRunID,
WorkflowRunID: item.WorkflowRunID,
StepType: item.StepType,
StepCode: item.StepCode,
Status: item.Status,
InputPreview: item.InputPreview,
OutputPreview: item.OutputPreview,
ErrorMessage: item.ErrorMessage,
StartedAt: formatAgentRunTime(item.StartedAt),
EndedAt: formatAgentRunTimePtr(item.EndedAt),
DurationMS: item.DurationMS,
}
}
func BuildAgentStepList(list []models.AgentStep) []response.AgentStepResponse {
ret := make([]response.AgentStepResponse, 0, len(list))
for i := range list {
ret = append(ret, BuildAgentStep(&list[i]))
}
return ret
}
func BuildAgentToolCall(item *models.AgentToolCall) response.AgentToolCallResponse {
if item == nil {
return response.AgentToolCallResponse{}
}
return response.AgentToolCallResponse{
ID: item.ID,
AgentRunID: item.AgentRunID,
AgentStepID: item.AgentStepID,
ToolCode: item.ToolCode,
RiskLevel: item.RiskLevel,
RequireConfirm: item.RequireConfirm,
Status: item.Status,
ArgumentsPreview: item.ArgumentsPreview,
ResultPreview: item.ResultPreview,
ErrorMessage: item.ErrorMessage,
DurationMS: item.DurationMS,
CreatedAt: formatAgentRunTime(item.CreatedAt),
}
}
func BuildAgentToolCallList(list []models.AgentToolCall) []response.AgentToolCallResponse {
ret := make([]response.AgentToolCallResponse, 0, len(list))
for i := range list {
ret = append(ret, BuildAgentToolCall(&list[i]))
}
return ret
}
func formatAgentRunTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.Format("2006-01-02 15:04:05")
}
func formatAgentRunTimePtr(value *time.Time) string {
if value == nil {
return ""
}
return formatAgentRunTime(*value)
}
func agentRunDurationMS(startedAt time.Time, endedAt *time.Time) int64 {
if startedAt.IsZero() || endedAt == nil || endedAt.IsZero() {
return 0
}
duration := endedAt.Sub(startedAt).Milliseconds()
if duration < 0 {
return 0
}
return duration
}

View File

@@ -0,0 +1,39 @@
package builders
import (
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/services"
"time"
)
func BuildAgentTeamScheduleBatchPreviewResponse(result *services.AgentTeamScheduleBatchPreviewResult) *response.AgentTeamScheduleBatchPreviewResponse {
if result == nil {
return nil
}
items := make([]response.AgentTeamScheduleBatchPreviewItem, 0, len(result.Items))
for _, item := range result.Items {
items = append(items, response.AgentTeamScheduleBatchPreviewItem{
TeamID: item.TeamID,
TeamName: item.TeamName,
Date: item.Date.Format(time.DateOnly),
Weekday: item.Weekday,
StartAt: item.StartAt.Format(time.DateTime),
EndAt: item.EndAt.Format(time.DateTime),
Remark: item.Remark,
Conflict: item.Conflict,
ConflictReason: item.ConflictReason,
})
}
return &response.AgentTeamScheduleBatchPreviewResponse{
Total: result.Total,
Conflict: result.Conflict,
Items: items,
}
}
func BuildAgentTeamScheduleBatchGenerateResponse(result *services.AgentTeamScheduleBatchGenerateResult) *response.AgentTeamScheduleBatchGenerateResponse {
if result == nil {
return nil
}
return &response.AgentTeamScheduleBatchGenerateResponse{Created: result.Created}
}

View File

@@ -0,0 +1,232 @@
package builders
import (
"encoding/json"
"time"
"agent-desk/internal/ai/workflow/dsl"
workflowregistry "agent-desk/internal/ai/workflow/registry"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/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()
}

View File

@@ -0,0 +1,161 @@
package builders
import (
"encoding/json"
"testing"
"time"
"agent-desk/internal/ai/workflow/dsl"
workflowregistry "agent-desk/internal/ai/workflow/registry"
"agent-desk/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
}

View File

@@ -0,0 +1,35 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/services/storage"
"log/slog"
)
func BuildAsset(item *models.Asset) response.AssetResponse {
ret := response.AssetResponse{
ID: item.ID,
AssetID: item.AssetID,
Provider: item.Provider,
Filename: item.Filename,
FileSize: item.FileSize,
MimeType: item.MimeType,
StorageKey: item.StorageKey,
Status: item.Status,
CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: item.UpdatedAt.Format("2006-01-02 15:04:05"),
CreateUserID: item.CreateUserID,
CreateUserName: item.CreateUserName,
UpdateUserID: item.UpdateUserID,
UpdateUserName: item.UpdateUserName,
}
if provider, err := storage.GetProvider(item.Provider); err != nil {
slog.Error("get storage provider failed", "provider", item.Provider, "error", err)
} else {
ret.URL = provider.GetSignedURL(item.StorageKey)
}
return ret
}

View File

@@ -0,0 +1,32 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/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
}

View File

@@ -0,0 +1,339 @@
package builders
import (
"strings"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/services"
"github.com/mlogclub/simple/sqls"
)
func BuildConversation(item *models.Conversation) response.ConversationResponse {
return BuildConversationWithLocale(item, i18nx.DefaultLocale)
}
func BuildConversationWithLocale(item *models.Conversation, locale string) response.ConversationResponse {
agentReadState, customerReadState := services.ConversationReadStateService.GetConversationReadStates(item.ID)
ret := response.ConversationResponse{
ID: item.ID,
AIAgentID: item.AIAgentID,
ChannelID: item.ChannelID,
CustomerID: item.CustomerID,
CustomerName: item.CustomerName,
Subject: item.Subject,
Status: item.Status,
ServiceMode: item.ServiceMode,
Priority: item.Priority,
CurrentAssigneeID: item.CurrentAssigneeID,
CurrentTeamID: item.CurrentTeamID,
LastMessageID: item.LastMessageID,
LastMessageAt: utils.FormatTime(item.LastMessageAt),
LastActiveAt: utils.FormatTime(item.LastActiveAt),
LastMessageSummary: localizeConversationSummary(locale, item.LastMessageSummary),
CustomerUnreadCount: item.CustomerUnreadCount,
AgentUnreadCount: item.AgentUnreadCount,
CustomerLastReadMessageID: readStateMessageID(customerReadState),
CustomerLastReadAt: readStateAt(customerReadState),
AgentLastReadMessageID: readStateMessageID(agentReadState),
AgentLastReadAt: readStateAt(agentReadState),
ClosedAt: utils.FormatTimePtr(item.ClosedAt),
ClosedBy: item.ClosedBy,
CloseReason: item.CloseReason,
}
if identity := services.ConversationService.GetConversationExternalIdentity(item); identity != nil {
ret.CustomerOnline = services.WsService.IsGuestOnline(identity.ExternalID)
}
if item.CurrentAssigneeID > 0 {
if user := services.UserService.Get(item.CurrentAssigneeID); user != nil {
ret.CurrentAssigneeName = user.Nickname
if ret.CurrentAssigneeName == "" {
ret.CurrentAssigneeName = user.Username
}
}
}
if item.CurrentTeamID > 0 {
if team := services.AgentTeamService.Get(item.CurrentTeamID); team != nil {
ret.CurrentTeamName = team.Name
}
}
if item.ClosedBy > 0 {
if user := services.UserService.Get(item.ClosedBy); user != nil {
ret.ClosedByName = user.Nickname
if ret.ClosedByName == "" {
ret.ClosedByName = user.Username
}
}
}
if sat := services.ConversationSatisfactionService.GetByConversationID(item.ID); sat != nil {
ret.Satisfaction = BuildSatisfactionResponse(sat)
}
return ret
}
func BuildSatisfactionResponse(item *models.ConversationSatisfaction) *response.ConversationSatisfactionResponse {
if item == nil {
return nil
}
return &response.ConversationSatisfactionResponse{
ID: item.ID,
ConversationID: item.ConversationID,
Score: item.Score,
Feedback: item.Feedback,
CreatedAt: utils.FormatTime(item.CreatedAt),
}
}
// conversationSummaryTokens are the markers BuildRuntimeMessageText and
// BuildHTMLSummary write into conversation.last_message_summary.
//
// They are persisted, not rendered on the fly, so the stored value is Chinese
// regardless of who is reading it. Rather than migrate every existing row, the
// stored token is recognised here and swapped for the reader's locale. Storing
// localized text in the database would make the column meaningless as soon as
// a second locale existed.
var conversationSummaryTokens = []struct {
stored string
messageKey string
}{
{stored: "[图片]", messageKey: "conversation.summary.image"},
{stored: "[附件]", messageKey: "conversation.summary.attachment"},
{stored: "该消息已撤回", messageKey: "conversation.summary.recalled"},
}
// localizeConversationSummary translates the persisted Chinese markers for the
// reader's locale. A summary is a user-typed message more often than not, so
// anything unrecognised is returned untouched rather than guessed at.
func localizeConversationSummary(locale string, summary string) string {
summary = strings.TrimSpace(summary)
if summary == "" {
return ""
}
normalized := i18nx.NormalizeLocale(locale)
for _, token := range conversationSummaryTokens {
translated := i18nx.Getf(normalized, token.messageKey)
switch {
case summary == token.stored:
return translated
case strings.HasPrefix(summary, token.stored+" "):
return translated + " " + strings.TrimPrefix(summary, token.stored+" ")
}
}
return summary
}
func BuildParticipantResponses(conversationID int64) []response.ConversationParticipantResponse {
list := services.ConversationParticipantService.Find(sqls.NewCnd().Eq("conversation_id", conversationID).Asc("id"))
if len(list) == 0 {
return nil
}
ret := make([]response.ConversationParticipantResponse, 0, len(list))
for _, item := range list {
ret = append(ret, response.ConversationParticipantResponse{
ID: item.ID,
ParticipantType: item.ParticipantType,
ParticipantID: item.ParticipantID,
ExternalParticipantID: item.ExternalParticipantID,
JoinedAt: utils.FormatTimePtr(item.JoinedAt),
LeftAt: utils.FormatTimePtr(item.LeftAt),
Status: item.Status,
})
}
return ret
}
func BuildMessages(list []models.Message) []response.MessageResponse {
return BuildMessagesWithLocale(list, i18nx.DefaultLocale)
}
func BuildMessagesWithLocale(list []models.Message, locale string) []response.MessageResponse {
if len(list) == 0 {
return nil
}
agentReadState, customerReadState := services.ConversationReadStateService.GetConversationReadStates(list[0].ConversationID)
aiSenderNames, userSenderNames := collectMessageSenderNameMaps(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))
}
return ret
}
func BuildMessage(item *models.Message) response.MessageResponse {
return BuildMessageWithLocale(item, i18nx.DefaultLocale)
}
func BuildMessageWithLocale(item *models.Message, locale string) response.MessageResponse {
agentReadState, customerReadState := services.ConversationReadStateService.GetConversationReadStates(item.ConversationID)
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 BuildMessageWithReadStatesAndLocale(item *models.Message, agentReadState, customerReadState *models.ConversationReadState, aiSenderNames, 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,
MessageType: item.MessageType,
Content: localizeRenderableMessageContent(locale, content),
Payload: payload,
SendStatus: item.SendStatus,
SentAt: utils.FormatTimePtr(item.SentAt),
DeliveredAt: utils.FormatTimePtr(item.DeliveredAt),
ReadAt: utils.FormatTimePtr(item.ReadAt),
CustomerRead: isMessageRead(item, customerReadState),
CustomerReadAt: readMessageAt(item, customerReadState),
AgentRead: isMessageRead(item, agentReadState),
AgentReadAt: readMessageAt(item, agentReadState),
RecalledAt: utils.FormatTimePtr(item.RecalledAt),
QuotedMessageID: item.QuotedMessageID,
}
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 {
ret.SenderName = aiAgent.Name
}
} else if item.SenderType == enums.IMSenderTypeAgent {
profile := agentProfiles[item.SenderID]
if profile != nil {
if dn := strings.TrimSpace(profile.DisplayName); dn != "" {
ret.SenderName = dn
}
if av := strings.TrimSpace(profile.Avatar); av != "" {
ret.SenderAvatar = av
}
}
if ret.SenderName == "" {
if userSenderNames != nil {
ret.SenderName = userSenderNames[item.SenderID]
} else if user := services.UserService.Get(item.SenderID); user != nil {
ret.SenderName = user.Nickname
if ret.SenderName == "" {
ret.SenderName = user.Username
}
}
}
} else if userSenderNames != nil {
ret.SenderName = userSenderNames[item.SenderID]
} else if user := services.UserService.Get(item.SenderID); user != nil {
ret.SenderName = user.Nickname
if ret.SenderName == "" {
ret.SenderName = user.Username
}
}
}
return ret
}
func localizeRenderableMessageContent(locale string, content string) string {
if i18nx.NormalizeLocale(locale) != i18nx.LocaleEnUS {
return content
}
if content == "该消息已撤回" {
return "This message was recalled."
}
return content
}
func collectAgentProfilesByMessages(list []models.Message) map[int64]*models.AgentProfile {
var agentUserIDs []int64
seen := make(map[int64]struct{})
for i := range list {
m := &list[i]
if m.SenderType != enums.IMSenderTypeAgent || m.SenderID <= 0 {
continue
}
if _, ok := seen[m.SenderID]; ok {
continue
}
seen[m.SenderID] = struct{}{}
agentUserIDs = append(agentUserIDs, m.SenderID)
}
if len(agentUserIDs) == 0 {
return nil
}
profiles := services.AgentProfileService.Find(sqls.NewCnd().In("user_id", agentUserIDs))
out := make(map[int64]*models.AgentProfile, len(profiles))
for i := range profiles {
out[profiles[i].UserID] = &profiles[i]
}
return out
}
func collectMessageSenderNameMaps(list []models.Message) (aiNames map[int64]string, userNames map[int64]string) {
aiNames = make(map[int64]string)
userNames = make(map[int64]string)
var aiIDs, userIDs []int64
seenAI := make(map[int64]struct{})
seenUser := make(map[int64]struct{})
for i := range list {
m := &list[i]
if m.SenderID <= 0 {
continue
}
if m.SenderType == enums.IMSenderTypeAI {
if _, ok := seenAI[m.SenderID]; ok {
continue
}
seenAI[m.SenderID] = struct{}{}
aiIDs = append(aiIDs, m.SenderID)
continue
}
if _, ok := seenUser[m.SenderID]; ok {
continue
}
seenUser[m.SenderID] = struct{}{}
userIDs = append(userIDs, m.SenderID)
}
for _, a := range services.AIAgentService.FindByIds(aiIDs) {
aiNames[a.ID] = a.Name
}
for _, u := range services.UserService.FindByIds(userIDs) {
name := u.Nickname
if name == "" {
name = u.Username
}
userNames[u.ID] = name
}
return aiNames, userNames
}
func isMessageRead(item *models.Message, state *models.ConversationReadState) bool {
return item != nil && state != nil && state.LastReadMessageID >= item.ID
}
func readMessageAt(item *models.Message, state *models.ConversationReadState) string {
if !isMessageRead(item, state) {
return ""
}
return utils.FormatTimePtr(state.LastReadAt)
}
func readStateMessageID(state *models.ConversationReadState) int64 {
if state == nil {
return 0
}
return state.LastReadMessageID
}
func readStateAt(state *models.ConversationReadState) string {
if state == nil {
return ""
}
return utils.FormatTimePtr(state.LastReadAt)
}

View File

@@ -0,0 +1,174 @@
package builders
import (
"encoding/json"
"strings"
"testing"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/i18nx"
)
func TestLocalizeConversationSummary(t *testing.T) {
t.Parallel()
tests := []struct {
name string
locale string
summary string
want string
}{
{
name: "image summary in english",
locale: i18nx.LocaleEnUS,
summary: "[图片]",
want: "[Image]",
},
{
name: "attachment summary in english",
locale: i18nx.LocaleEnUS,
summary: "[附件] spec.pdf",
want: "[Attachment] spec.pdf",
},
{
name: "recalled message in english",
locale: i18nx.LocaleEnUS,
summary: "该消息已撤回",
want: "This message was recalled.",
},
{
name: "business text is not translated",
locale: i18nx.LocaleEnUS,
summary: "客户反馈无法登录",
want: "客户反馈无法登录",
},
{
name: "chinese locale keeps existing summary",
locale: i18nx.LocaleZhCN,
summary: "[图片]",
want: "[图片]",
},
// Russian used to fall through to the raw stored marker, so a visitor
// saw [图片] in their request list.
{
name: "image summary in russian",
locale: i18nx.LocaleRuRU,
summary: "[图片]",
want: "[Изображение]",
},
{
name: "attachment summary keeps the filename in russian",
locale: i18nx.LocaleRuRU,
summary: "[附件] spec.pdf",
want: "[Вложение] spec.pdf",
},
{
name: "recalled message in russian",
locale: i18nx.LocaleRuRU,
summary: "该消息已撤回",
want: "Это сообщение было отозвано.",
},
{
name: "unknown locale follows the system default",
locale: "xx-YY",
summary: "[图片]",
want: i18nx.Getf(i18nx.DefaultLocale, "conversation.summary.image"),
},
{
name: "business text is not translated in russian",
locale: i18nx.LocaleRuRU,
summary: "客户反馈无法登录",
want: "客户反馈无法登录",
},
{
name: "blank summary stays blank",
locale: i18nx.LocaleRuRU,
summary: " ",
want: "",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := localizeConversationSummary(tt.locale, tt.summary); got != tt.want {
t.Fatalf("localizeConversationSummary() = %q, want %q", got, tt.want)
}
})
}
}
func TestLocalizeRenderableMessageContent(t *testing.T) {
t.Parallel()
tests := []struct {
name string
locale string
content string
want string
}{
{
name: "recalled message in english",
locale: i18nx.LocaleEnUS,
content: "该消息已撤回",
want: "This message was recalled.",
},
{
name: "normal customer message is not translated",
locale: i18nx.LocaleEnUS,
content: "客户反馈无法登录",
want: "客户反馈无法登录",
},
{
name: "chinese locale keeps content",
locale: i18nx.LocaleZhCN,
content: "该消息已撤回",
want: "该消息已撤回",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := localizeRenderableMessageContent(tt.locale, tt.content); got != tt.want {
t.Fatalf("localizeRenderableMessageContent() = %q, want %q", got, tt.want)
}
})
}
}
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,
ConversationID: 2,
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeText,
Content: "hello",
}, nil, nil, nil, nil, nil, i18nx.DefaultLocale)
raw, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal message response: %v", err)
}
if strings.Contains(string(raw), "seqNo") {
t.Fatalf("message response should not expose seqNo, got %s", raw)
}
}

View File

@@ -0,0 +1,39 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/utils"
"agent-desk/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
}

View File

@@ -0,0 +1,36 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/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
}

View File

@@ -0,0 +1,191 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"encoding/json"
)
func BuildKnowledgeBase(item *models.KnowledgeBase) response.KnowledgeBaseResponse {
return response.KnowledgeBaseResponse{
ID: item.ID,
Name: item.Name,
Description: item.Description,
KnowledgeType: item.KnowledgeType,
KnowledgeTypeName: enums.GetKnowledgeBaseTypeLabel(enums.KnowledgeBaseType(item.KnowledgeType)),
Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status),
DefaultTopK: item.DefaultTopK,
DefaultScoreThreshold: item.DefaultScoreThreshold,
DefaultRerankLimit: item.DefaultRerankLimit,
ChunkProvider: item.ChunkProvider,
ChunkTargetTokens: item.ChunkTargetTokens,
ChunkMaxTokens: item.ChunkMaxTokens,
ChunkOverlapTokens: item.ChunkOverlapTokens,
AnswerMode: item.AnswerMode,
AnswerModeName: enums.GetKnowledgeAnswerModeLabel(enums.KnowledgeAnswerMode(item.AnswerMode)),
Remark: item.Remark,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
CreateUserName: item.CreateUserName,
UpdateUserName: item.UpdateUserName,
}
}
func BuildKnowledgeDocument(item *models.KnowledgeDocument) response.KnowledgeDocumentResponse {
return response.KnowledgeDocumentResponse{
ID: item.ID,
KnowledgeBaseID: item.KnowledgeBaseID,
DirectoryID: item.DirectoryID,
Title: item.Title,
Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status),
IndexStatus: item.IndexStatus,
IndexStatusName: enums.GetKnowledgeDocumentIndexStatusLabel(item.IndexStatus),
IndexedAt: item.IndexedAt,
IndexError: item.IndexError,
ContentHash: item.ContentHash,
ContentType: item.ContentType,
Content: item.Content,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
CreateUserName: item.CreateUserName,
UpdateUserName: item.UpdateUserName,
}
}
func BuildKnowledgeDocumentList(item *models.KnowledgeDocument) response.KnowledgeDocumentListResponse {
return response.KnowledgeDocumentListResponse{
ID: item.ID,
KnowledgeBaseID: item.KnowledgeBaseID,
DirectoryID: item.DirectoryID,
Title: item.Title,
Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status),
IndexStatus: item.IndexStatus,
IndexStatusName: enums.GetKnowledgeDocumentIndexStatusLabel(item.IndexStatus),
IndexedAt: item.IndexedAt,
IndexError: item.IndexError,
ContentHash: item.ContentHash,
ContentType: item.ContentType,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
CreateUserName: item.CreateUserName,
UpdateUserName: item.UpdateUserName,
}
}
func BuildKnowledgeFAQ(item *models.KnowledgeFAQ) response.KnowledgeFAQResponse {
return response.KnowledgeFAQResponse{
ID: item.ID,
KnowledgeBaseID: item.KnowledgeBaseID,
DirectoryID: item.DirectoryID,
Question: item.Question,
Answer: item.Answer,
SimilarQuestions: parseSimilarQuestions(item.SimilarQuestions),
Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status),
IndexStatus: item.IndexStatus,
IndexStatusName: enums.GetKnowledgeDocumentIndexStatusLabel(item.IndexStatus),
IndexedAt: item.IndexedAt,
IndexError: item.IndexError,
Remark: item.Remark,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
CreateUserName: item.CreateUserName,
UpdateUserName: item.UpdateUserName,
}
}
func BuildKnowledgeDirectory(item *models.KnowledgeDirectory) response.KnowledgeDirectoryResponse {
return response.KnowledgeDirectoryResponse{
ID: item.ID,
KnowledgeBaseID: item.KnowledgeBaseID,
ParentID: item.ParentID,
Name: item.Name,
SortNo: item.SortNo,
Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status),
Remark: item.Remark,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
CreateUserName: item.CreateUserName,
UpdateUserName: item.UpdateUserName,
Children: []response.KnowledgeDirectoryResponse{},
}
}
func BuildKnowledgeRetrieveLog(item *models.KnowledgeRetrieveLog) response.KnowledgeRetrieveLogResponse {
return response.KnowledgeRetrieveLogResponse{
ID: item.ID,
KnowledgeBaseID: item.KnowledgeBaseID,
Channel: item.Channel,
ChannelName: enums.GetKnowledgeRetrieveChannelLabel(enums.KnowledgeRetrieveChannel(item.Channel)),
Scene: item.Scene,
SceneName: enums.GetKnowledgeRetrieveSceneLabel(enums.KnowledgeRetrieveScene(item.Scene)),
SessionID: item.SessionID,
ConversationID: item.ConversationID,
RequestID: item.RequestID,
Question: item.Question,
RewriteQuestion: item.RewriteQuestion,
Answer: item.Answer,
AnswerStatus: item.AnswerStatus,
AnswerStatusName: enums.GetKnowledgeAnswerStatusLabel(enums.KnowledgeAnswerStatus(item.AnswerStatus)),
HitCount: item.HitCount,
TopScore: item.TopScore,
ChunkProvider: item.ChunkProvider,
ChunkTargetTokens: item.ChunkTargetTokens,
ChunkMaxTokens: item.ChunkMaxTokens,
ChunkOverlapTokens: item.ChunkOverlapTokens,
RerankEnabled: item.RerankEnabled,
RerankLimit: item.RerankLimit,
CitationCount: item.CitationCount,
UsedChunkCount: item.UsedChunkCount,
LatencyMs: item.LatencyMs,
RetrieveMs: item.RetrieveMs,
GenerateMs: item.GenerateMs,
PromptTokens: item.PromptTokens,
CompletionTokens: item.CompletionTokens,
ModelName: item.ModelName,
TraceData: item.TraceData,
CreatedAt: item.CreatedAt,
}
}
func BuildKnowledgeRetrieveHitResponse(item *models.KnowledgeRetrieveHit) response.KnowledgeRetrieveHitResponse {
return response.KnowledgeRetrieveHitResponse{
ID: item.ID,
RetrieveLogID: item.RetrieveLogID,
KnowledgeBaseID: item.KnowledgeBaseID,
ChunkID: item.ChunkID,
DocumentID: item.DocumentID,
DocumentTitle: item.DocumentTitle,
FaqID: item.FaqID,
FaqQuestion: item.FaqQuestion,
ChunkNo: item.ChunkNo,
Title: item.Title,
SectionPath: item.SectionPath,
ChunkType: item.ChunkType,
ChunkTypeName: enums.GetKnowledgeChunkTypeLabel(enums.KnowledgeChunkType(item.ChunkType)),
Provider: item.Provider,
RankNo: item.RankNo,
Score: item.Score,
RerankScore: item.RerankScore,
UsedInAnswer: item.UsedInAnswer,
IsCitation: item.IsCitation,
Snippet: item.Snippet,
CreatedAt: item.CreatedAt,
}
}
func parseSimilarQuestions(raw string) []string {
if raw == "" {
return []string{}
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return []string{}
}
return items
}

View File

@@ -0,0 +1,127 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/pkg/utils"
"regexp"
"strings"
)
var (
ticketAssignedNotificationPattern = regexp.MustCompile(`^工单 (.+) 已指派给你$`)
conversationAssignedNotificationPattern = regexp.MustCompile(`^会话 #([0-9]+) 已分配给你$`)
)
func BuildNotification(item *models.Notification) *response.NotificationResponse {
return BuildNotificationWithLocale(item, i18nx.DefaultLocale)
}
func BuildNotificationWithLocale(item *models.Notification, locale string) *response.NotificationResponse {
if item == nil {
return nil
}
title, content := localizeNotificationText(item, locale)
return &response.NotificationResponse{
ID: item.ID,
RecipientUserID: item.RecipientUserID,
Title: title,
Content: content,
NotificationType: item.NotificationType,
BizType: item.BizType,
BizID: item.BizID,
ActionURL: item.ActionURL,
ReadAt: utils.FormatTimePtr(item.ReadAt),
CreatedAt: utils.FormatTime(item.CreatedAt),
}
}
func BuildNotificationList(list []models.Notification) []response.NotificationResponse {
return BuildNotificationListWithLocale(list, i18nx.DefaultLocale)
}
func BuildNotificationListWithLocale(list []models.Notification, locale string) []response.NotificationResponse {
results := make([]response.NotificationResponse, 0, len(list))
for i := range list {
if item := BuildNotificationWithLocale(&list[i], locale); item != nil {
results = append(results, *item)
}
}
return results
}
func localizeNotificationText(item *models.Notification, locale string) (string, string) {
title := item.Title
content := item.Content
if i18nx.NormalizeLocale(locale) != i18nx.LocaleEnUS {
return title, content
}
switch strings.TrimSpace(item.NotificationType) {
case "ticket_assigned":
return localizeTicketAssignedNotification(title, content)
case "conversation_assigned":
return localizeConversationAssignedNotification(title, content)
default:
return title, content
}
}
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 {
return localizeNotificationTitle(title), content
}
if matches := conversationAssignedNotificationPattern.FindStringSubmatch(lines[0]); len(matches) == 2 {
lines[0] = i18nx.Getf(i18nx.LocaleEnUS, "notification.conversationAssigned.line", matches[1])
}
for i, line := range lines[1:] {
if reason, ok := strings.CutPrefix(line, "分配原因: "); ok {
lines[i+1] = i18nx.Getf(i18nx.LocaleEnUS, "notification.conversationAssigned.reason", reason)
continue
}
if reason, ok := strings.CutPrefix(line, "转接原因: "); ok {
lines[i+1] = i18nx.Getf(i18nx.LocaleEnUS, "notification.conversationTransferred.reason", reason)
}
}
return localizeNotificationTitle(title), strings.Join(lines, "\n")
}
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 "会话自动分配提醒":
return i18nx.Getf(i18nx.LocaleEnUS, "notification.conversationAutoAssigned.title")
case "会话分配提醒":
return i18nx.Getf(i18nx.LocaleEnUS, "notification.conversationAssigned.title")
default:
return title
}
}
func splitNotificationLines(content string) []string {
normalized := strings.TrimSpace(content)
if normalized == "" {
return nil
}
return strings.Split(normalized, "\n")
}

View File

@@ -0,0 +1,38 @@
package builders
import (
"testing"
"agent-desk/internal/models"
"agent-desk/internal/pkg/i18nx"
)
func TestBuildNotificationListReturnsEmptySlice(t *testing.T) {
results := BuildNotificationList([]models.Notification{})
if results == nil {
t.Fatalf("expected empty slice, got nil")
}
if len(results) != 0 {
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)
}
}

View File

@@ -0,0 +1,23 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
)
func BuildOrganization(item *models.Organization, role string, isActive bool) *response.OrganizationResponse {
if item == nil {
return nil
}
return &response.OrganizationResponse{
ID: item.ID,
Code: item.Code,
Name: item.Name,
Logo: item.Logo,
Plan: item.Plan,
Status: item.Status,
Role: role,
IsActive: isActive,
CreatedAt: item.CreatedAt,
}
}

View File

@@ -0,0 +1,32 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
)
func BuildQuickReplyResponse(item *models.QuickReply) *response.QuickReplyResponse {
if item == nil {
return nil
}
return &response.QuickReplyResponse{
ID: item.ID,
GroupName: item.GroupName,
Title: item.Title,
Content: item.Content,
Status: item.Status,
SortNo: item.SortNo,
CreatedBy: item.CreateUserID,
}
}
func BuildQuickReplyResponses(list []models.QuickReply) []response.QuickReplyResponse {
results := make([]response.QuickReplyResponse, 0, len(list))
for _, item := range list {
result := BuildQuickReplyResponse(&item)
if result != nil {
results = append(results, *result)
}
}
return results
}

View File

@@ -0,0 +1,42 @@
package builders
import (
"encoding/json"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/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 "未知"
}

View File

@@ -0,0 +1,174 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"encoding/json"
"time"
)
func BuildDocPage(item *models.DocPage, includeContent bool) *response.DocPageResponse {
if item == nil {
return nil
}
content := item.Content
if !includeContent {
content = ""
}
return &response.DocPageResponse{
ID: item.ID,
ParentID: item.ParentID,
Title: item.Title,
Slug: item.Slug,
Summary: item.Summary,
ContentType: item.ContentType,
Content: content,
CoverURL: item.CoverURL,
Tags: parseSupportTags(item.TagsJSON),
Status: item.Status,
SortNo: item.SortNo,
ViewCount: item.ViewCount,
HelpfulCount: item.HelpfulCount,
UnhelpfulCount: item.UnhelpfulCount,
PublishedAt: formatSupportTime(item.PublishedAt),
SyncedKnowledgeDocumentID: item.SyncedKnowledgeDocumentID,
Remark: item.Remark,
CreatedAt: formatSupportTime(&item.CreatedAt),
UpdatedAt: formatSupportTime(&item.UpdatedAt),
}
}
func BuildDocPageNavigationTree(list []models.DocPage) []*response.DocPageNavigationResponse {
nodes := make(map[int64]*response.DocPageNavigationResponse, len(list))
for i := range list {
item := &list[i]
nodes[item.ID] = &response.DocPageNavigationResponse{
ID: item.ID, ParentID: item.ParentID, Title: item.Title, Slug: item.Slug, SortNo: item.SortNo,
Children: make([]*response.DocPageNavigationResponse, 0),
}
}
roots := make([]*response.DocPageNavigationResponse, 0)
for i := range list {
item := &list[i]
node := nodes[item.ID]
if item.ParentID == 0 || nodes[item.ParentID] == nil {
roots = append(roots, node)
continue
}
nodes[item.ParentID].Children = append(nodes[item.ParentID].Children, node)
}
return roots
}
func BuildCategory(item *models.Category) *response.CategoryResponse {
if item == nil {
return nil
}
return &response.CategoryResponse{
ID: item.ID,
Name: item.Name,
Slug: item.Slug,
Description: item.Description,
SortNo: item.SortNo,
Status: item.Status,
Remark: item.Remark,
CreatedAt: formatSupportTime(&item.CreatedAt),
UpdatedAt: formatSupportTime(&item.UpdatedAt),
}
}
func BuildPostCategories(list []models.Category) []response.CategoryResponse {
ret := make([]response.CategoryResponse, 0, len(list))
for _, item := range list {
if resp := BuildCategory(&item); resp != nil {
ret = append(ret, *resp)
}
}
return ret
}
func BuildPost(item *models.Post, categoryName string, user *models.User) *response.PostResponse {
if item == nil {
return nil
}
userName := ""
userType := enums.UserTypeUser
if user != nil {
userName = user.Nickname
if userName == "" {
userName = user.Username
}
userType = user.UserType
}
return &response.PostResponse{
ID: item.ID,
CategoryID: item.CategoryID,
CategoryName: categoryName,
UserID: item.UserID,
UserName: userName,
UserType: userType,
Title: item.Title,
ContentType: item.ContentType,
Content: item.Content,
Tags: parseSupportTags(item.TagsJSON),
Status: item.Status,
AcceptedCommentID: item.AcceptedCommentID,
CommentCount: item.CommentCount,
ReactionCount: item.ReactionCount,
ViewCount: item.ViewCount,
LastCommentedAt: formatSupportTime(item.LastCommentedAt),
LastCommentUserType: item.LastCommentUserType,
LastCommentUserID: item.LastCommentUserID,
CreatedAt: formatSupportTime(&item.CreatedAt),
UpdatedAt: formatSupportTime(&item.UpdatedAt),
}
}
func BuildComment(item *models.Comment, authorName string) *response.CommentResponse {
if item == nil {
return nil
}
contentType := item.ContentType
content := item.Content
if item.Status == enums.CommentStatusDeleted {
contentType = "markdown"
content = ""
}
return &response.CommentResponse{
ID: item.ID,
PostID: item.PostID,
ParentID: item.ParentID,
AuthorType: item.AuthorType,
AuthorID: item.AuthorID,
AuthorName: authorName,
ContentType: contentType,
Content: content,
Status: item.Status,
ReactionCount: item.ReactionCount,
ReplyCount: item.ReplyCount,
ReportCount: item.ReportCount,
IsAccepted: item.IsAccepted,
Replies: []response.CommentResponse{},
CreatedAt: formatSupportTime(&item.CreatedAt),
UpdatedAt: formatSupportTime(&item.UpdatedAt),
}
}
func parseSupportTags(raw string) []string {
var ret []string
if raw == "" {
return []string{}
}
if err := json.Unmarshal([]byte(raw), &ret); err != nil {
return []string{}
}
return ret
}
func formatSupportTime(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.Format(time.DateTime)
}

View File

@@ -0,0 +1,17 @@
package builders
import (
"agent-desk/internal/models"
"testing"
)
func TestBuildDocPageNavigationTree(t *testing.T) {
menu := BuildDocPageNavigationTree([]models.DocPage{
{ID: 1, Title: "Install", Slug: "install"},
{ID: 2, Title: "Overview", Slug: "overview"},
{ID: 3, ParentID: 2, Title: "Change log", Slug: "changelog"},
})
if len(menu) != 2 || menu[0].Slug != "install" || menu[1].Slug != "overview" || len(menu[1].Children) != 1 || menu[1].Children[0].Slug != "changelog" {
t.Fatalf("unexpected navigation tree: %#v", menu)
}
}

View File

@@ -0,0 +1,75 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/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
}

View File

@@ -0,0 +1,191 @@
package builders
import (
"encoding/json"
"strings"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/services"
)
type TicketBuildContext struct {
TagsByTicketID map[int64][]models.Tag
Users map[int64]*models.User
Customers map[int64]*models.Customer
}
type TicketDetailBuildContext struct {
Users map[int64]*models.User
}
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 *models.User) string {
if user == nil {
return ""
}
if user.Nickname != "" {
return user.Nickname
}
return user.Username
}

View File

@@ -0,0 +1,122 @@
package builders
import (
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
)
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]*models.User{
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]*models.User{
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")
}
}

View File

@@ -0,0 +1,69 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/services"
)
type UserBuildOptions struct {
Roles bool
Permissions bool
}
func BuildUserList(items []models.User, options UserBuildOptions) []response.UserResponse {
results := make([]response.UserResponse, 0, len(items))
for _, item := range items {
results = append(results, *BuildUserResponse(&item, options))
}
return results
}
func BuildUserResponse(item *models.User, options UserBuildOptions) *response.UserResponse {
if item == nil {
return nil
}
ret := &response.UserResponse{
ID: item.ID,
Username: item.Username,
Nickname: item.Nickname,
Avatar: item.Avatar,
UserType: item.UserType,
Status: item.Status,
LastLoginAt: utils.FormatTimePtr(item.LastLoginAt),
LastLoginIP: item.LastLoginIP,
}
if item.Mobile != nil {
ret.Mobile = *item.Mobile
}
if item.Email != nil {
ret.Email = *item.Email
}
if options.Roles {
ret.Roles = buildAssignedRoles(item.ID)
}
if options.Permissions {
permissionCodes, _ := services.AuthService.GetUserPermissions(item.ID)
ret.Permissions = permissionCodes
}
return ret
}
func buildAssignedRoles(userID int64) []response.RoleResponse {
roles, _ := services.AuthService.GetUserRoles(userID)
results := make([]response.RoleResponse, 0, len(roles))
for _, role := range roles {
results = append(results, response.RoleResponse{
ID: role.ID,
Name: role.Name,
Code: role.Code,
Status: role.Status,
IsSystem: role.IsSystem,
SortNo: role.SortNo,
})
}
return results
}