chore: import clean Agent Desk snapshot (2/7)
This commit is contained in:
82
internal/builders/agent_profile_builder.go
Normal file
82
internal/builders/agent_profile_builder.go
Normal 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
|
||||
}
|
||||
29
internal/builders/agent_revision_builder.go
Normal file
29
internal/builders/agent_revision_builder.go
Normal 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
|
||||
}
|
||||
144
internal/builders/agent_run_builder.go
Normal file
144
internal/builders/agent_run_builder.go
Normal 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
|
||||
}
|
||||
39
internal/builders/agent_team_schedule_builder.go
Normal file
39
internal/builders/agent_team_schedule_builder.go
Normal 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}
|
||||
}
|
||||
232
internal/builders/ai_workflow_builder.go
Normal file
232
internal/builders/ai_workflow_builder.go
Normal 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()
|
||||
}
|
||||
161
internal/builders/ai_workflow_builder_test.go
Normal file
161
internal/builders/ai_workflow_builder_test.go
Normal 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
|
||||
}
|
||||
35
internal/builders/asset_builder.go
Normal file
35
internal/builders/asset_builder.go
Normal 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
|
||||
}
|
||||
32
internal/builders/company_builder.go
Normal file
32
internal/builders/company_builder.go
Normal 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
|
||||
}
|
||||
339
internal/builders/conversation_builder.go
Normal file
339
internal/builders/conversation_builder.go
Normal 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)
|
||||
}
|
||||
174
internal/builders/conversation_builder_test.go
Normal file
174
internal/builders/conversation_builder_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
39
internal/builders/customer_builder.go
Normal file
39
internal/builders/customer_builder.go
Normal 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
|
||||
}
|
||||
36
internal/builders/customer_contact_builder.go
Normal file
36
internal/builders/customer_contact_builder.go
Normal 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
|
||||
}
|
||||
191
internal/builders/knowledge_builder.go
Normal file
191
internal/builders/knowledge_builder.go
Normal 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
|
||||
}
|
||||
127
internal/builders/notification_builder.go
Normal file
127
internal/builders/notification_builder.go
Normal 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")
|
||||
}
|
||||
38
internal/builders/notification_builder_test.go
Normal file
38
internal/builders/notification_builder_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
23
internal/builders/organization_builder.go
Normal file
23
internal/builders/organization_builder.go
Normal 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,
|
||||
}
|
||||
}
|
||||
32
internal/builders/quick_reply_builder.go
Normal file
32
internal/builders/quick_reply_builder.go
Normal 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
|
||||
}
|
||||
42
internal/builders/skill_builder.go
Normal file
42
internal/builders/skill_builder.go
Normal 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 "未知"
|
||||
}
|
||||
174
internal/builders/support_builder.go
Normal file
174
internal/builders/support_builder.go
Normal 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)
|
||||
}
|
||||
17
internal/builders/support_builder_test.go
Normal file
17
internal/builders/support_builder_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
75
internal/builders/tag_builder.go
Normal file
75
internal/builders/tag_builder.go
Normal 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
|
||||
}
|
||||
191
internal/builders/ticket_builder.go
Normal file
191
internal/builders/ticket_builder.go
Normal 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
|
||||
}
|
||||
122
internal/builders/ticket_builder_test.go
Normal file
122
internal/builders/ticket_builder_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
69
internal/builders/user_builder.go
Normal file
69
internal/builders/user_builder.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user