From fe4a99cf4b25af97cc623c424cc4294a639cd517 Mon Sep 17 00:00:00 2001 From: mono Date: Fri, 16 Oct 2026 11:58:36 +0000 Subject: [PATCH] chore: import clean Agent Desk snapshot (3/7) --- internal/services/ai_workflow_service_test.go | 393 ++++ internal/services/asset_service.go | 215 +++ internal/services/auth_service.go | 542 ++++++ internal/services/auth_service_test.go | 521 ++++++ internal/services/branding_service.go | 142 ++ internal/services/business_tool_executor.go | 152 ++ .../channel_message_outbox_service.go | 426 +++++ internal/services/channel_service.go | 685 +++++++ internal/services/channel_service_test.go | 139 ++ internal/services/company_service.go | 150 ++ .../conversation_assignment_service.go | 76 + .../conversation_customer_service_test.go | 376 ++++ .../services/conversation_dispatch_service.go | 476 +++++ .../conversation_event_log_service.go | 88 + ...nversation_human_dispatch_realtime_test.go | 242 +++ .../conversation_human_dispatch_service.go | 443 +++++ ...onversation_human_dispatch_service_test.go | 423 +++++ .../conversation_interrupt_service.go | 195 ++ .../conversation_participant_service.go | 83 + .../conversation_read_state_service.go | 275 +++ .../conversation_satisfaction_service.go | 85 + .../conversation_satisfaction_service_test.go | 150 ++ internal/services/conversation_service.go | 1006 ++++++++++ internal/services/conversation_tag_service.go | 95 + internal/services/cronx/cron.go | 54 + internal/services/customer_contact_service.go | 524 ++++++ .../services/customer_identity_service.go | 67 + internal/services/customer_service.go | 373 ++++ internal/services/customer_service_test.go | 125 ++ internal/services/customer_session_service.go | 252 +++ internal/services/dashboard_service.go | 519 ++++++ internal/services/dashboard_service_test.go | 41 + internal/services/discord_inbound_service.go | 176 ++ .../services/discord_inbound_service_test.go | 294 +++ internal/services/discord_integration_test.go | 169 ++ internal/services/discord_outbound_service.go | 231 +++ internal/services/email_delivery_service.go | 71 + internal/services/email_delivery_settings.go | 125 ++ .../services/email_delivery_settings_test.go | 144 ++ internal/services/email_inbound_service.go | 243 +++ internal/services/email_outbound_service.go | 239 +++ internal/services/email_request_service.go | 445 +++++ .../services/email_request_service_test.go | 510 ++++++ .../notification_event_handler.go | 100 + .../notification_event_handler_test.go | 123 ++ internal/services/im_message_asset.go | 116 ++ internal/services/knowledge_base_service.go | 331 ++++ .../services/knowledge_base_service_test.go | 205 +++ .../services/knowledge_directory_service.go | 212 +++ .../knowledge_directory_service_test.go | 153 ++ .../services/knowledge_document_service.go | 290 +++ internal/services/knowledge_faq_excel.go | 371 ++++ internal/services/knowledge_faq_excel_test.go | 249 +++ internal/services/knowledge_faq_service.go | 238 +++ .../knowledge_retrieve_log_service.go | 47 + .../services/login_credential_log_service.go | 67 + internal/services/login_session_service.go | 95 + internal/services/mcp_debug_service.go | 155 ++ internal/services/message_service.go | 747 ++++++++ internal/services/message_service_test.go | 461 +++++ internal/services/migration_service.go | 67 + internal/services/notification_service.go | 105 ++ .../services/notification_service_test.go | 126 ++ internal/services/oidc_login_service.go | 416 +++++ internal/services/oidc_login_service_test.go | 153 ++ internal/services/organization_service.go | 98 + internal/services/permission_service.go | 144 ++ internal/services/permission_service_test.go | 100 + internal/services/quick_reply_service.go | 125 ++ internal/services/role_permission_service.go | 67 + internal/services/role_service.go | 204 +++ internal/services/setup_readiness_service.go | 1630 +++++++++++++++++ .../services/setup_readiness_service_test.go | 436 +++++ internal/services/skill_definition_service.go | 202 ++ internal/services/skill_runtime_service.go | 53 + internal/services/slack_inbound_service.go | 180 ++ .../services/slack_inbound_service_test.go | 368 ++++ internal/services/slack_outbound_service.go | 187 ++ internal/services/storage/dto.go | 23 + internal/services/storage/local.go | 70 + internal/services/storage/oss.go | 177 ++ internal/services/storage/provider.go | 50 + internal/services/storage/safety.go | 218 +++ internal/services/storage/safety_test.go | 268 +++ internal/services/storage/utils.go | 78 + internal/services/support_service.go | 740 ++++++++ internal/services/support_service_test.go | 360 ++++ .../system_config_branding_validator.go | 98 + internal/services/system_config_service.go | 307 ++++ .../services/system_config_service_test.go | 139 ++ .../system_config_support_validator.go | 170 ++ .../system_config_support_validator_test.go | 30 + internal/services/tag_service.go | 269 +++ internal/services/telegram_inbound_service.go | 113 ++ .../services/telegram_integration_test.go | 179 ++ .../services/telegram_outbound_service.go | 150 ++ internal/services/ticket_no_service.go | 123 ++ internal/services/ticket_progress_service.go | 67 + internal/services/ticket_service.go | 690 +++++++ internal/services/ticket_service_test.go | 718 ++++++++ internal/services/ticket_tag_service.go | 104 ++ internal/services/ticket_view_service.go | 92 + internal/services/tool_catalog_service.go | 141 ++ internal/services/user_identity_service.go | 67 + internal/services/user_permission_service.go | 67 + internal/services/user_role_service.go | 67 + internal/services/user_service.go | 316 ++++ internal/services/webhook_sync_service.go | 304 +++ .../services/webhook_sync_service_test.go | 178 ++ internal/services/ws_connection_manager.go | 134 ++ internal/services/ws_realtime_types.go | 363 ++++ internal/services/ws_service.go | 819 +++++++++ internal/services/ws_service_test.go | 203 ++ internal/services/zalo_inbound_service.go | 126 ++ internal/services/zalo_outbound_service.go | 144 ++ internal/slack/client.go | 109 ++ internal/slack/client_test.go | 120 ++ internal/slack/types.go | 37 + internal/telegram/client.go | 137 ++ internal/telegram/client_test.go | 124 ++ internal/telegram/types.go | 70 + internal/zalo/client.go | 140 ++ internal/zalo/client_test.go | 81 + internal/zalo/types.go | 59 + qdrant | 1 + screenshots/1.png | Bin 0 -> 197055 bytes screenshots/2.png | Bin 0 -> 212039 bytes 127 files changed, 29641 insertions(+) create mode 100644 internal/services/ai_workflow_service_test.go create mode 100644 internal/services/asset_service.go create mode 100644 internal/services/auth_service.go create mode 100644 internal/services/auth_service_test.go create mode 100644 internal/services/branding_service.go create mode 100644 internal/services/business_tool_executor.go create mode 100644 internal/services/channel_message_outbox_service.go create mode 100644 internal/services/channel_service.go create mode 100644 internal/services/channel_service_test.go create mode 100644 internal/services/company_service.go create mode 100644 internal/services/conversation_assignment_service.go create mode 100644 internal/services/conversation_customer_service_test.go create mode 100644 internal/services/conversation_dispatch_service.go create mode 100644 internal/services/conversation_event_log_service.go create mode 100644 internal/services/conversation_human_dispatch_realtime_test.go create mode 100644 internal/services/conversation_human_dispatch_service.go create mode 100644 internal/services/conversation_human_dispatch_service_test.go create mode 100644 internal/services/conversation_interrupt_service.go create mode 100644 internal/services/conversation_participant_service.go create mode 100644 internal/services/conversation_read_state_service.go create mode 100644 internal/services/conversation_satisfaction_service.go create mode 100644 internal/services/conversation_satisfaction_service_test.go create mode 100644 internal/services/conversation_service.go create mode 100644 internal/services/conversation_tag_service.go create mode 100644 internal/services/cronx/cron.go create mode 100644 internal/services/customer_contact_service.go create mode 100644 internal/services/customer_identity_service.go create mode 100644 internal/services/customer_service.go create mode 100644 internal/services/customer_service_test.go create mode 100644 internal/services/customer_session_service.go create mode 100644 internal/services/dashboard_service.go create mode 100644 internal/services/dashboard_service_test.go create mode 100644 internal/services/discord_inbound_service.go create mode 100644 internal/services/discord_inbound_service_test.go create mode 100644 internal/services/discord_integration_test.go create mode 100644 internal/services/discord_outbound_service.go create mode 100644 internal/services/email_delivery_service.go create mode 100644 internal/services/email_delivery_settings.go create mode 100644 internal/services/email_delivery_settings_test.go create mode 100644 internal/services/email_inbound_service.go create mode 100644 internal/services/email_outbound_service.go create mode 100644 internal/services/email_request_service.go create mode 100644 internal/services/email_request_service_test.go create mode 100644 internal/services/event_handlers/notification_event_handler.go create mode 100644 internal/services/event_handlers/notification_event_handler_test.go create mode 100644 internal/services/im_message_asset.go create mode 100644 internal/services/knowledge_base_service.go create mode 100644 internal/services/knowledge_base_service_test.go create mode 100644 internal/services/knowledge_directory_service.go create mode 100644 internal/services/knowledge_directory_service_test.go create mode 100644 internal/services/knowledge_document_service.go create mode 100644 internal/services/knowledge_faq_excel.go create mode 100644 internal/services/knowledge_faq_excel_test.go create mode 100644 internal/services/knowledge_faq_service.go create mode 100644 internal/services/knowledge_retrieve_log_service.go create mode 100644 internal/services/login_credential_log_service.go create mode 100644 internal/services/login_session_service.go create mode 100644 internal/services/mcp_debug_service.go create mode 100644 internal/services/message_service.go create mode 100644 internal/services/message_service_test.go create mode 100644 internal/services/migration_service.go create mode 100644 internal/services/notification_service.go create mode 100644 internal/services/notification_service_test.go create mode 100644 internal/services/oidc_login_service.go create mode 100644 internal/services/oidc_login_service_test.go create mode 100644 internal/services/organization_service.go create mode 100644 internal/services/permission_service.go create mode 100644 internal/services/permission_service_test.go create mode 100644 internal/services/quick_reply_service.go create mode 100644 internal/services/role_permission_service.go create mode 100644 internal/services/role_service.go create mode 100644 internal/services/setup_readiness_service.go create mode 100644 internal/services/setup_readiness_service_test.go create mode 100644 internal/services/skill_definition_service.go create mode 100644 internal/services/skill_runtime_service.go create mode 100644 internal/services/slack_inbound_service.go create mode 100644 internal/services/slack_inbound_service_test.go create mode 100644 internal/services/slack_outbound_service.go create mode 100644 internal/services/storage/dto.go create mode 100644 internal/services/storage/local.go create mode 100644 internal/services/storage/oss.go create mode 100644 internal/services/storage/provider.go create mode 100644 internal/services/storage/safety.go create mode 100644 internal/services/storage/safety_test.go create mode 100644 internal/services/storage/utils.go create mode 100644 internal/services/support_service.go create mode 100644 internal/services/support_service_test.go create mode 100644 internal/services/system_config_branding_validator.go create mode 100644 internal/services/system_config_service.go create mode 100644 internal/services/system_config_service_test.go create mode 100644 internal/services/system_config_support_validator.go create mode 100644 internal/services/system_config_support_validator_test.go create mode 100644 internal/services/tag_service.go create mode 100644 internal/services/telegram_inbound_service.go create mode 100644 internal/services/telegram_integration_test.go create mode 100644 internal/services/telegram_outbound_service.go create mode 100644 internal/services/ticket_no_service.go create mode 100644 internal/services/ticket_progress_service.go create mode 100644 internal/services/ticket_service.go create mode 100644 internal/services/ticket_service_test.go create mode 100644 internal/services/ticket_tag_service.go create mode 100644 internal/services/ticket_view_service.go create mode 100644 internal/services/tool_catalog_service.go create mode 100644 internal/services/user_identity_service.go create mode 100644 internal/services/user_permission_service.go create mode 100644 internal/services/user_role_service.go create mode 100644 internal/services/user_service.go create mode 100644 internal/services/webhook_sync_service.go create mode 100644 internal/services/webhook_sync_service_test.go create mode 100644 internal/services/ws_connection_manager.go create mode 100644 internal/services/ws_realtime_types.go create mode 100644 internal/services/ws_service.go create mode 100644 internal/services/ws_service_test.go create mode 100644 internal/services/zalo_inbound_service.go create mode 100644 internal/services/zalo_outbound_service.go create mode 100644 internal/slack/client.go create mode 100644 internal/slack/client_test.go create mode 100644 internal/slack/types.go create mode 100644 internal/telegram/client.go create mode 100644 internal/telegram/client_test.go create mode 100644 internal/telegram/types.go create mode 100644 internal/zalo/client.go create mode 100644 internal/zalo/client_test.go create mode 100644 internal/zalo/types.go create mode 160000 qdrant create mode 100644 screenshots/1.png create mode 100644 screenshots/2.png diff --git a/internal/services/ai_workflow_service_test.go b/internal/services/ai_workflow_service_test.go new file mode 100644 index 0000000..3e8b648 --- /dev/null +++ b/internal/services/ai_workflow_service_test.go @@ -0,0 +1,393 @@ +package services + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "agent-desk/internal/ai/workflow/dsl" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func TestAIWorkflowServiceValidateDefinitionReportsErrors(t *testing.T) { + setupAIWorkflowTestDB(t) + result := AIWorkflowService.ValidateDefinition(dsl.Definition{ + SchemaVersion: dsl.SchemaVersion, + Nodes: []dsl.Node{ + workflowServiceTestNode("start_1", "start", nil, nil), + workflowServiceTestNode("create_1", "create_ticket", nil, nil), + workflowServiceTestNode("end_1", "end", nil, nil), + }, + Edges: []dsl.Edge{ + workflowServiceTestEdge("start_1", "create_1"), + workflowServiceTestEdge("create_1", "end_1"), + }, + }) + + if result.Valid { + t.Fatalf("expected invalid workflow definition") + } + if len(result.Errors) == 0 { + t.Fatalf("expected validation errors") + } +} + +func TestAIWorkflowServiceDefaultDefinitionUsesOfficialFlowGramModel(t *testing.T) { + definition := defaultAgentWorkflowDefinition() + if definition.SchemaVersion != 0 { + t.Fatalf("official FlowGram definition must not contain the legacy schemaVersion, got %d", definition.SchemaVersion) + } + if len(definition.Nodes) != 3 { + t.Fatalf("default node count = %d, want 3", len(definition.Nodes)) + } + nodeTypes := []string{definition.Nodes[0].Type, definition.Nodes[1].Type, definition.Nodes[2].Type} + if strings.Join(nodeTypes, ",") != "start,llm,end" { + t.Fatalf("default node types = %v, want [start llm end]", nodeTypes) + } + if len(definition.GlobalVariable) == 0 { + t.Fatalf("official FlowGram globalVariable is required") + } + if result := AIWorkflowService.ValidateDefinition(definition); !result.Valid { + t.Fatalf("default official FlowGram definition is invalid: %#v", result.Errors) + } +} + +func TestAIWorkflowServicePublishCreatesImmutableVersion(t *testing.T) { + setupAIWorkflowTestDB(t) + operator := aiWorkflowTestOperator() + workflow, err := AIWorkflowService.CreateWorkflow(request.CreateAIWorkflowRequest{ + Name: "support flow", + Description: "customer service flow", + Definition: validAIWorkflowDefinition(), + }, operator) + if err != nil { + t.Fatalf("CreateWorkflow() error = %v", err) + } + + version, err := AIWorkflowService.PublishWorkflow(request.PublishAIWorkflowRequest{ + WorkflowID: workflow.ID, + Definition: validAIWorkflowDefinition(), + }, operator) + if err != nil { + t.Fatalf("PublishWorkflow() error = %v", err) + } + + if version.WorkflowID != workflow.ID { + t.Fatalf("expected workflow id %d, got %d", workflow.ID, version.WorkflowID) + } + if version.Version != 1 { + t.Fatalf("expected first version to be 1, got %d", version.Version) + } + if version.DefinitionHash == "" { + t.Fatalf("expected definition hash") + } + if version.PublishedAt == nil { + t.Fatalf("expected published timestamp") + } + + var stored dsl.Definition + if err := json.Unmarshal([]byte(version.Definition), &stored); err != nil { + t.Fatalf("unmarshal stored definition: %v", err) + } + if stored.SchemaVersion != dsl.SchemaVersion || len(stored.Nodes) == 0 { + t.Fatalf("unexpected stored definition: %+v", stored) + } +} + +func TestAIWorkflowServiceWorkflowTemplatesAreValid(t *testing.T) { + templates := AIWorkflowService.ListWorkflowTemplates() + if len(templates) != 4 { + t.Fatalf("template count = %d, want 4", len(templates)) + } + seen := make(map[string]struct{}, len(templates)) + for _, item := range templates { + if item.Code == "" || item.Name == "" { + t.Fatalf("template identity is required: %#v", item) + } + if _, exists := seen[item.Code]; exists { + t.Fatalf("duplicate template code: %s", item.Code) + } + seen[item.Code] = struct{}{} + if result := AIWorkflowService.ValidateDefinition(item.Definition); !result.Valid { + t.Fatalf("template %s is invalid: %#v", item.Code, result.Errors) + } + } +} + +func TestAIWorkflowServicePublishIncrementsVersion(t *testing.T) { + setupAIWorkflowTestDB(t) + operator := aiWorkflowTestOperator() + workflow, err := AIWorkflowService.CreateWorkflow(request.CreateAIWorkflowRequest{ + Name: "support flow versions", + Definition: validAIWorkflowDefinition(), + }, operator) + if err != nil { + t.Fatalf("CreateWorkflow() error = %v", err) + } + + first, err := AIWorkflowService.PublishWorkflow(request.PublishAIWorkflowRequest{ + WorkflowID: workflow.ID, + Definition: validAIWorkflowDefinition(), + }, operator) + if err != nil { + t.Fatalf("PublishWorkflow() first error = %v", err) + } + second, err := AIWorkflowService.PublishWorkflow(request.PublishAIWorkflowRequest{ + WorkflowID: workflow.ID, + Definition: validAIWorkflowDefinition(), + }, operator) + if err != nil { + t.Fatalf("PublishWorkflow() second error = %v", err) + } + + if first.Version != 1 || second.Version != 2 { + t.Fatalf("expected versions 1 and 2, got %d and %d", first.Version, second.Version) + } +} + +func TestAIWorkflowServicePublishRejectsInvalidDSL(t *testing.T) { + setupAIWorkflowTestDB(t) + operator := aiWorkflowTestOperator() + workflow, err := AIWorkflowService.CreateWorkflow(request.CreateAIWorkflowRequest{ + Name: "invalid publish flow", + Definition: validAIWorkflowDefinition(), + }, operator) + if err != nil { + t.Fatalf("CreateWorkflow() error = %v", err) + } + + _, err = AIWorkflowService.PublishWorkflow(request.PublishAIWorkflowRequest{ + WorkflowID: workflow.ID, + Definition: dsl.Definition{ + SchemaVersion: dsl.SchemaVersion, + Nodes: []dsl.Node{ + workflowServiceTestNode("start_1", "start", nil, nil), + workflowServiceTestNode("create_1", "create_ticket", nil, nil), + workflowServiceTestNode("end_1", "end", nil, nil), + }, + Edges: []dsl.Edge{ + workflowServiceTestEdge("start_1", "create_1"), + workflowServiceTestEdge("create_1", "end_1"), + }, + }, + }, operator) + if err == nil { + t.Fatalf("expected invalid publish to fail") + } + if versions := repositories.AIWorkflowVersionRepository.Find(sqls.DB(), sqls.NewCnd().Eq("workflow_id", workflow.ID)); len(versions) != 0 { + t.Fatalf("expected no versions after invalid publish, got %d", len(versions)) + } +} + +func TestAIWorkflowServiceListExcludesDeletedWorkflows(t *testing.T) { + setupAIWorkflowTestDB(t) + operator := aiWorkflowTestOperator() + active, err := AIWorkflowService.CreateWorkflow(request.CreateAIWorkflowRequest{ + Name: "active workflow", + Definition: validAIWorkflowDefinition(), + }, operator) + if err != nil { + t.Fatalf("CreateWorkflow(active) error = %v", err) + } + deleted, err := AIWorkflowService.CreateWorkflow(request.CreateAIWorkflowRequest{ + Name: "deleted workflow", + Definition: validAIWorkflowDefinition(), + }, operator) + if err != nil { + t.Fatalf("CreateWorkflow(deleted) error = %v", err) + } + if err := AIWorkflowService.DeleteWorkflow(deleted.ID, operator); err != nil { + t.Fatalf("DeleteWorkflow() error = %v", err) + } + + list, paging := AIWorkflowService.FindPageByCnd(sqls.NewCnd().NotEq("status", enums.StatusDeleted).Desc("id").Page(1, 20)) + if paging.Total != 1 || len(list) != 1 || list[0].ID != active.ID { + t.Fatalf("deleted workflow must be excluded: total=%d list=%#v", paging.Total, list) + } +} + +func TestAIWorkflowServiceRunListAndDetail(t *testing.T) { + setupAIWorkflowTestDB(t) + now := time.Now() + agent := models.AIAgent{Name: "售后 Agent", Status: enums.StatusOk} + if err := sqls.DB().Create(&agent).Error; err != nil { + t.Fatalf("create agent: %v", err) + } + workflow := models.AIWorkflow{Name: "售后流程", Status: enums.StatusOk} + if err := sqls.DB().Create(&workflow).Error; err != nil { + t.Fatalf("create workflow: %v", err) + } + versionDefinition := validAIWorkflowDefinition() + versionDefinition.Nodes[1].Data.Title = "运行时回复" + versionDefinitionJSON, err := json.Marshal(versionDefinition) + if err != nil { + t.Fatalf("marshal version definition: %v", err) + } + version := models.AIWorkflowVersion{ + WorkflowID: workflow.ID, + Version: 7, + Status: enums.StatusOk, + Definition: string(versionDefinitionJSON), + } + if err := sqls.DB().Create(&version).Error; err != nil { + t.Fatalf("create workflow version: %v", err) + } + run := models.AIWorkflowRun{ + WorkflowID: workflow.ID, + WorkflowVersionID: version.ID, + ConversationID: 303, + AIAgentID: agent.ID, + MessageID: 404, + Status: 1, + StartedAt: now, + EndedAt: &now, + } + if err := sqls.DB().Create(&run).Error; err != nil { + t.Fatalf("create workflow run: %v", err) + } + otherRun := models.AIWorkflowRun{ + WorkflowID: workflow.ID, + WorkflowVersionID: version.ID, + ConversationID: 999, + AIAgentID: agent.ID, + MessageID: 505, + Status: 1, + StartedAt: now, + } + if err := sqls.DB().Create(&otherRun).Error; err != nil { + t.Fatalf("create other workflow run: %v", err) + } + nodes := []models.AIWorkflowNodeRun{ + { + WorkflowRunID: run.ID, + NodeID: "start_1", + NodeType: "start", + Status: 1, + InputPreview: `{"inputs":{}}`, + OutputPreview: `{"messageId":404}`, + StartedAt: now, + EndedAt: &now, + }, + { + WorkflowRunID: run.ID, + NodeID: "reply_1", + NodeType: "llm_reply", + Status: 1, + OutputPreview: `{"replyText":"hello"}`, + StartedAt: now, + EndedAt: &now, + DurationMS: 8, + }, + } + if err := sqls.DB().Create(&nodes).Error; err != nil { + t.Fatalf("create workflow node runs: %v", err) + } + + list, paging := AIWorkflowService.FindRunPageByCnd(sqls.NewCnd().Eq("conversation_id", 303).Desc("id").Page(1, 20)) + if paging.Total != 1 || len(list) != 1 || list[0].ID != run.ID { + t.Fatalf("unexpected run list: total=%d list=%#v", paging.Total, list) + } + auditItems := AIWorkflowService.BuildRunAuditItems(list) + if len(auditItems) != 1 { + t.Fatalf("unexpected audit item count: %d", len(auditItems)) + } + if auditItems[0].Workflow == nil || auditItems[0].Workflow.Name != workflow.Name { + t.Fatalf("expected workflow context, got %#v", auditItems[0].Workflow) + } + if auditItems[0].Version == nil || auditItems[0].Version.Version != version.Version { + t.Fatalf("expected version context, got %#v", auditItems[0].Version) + } + if auditItems[0].Agent == nil || auditItems[0].Agent.Name != agent.Name { + t.Fatalf("expected agent context, got %#v", auditItems[0].Agent) + } + + detail, nodeRuns := AIWorkflowService.GetRunDetail(run.ID) + if detail == nil || detail.ID != run.ID { + t.Fatalf("unexpected detail run: %#v", detail) + } + if len(nodeRuns) != 2 || nodeRuns[0].NodeID != "start_1" || nodeRuns[1].NodeID != "reply_1" { + t.Fatalf("unexpected detail nodes: %#v", nodeRuns) + } + if missing, missingNodes := AIWorkflowService.GetRunDetail(999999); missing != nil || len(missingNodes) != 0 { + t.Fatalf("expected missing detail to be empty, got run=%#v nodes=%#v", missing, missingNodes) + } +} + +func setupAIWorkflowTestDB(t *testing.T) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate(&models.AIAgent{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}, &models.AIAgentWorkflowBinding{}, &models.AIWorkflowRun{}, &models.AIWorkflowNodeRun{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + for _, id := range []int64{12, 23, 99} { + if err := sqls.DB().Create(&models.AIAgent{ID: id, Name: "agent", Status: enums.StatusOk}).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + } +} + +func validAIWorkflowDefinition() dsl.Definition { + return dsl.Definition{ + SchemaVersion: dsl.SchemaVersion, + Nodes: []dsl.Node{ + workflowServiceTestNode("start_1", "start", nil, nil), + workflowServiceTestNode("reply_1", "send_reply", map[string]dsl.Value{ + "replyText": dsl.RefValue("start_1", "userMessage"), + }, map[string]any{"text": "hello"}), + workflowServiceTestNode("end_1", "end", nil, nil), + }, + Edges: []dsl.Edge{ + workflowServiceTestEdge("start_1", "reply_1"), + workflowServiceTestEdge("reply_1", "end_1"), + }, + } +} + +func workflowServiceTestNode(id string, nodeType string, inputs map[string]dsl.Value, config any) dsl.Node { + return dsl.Node{ + ID: id, + Type: nodeType, + Meta: dsl.NodeMeta{Position: dsl.Position{X: 0, Y: 0}}, + Data: dsl.NodeData{ + Title: nodeType, + InputsValues: inputs, + Config: mustMarshalWorkflowServiceTestConfig(config), + }, + } +} + +func workflowServiceTestEdge(source string, target string) dsl.Edge { + return dsl.Edge{SourceNodeID: source, TargetNodeID: target} +} + +func mustMarshalWorkflowServiceTestConfig(value any) json.RawMessage { + if value == nil { + return nil + } + raw, err := json.Marshal(value) + if err != nil { + panic(err) + } + return raw +} + +func aiWorkflowTestOperator() *dto.AuthPrincipal { + return &dto.AuthPrincipal{ + UserID: 1, + Username: "workflow-tester", + Nickname: "workflow-tester", + } +} diff --git a/internal/services/asset_service.go b/internal/services/asset_service.go new file mode 100644 index 0000000..c82a6ad --- /dev/null +++ b/internal/services/asset_service.go @@ -0,0 +1,215 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + "agent-desk/internal/services/storage" + "bytes" + "io" + "mime/multipart" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mlogclub/simple/sqls" +) + +var AssetService = newAssetService() + +func newAssetService() *assetService { + return &assetService{} +} + +type assetService struct { +} + +func (s *assetService) Get(id int64) *models.Asset { + return repositories.AssetRepository.Get(sqls.DB(), id) +} + +func (s *assetService) GetByAssetID(assetID string) *models.Asset { + return repositories.AssetRepository.GetByAssetID(sqls.DB(), strings.TrimSpace(assetID)) +} + +func (s *assetService) GetByStorageKey(storageKey string) *models.Asset { + return repositories.AssetRepository.GetByStorageKey(sqls.DB(), strings.TrimSpace(storageKey)) +} + +func (s *assetService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Asset, paging *sqls.Paging) { + return repositories.AssetRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *assetService) OpenReader(asset *models.Asset) (io.ReadCloser, error) { + cfg := config.Current() + if asset == nil { + return nil, errorsx.InvalidParamI18n("error.e0146") + } + switch asset.Provider { + case "", enums.AssetProviderLocal: + return storage.NewLocalStorage(cfg.Storage.Local).Read(asset.StorageKey) + case enums.AssetProviderOSS: + return storage.NewOSSStorage(cfg.Storage.OSS).Read(asset.StorageKey) + default: + return nil, errorsx.InvalidParamI18n("error.e0195") + } +} + +func (s *assetService) UploadBytes(data []byte, prefix, filename string, principal *dto.AuthPrincipal) (*models.Asset, error) { + filename = storage.SanitizeFilename(filename) + mimeType, err := storage.ValidateUpload(filename, "", http.DetectContentType(data)) + if err != nil { + return nil, err + } + return s.Upload(bytes.NewReader(data), storage.UploadInfo{ + Prefix: prefix, + Filename: filename, + FileSize: int64(len(data)), + MimeType: mimeType, + Principal: principal, + }) +} + +func (s *assetService) UploadFile(file *multipart.FileHeader, prefix string, principal *dto.AuthPrincipal) (*models.Asset, error) { + if file == nil { + return nil, errorsx.InvalidParamI18n("error.e0323") + } + + cfg := config.Current() + if file.Size > cfg.Storage.MaxUploadSizeBytes() { + return nil, errorsx.InvalidParamI18n("error.e0079") + } + + src, err := file.Open() + if err != nil { + return nil, err + } + defer func() { _ = src.Close() }() + + sniffed, err := storage.SniffContentType(src) + if err != nil { + return nil, err + } + + filename := storage.SanitizeFilename(file.Filename) + mimeType, err := storage.ValidateUpload(filename, file.Header.Get("Content-Type"), sniffed) + if err != nil { + return nil, err + } + + return s.Upload(src, storage.UploadInfo{ + Prefix: prefix, + Filename: filename, + FileSize: file.Size, + MimeType: mimeType, + Principal: principal, + }) +} + +func (s *assetService) Upload(reader io.Reader, info storage.UploadInfo) (*models.Asset, error) { + provider, err := storage.GetDefault() + if err != nil { + return nil, err + } + + assetID, key := storage.GenerateStorageKey(info) + item := &models.Asset{ + AssetID: assetID, + Provider: provider.ProviderType(), + StorageKey: key, + Filename: info.Filename, + FileSize: info.FileSize, + MimeType: info.MimeType, + Status: enums.AssetStatusPending, + AuditFields: utils.BuildAuditFields(info.Principal), + } + if err := repositories.AssetRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + + if _, err := provider.Upload(reader, key, storage.UploadInfo{ + Prefix: info.Prefix, + Filename: info.Filename, + FileSize: info.FileSize, + MimeType: info.MimeType, + Principal: info.Principal, + }); err != nil { + _ = s.markAssetStatus(item.ID, enums.AssetStatusFailed, info.Principal) + return nil, err + } + + item.Status = enums.AssetStatusSuccess + _ = repositories.AssetRepository.UpdateColumn(sqls.DB(), item.ID, "status", enums.AssetStatusSuccess) + + return item, nil +} + +func (s *assetService) GetSignedURL(id int64) (string, error) { + item := s.Get(id) + if item == nil { + return "", errorsx.InvalidParamI18n("error.e0214") + } + if item.Status != enums.AssetStatusSuccess { + return "", errorsx.InvalidParamI18n("error.e0213") + } + + provider, err := storage.NewProvider(item.Provider) + if err != nil { + return "", err + } + accessURL := provider.GetSignedURL(item.StorageKey) + return accessURL, nil +} + +func (s *assetService) DeleteAsset(id int64, principal *dto.AuthPrincipal) error { + if principal == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + item := s.Get(id) + if item == nil { + return errorsx.InvalidParamI18n("error.e0214") + } + return repositories.AssetRepository.Updates(sqls.DB(), id, map[string]any{ + "status": enums.AssetStatusDeleted, + "update_user_id": principal.UserID, + "update_user_name": principal.Username, + "updated_at": time.Now(), + }) +} + +func (s *assetService) markAssetStatus(id int64, status enums.AssetStatus, principal *dto.AuthPrincipal) error { + updates := map[string]any{ + "status": status, + "updated_at": time.Now(), + } + if principal != nil { + updates["update_user_id"] = principal.UserID + updates["update_user_name"] = principal.Username + } + return repositories.AssetRepository.Updates(sqls.DB(), id, updates) +} + +func (s *assetService) buildFilenameFromMime(mimeType string) string { + mimeType = strings.TrimSpace(strings.Split(mimeType, ";")[0]) + ext := ".bin" + switch mimeType { + case "image/jpeg": + ext = ".jpg" + case "image/png": + ext = ".png" + case "image/gif": + ext = ".gif" + case "image/webp": + ext = ".webp" + case "application/pdf": + ext = ".pdf" + case "text/plain": + ext = ".txt" + } + return "upload_" + strings.ReplaceAll(uuid.NewString(), "-", "") + ext +} diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go new file mode 100644 index 0000000..f8cbbd3 --- /dev/null +++ b/internal/services/auth_service.go @@ -0,0 +1,542 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + "crypto/rand" + "encoding/hex" + "net/mail" + "slices" + "sort" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/mlogclub/simple/common/strs" + "github.com/mlogclub/simple/sqls" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +const ( + authPrincipalContextKey = "authPrincipal" +) + +var AuthService = newAuthService() + +func newAuthService() *authService { + return &authService{} +} + +type authService struct { +} + +func (s *authService) GetAuthPrincipal(ctx *gin.Context) *dto.AuthPrincipal { + if ctx == nil { + return nil + } + v, _ := ctx.Get(authPrincipalContextKey) + if principal, ok := v.(*dto.AuthPrincipal); ok { + return principal + } + return nil +} + +func (s *authService) setAuthPrincipal(ctx *gin.Context, user *models.User, roles, permissions []string) *dto.AuthPrincipal { + principal := &dto.AuthPrincipal{ + UserID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Avatar: user.Avatar, + UserType: user.UserType, + Status: user.Status, + Roles: roles, + Permissions: permissions, + } + ctx.Set(authPrincipalContextKey, principal) + return principal +} + +func (s *authService) RequirePermission(ctx *gin.Context, permission constants.Permission) (principal *dto.AuthPrincipal, err error) { + if principal = s.GetAuthPrincipal(ctx); principal == nil { + if principal, err = s.Authenticate(ctx); err != nil { + return nil, err + } + } + + if principal == nil { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + + if !s.HasPermission(ctx, permission.Code) { + return principal, errorsx.ForbiddenI18n("error.e0225") + } + return principal, nil +} + +func (s *authService) Login(req request.LoginRequest, authCfg config.AuthConfig, clientIP, userAgent string) (*response.LoginResponse, error) { + username := strings.TrimSpace(req.Username) + principal := normalizeLoginPrincipal(username) + password := req.Password + if username == "" || strings.TrimSpace(password) == "" { + return nil, errorsx.InvalidParamI18n("error.e0258") + } + + if s.isCredentialLocked(principal, clientIP, authCfg) { + _ = s.createLoginCredentialLog(principal, 0, false, clientIP, userAgent, "credential locked") + return nil, errorsx.CredentialLockedI18n("error.e0270") + } + + user := UserService.GetByUsername(username) + if user == nil || user.Status != enums.StatusOk { + _ = s.createLoginCredentialLog(principal, 0, false, clientIP, userAgent, "user not found") + return nil, errorsx.InvalidAccountI18n("error.e0260") + } + if strs.IsBlank(user.Password) || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil { + _ = s.createLoginCredentialLog(principal, user.ID, false, clientIP, userAgent, "password mismatch") + return nil, errorsx.InvalidAccountI18n("error.e0260") + } + + var ret *response.LoginResponse + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + var dbErr error + ret, dbErr = s.issueTokens(ctx, user, clientIP, userAgent, authCfg) + if dbErr != nil { + return dbErr + } + if dbErr = repositories.UserRepository.Updates(ctx.Tx, user.ID, map[string]any{ + "last_login_at": time.Now(), + "last_login_ip": clientIP, + "update_user_id": user.ID, + "update_user_name": user.Username, + "updated_at": time.Now(), + }); dbErr != nil { + return dbErr + } + return nil + }); err != nil { + return nil, err + } + + _ = s.createLoginCredentialLog(principal, user.ID, true, clientIP, userAgent, "") + return ret, nil +} + +func (s *authService) Logout(accessToken string) error { + accessToken = s.extractBearerToken(accessToken) + now := time.Now() + if accessToken != "" { + if session := LoginSessionService.FindOne(sqls.NewCnd().Eq("token", accessToken)); session != nil && session.RevokedAt == nil { + if err := LoginSessionService.Updates(session.ID, map[string]any{ + "revoked_at": now, + "updated_at": now, + }); err != nil { + return err + } + } + } + return nil +} + +func (s *authService) Authenticate(ctx *gin.Context) (*dto.AuthPrincipal, error) { + if principal := s.GetAuthPrincipal(ctx); principal != nil { + return principal, nil + } + + token := s.extractBearerToken(ctx.GetHeader("Authorization")) + if token == "" { + token = strings.TrimSpace(ctx.Query("accessToken")) + } + if token == "" { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + + session, err := s.validateSessionToken(token) + if err != nil { + return nil, err + } + + user := UserService.Get(session.UserID) + if user == nil || user.Status != enums.StatusOk { + return nil, errorsx.UnauthorizedI18n("error.e0256") + } + + roles, permissions, err := s.loadUserAuthScope(sqls.DB(), user.ID) + if err != nil { + return nil, err + } + principal := s.setAuthPrincipal(ctx, user, roles, permissions) + + now := time.Now() + _ = LoginSessionService.Updates(session.ID, map[string]any{ + "last_seen_at": now, + "updated_at": now, + }) + + return principal, nil +} + +func (s *authService) HasPermission(ctx *gin.Context, permissionCode string) bool { + principal := s.GetAuthPrincipal(ctx) + if principal == nil { + return false + } + return slices.Contains(principal.Permissions, permissionCode) +} + +func (s *authService) CurrentProfile(ctx *gin.Context) (*response.LoginResponse, error) { + principal, err := s.Authenticate(ctx) + if err != nil { + return nil, err + } + + user := UserService.Get(principal.UserID) + if user == nil || user.Status != enums.StatusOk { + return nil, errorsx.UnauthorizedI18n("error.e0256") + } + + return &response.LoginResponse{ + User: &response.AuthUserResponse{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Avatar: user.Avatar, + Email: derefString(user.Email), + UserType: user.UserType, + Status: user.Status, + Roles: principal.Roles, + }, + Permissions: principal.Permissions, + Roles: principal.Roles, + }, nil +} + +func (s *authService) UpdateProfile(ctx *gin.Context, req request.UpdateProfileRequest) (*response.LoginResponse, error) { + principal, err := s.Authenticate(ctx) + if err != nil { + return nil, err + } + user := UserService.Get(principal.UserID) + if user == nil || user.DeletedAt != nil || user.Status != enums.StatusOk { + return nil, errorsx.UnauthorizedI18n("error.e0256") + } + + nickname := strings.TrimSpace(req.Nickname) + if nickname == "" { + return nil, errorsx.InvalidParamI18n("error.profile.nicknameRequired") + } + if len([]rune(nickname)) > 100 { + return nil, errorsx.InvalidParamI18n("error.profile.nicknameTooLong") + } + avatar := strings.TrimSpace(req.Avatar) + if len(avatar) > 255 { + return nil, errorsx.InvalidParamI18n("error.profile.avatarTooLong") + } + email := utils.NormalizeNullableString(req.Email) + if email != nil { + if _, parseErr := mail.ParseAddress(*email); parseErr != nil { + return nil, errorsx.InvalidParamI18n("error.profile.emailInvalid") + } + if existed := UserService.GetByEmail(*email); existed != nil && existed.ID != user.ID { + return nil, errorsx.InvalidParamI18n("error.e0338") + } + } + + if err := repositories.UserRepository.Updates(sqls.DB(), user.ID, map[string]any{ + "nickname": nickname, + "avatar": avatar, + "email": email, + "update_user_id": user.ID, + "update_user_name": user.Username, + "updated_at": time.Now(), + }); err != nil { + return nil, err + } + return s.CurrentProfile(ctx) +} + +func (s *authService) GetUserRoles(userID int64) ([]models.Role, error) { + return s.loadUserRoles(sqls.DB(), userID) +} + +func (s *authService) GetUserPermissions(userID int64) ([]string, error) { + return s.loadUserPermissionCodes(sqls.DB(), userID) +} + +func (s *authService) issueTokens(ctx *sqls.TxContext, user *models.User, clientIP, userAgent string, authCfg config.AuthConfig) (*response.LoginResponse, error) { + roles, permissions, err := s.loadUserAuthScope(ctx.Tx, user.ID) + if err != nil { + return nil, err + } + + tokenTTL := s.resolveTokenTTL(authCfg) + accessToken, err := randomToken(constants.AuthTokenPrefix) + if err != nil { + return nil, err + } + + now := time.Now() + if err := repositories.LoginSessionRepository.Create(ctx.Tx, &models.LoginSession{ + UserID: user.ID, + Token: accessToken, + ClientType: constants.ClientTypeAdminWeb, + ClientIP: clientIP, + UserAgent: userAgent, + ExpiredAt: now.Add(tokenTTL), + LastSeenAt: &now, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: user.ID, + CreateUserName: user.Username, + UpdatedAt: now, + UpdateUserID: user.ID, + UpdateUserName: user.Username, + }, + }); err != nil { + return nil, err + } + + return &response.LoginResponse{ + AccessToken: accessToken, + ExpiresAt: now.Add(tokenTTL).UTC().Format(time.RFC3339), + User: &response.AuthUserResponse{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Avatar: user.Avatar, + Email: derefString(user.Email), + UserType: user.UserType, + Status: user.Status, + Roles: roles, + }, + Permissions: permissions, + Roles: roles, + }, nil +} + +func (s *authService) resolveTokenTTL(authCfg config.AuthConfig) time.Duration { + tokenTTL := 12 * time.Hour + if authCfg.TokenTTLHours > 0 { + tokenTTL = time.Duration(authCfg.TokenTTLHours) * time.Hour + } + return tokenTTL +} + +func (s *authService) validateSessionToken(token string) (*models.LoginSession, error) { + if strings.TrimSpace(token) == "" { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + session := LoginSessionService.FindOne(sqls.NewCnd().Eq("token", token)) + if session == nil { + return nil, errorsx.InvalidTokenI18n("error.e0269") + } + if session.RevokedAt != nil { + return nil, errorsx.InvalidTokenI18n("error.e0267") + } + if time.Now().After(session.ExpiredAt) { + return nil, errorsx.InvalidTokenI18n("error.e0268") + } + return session, nil +} + +func (s *authService) loadUserAuthScope(tx *gorm.DB, userID int64) ([]string, []string, error) { + roleCodes, err := s.loadUserRoleCodes(tx, userID) + if err != nil { + return nil, nil, err + } + permissionCodes, err := s.loadUserPermissionCodes(tx, userID) + if err != nil { + return nil, nil, err + } + return roleCodes, permissionCodes, nil +} + +func (s *authService) loadUserRoleCodes(tx *gorm.DB, userID int64) ([]string, error) { + roles, err := s.loadUserRoles(tx, userID) + if err != nil { + return nil, err + } + roleCodes := make([]string, 0, len(roles)) + for _, role := range roles { + roleCodes = append(roleCodes, role.Code) + } + return roleCodes, nil +} + +func (s *authService) loadUserRoles(tx *gorm.DB, userID int64) ([]models.Role, error) { + roles := make([]models.Role, 0) + if err := tx. + Table("t_role AS r"). + Select("r.*"). + Joins("JOIN t_user_role AS ur ON ur.role_id = r.id"). + Where("ur.user_id = ? AND r.status = ?", userID, enums.StatusOk). + Order("r.sort_no ASC, r.id ASC"). + Scan(&roles).Error; err != nil { + return nil, err + } + + return roles, nil +} + +func (s *authService) loadUserPermissionCodes(tx *gorm.DB, userID int64) ([]string, error) { + permissionRows := make([]struct { + Code string + }, 0) + db := tx.Table("t_permission AS p"). + Select("DISTINCT p.code"). + Joins("JOIN t_role_permission AS rp ON rp.permission_id = p.id"). + Joins("JOIN t_user_role AS ur ON ur.role_id = rp.role_id"). + Where("ur.user_id = ?", userID). + Where("p.status = ?", enums.StatusOk) + if err := db.Scan(&permissionRows).Error; err != nil { + return nil, err + } + + permissionCodes := make([]string, 0, len(permissionRows)) + for _, permission := range permissionRows { + permissionCodes = append(permissionCodes, permission.Code) + } + + overrideRows := make([]struct { + Code string + Effect int + }, 0) + if err := tx. + Table("t_user_permission AS up"). + Select("p.code, up.effect"). + Joins("JOIN t_permission AS p ON p.id = up.permission_id"). + Where("up.user_id = ? AND (up.expired_at IS NULL OR up.expired_at > ?)", userID, time.Now()). + Scan(&overrideRows).Error; err != nil { + return nil, err + } + + permissionSet := make(map[string]bool, len(permissionCodes)) + for _, code := range permissionCodes { + permissionSet[code] = true + } + for _, override := range overrideRows { + if override.Effect < 0 { + delete(permissionSet, override.Code) + continue + } + permissionSet[override.Code] = true + } + + permissionCodes = permissionCodes[:0] + for code := range permissionSet { + permissionCodes = append(permissionCodes, code) + } + sort.Strings(permissionCodes) + return permissionCodes, nil +} + +func (s *authService) extractBearerToken(header string) string { + header = strings.TrimSpace(header) + if header == "" { + return "" + } + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "" + } + return strings.TrimSpace(parts[1]) +} + +func (s *authService) createLoginCredentialLog(principal string, userID int64, success bool, clientIP, userAgent, reason string) error { + return LoginCredentialLogService.Create(&models.LoginCredentialLog{ + Principal: principal, + UserID: userID, + Success: success, + ClientIP: normalizeClientIP(clientIP), + UserAgent: userAgent, + Reason: reason, + CreatedAt: time.Now(), + }) +} + +// isCredentialLocked reports whether this attempt should be refused before the +// password is even checked. +// +// Both windows are keyed on the client address as well as the username. Keying on +// the username alone - which is what this used to do - means anyone who knows a +// username can lock the real account out for the whole window, from anywhere, as +// often as they like: that is a denial of service dressed up as a protection. The +// principal-and-address window still stops one source grinding on one account, and +// the address-only window across all principals still stops credential stuffing, +// but a legitimate user arriving from a different address is no longer locked out +// by somebody else's failures. +// +// This is only sound because the address itself is trustworthy; see the trusted +// proxy configuration in bootstrap.NewServer. +func (s *authService) isCredentialLocked(principal, clientIP string, authCfg config.AuthConfig) bool { + lockMinute := authCfg.CredentialLockMinute + if lockMinute <= 0 { + lockMinute = 15 + } + since := time.Now().Add(-time.Duration(lockMinute) * time.Minute) + ip := normalizeClientIP(clientIP) + + base := func() *sqls.Cnd { + return sqls.NewCnd(). + Eq("client_ip", ip). + Eq("success", false). + NotEq("reason", "credential locked"). + Where("created_at >= ?", since) + } + + if maxFailedAttempts := authCfg.MaxFailedAttempts; maxFailedAttempts > 0 { + count := LoginCredentialLogService.Count(base().Eq("principal", normalizeLoginPrincipal(principal))) + if count >= int64(maxFailedAttempts) { + return true + } + } + + // An undetermined address would otherwise pool every such caller into one + // bucket and lock them all out together. + if maxPerIP := authCfg.MaxFailedAttemptsPerIPOrDefault(); maxPerIP > 0 && ip != unknownClientIP { + if count := LoginCredentialLogService.Count(base()); count >= int64(maxPerIP) { + return true + } + } + + return false +} + +// unknownClientIP stands in for an address the server could not determine, so +// those attempts share one bucket on the principal window instead of matching +// nothing at all. +const unknownClientIP = "unknown" + +func normalizeClientIP(clientIP string) string { + if clientIP = strings.TrimSpace(clientIP); clientIP == "" { + return unknownClientIP + } + return clientIP +} + +func normalizeLoginPrincipal(principal string) string { + return strings.ToLower(strings.TrimSpace(principal)) +} + +func derefString(value *string) string { + if value == nil { + return "" + } + return *value +} + +func randomToken(prefix string) (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return prefix + hex.EncodeToString(buf), nil +} diff --git a/internal/services/auth_service_test.go b/internal/services/auth_service_test.go new file mode 100644 index 0000000..d137323 --- /dev/null +++ b/internal/services/auth_service_test.go @@ -0,0 +1,521 @@ +package services + +import ( + "errors" + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "github.com/mlogclub/simple/web" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestExtractBearerToken(t *testing.T) { + svc := newAuthService() + + if got := svc.extractBearerToken("Bearer token_123"); got != "token_123" { + t.Fatalf("expected bearer token to be extracted, got %q", got) + } + + if got := svc.extractBearerToken("token_123"); got != "" { + t.Fatalf("expected raw token to be rejected by bearer extractor, got %q", got) + } +} + +func TestAuthServiceLoginCreatesSingleAccessSession(t *testing.T) { + db := setupAuthServiceTestDB(t) + user := createAuthTestUser(t, db, "admin", "secret") + svc := newAuthService() + + ret, err := svc.Login(request.LoginRequest{ + Username: " admin ", + Password: "secret", + }, config.AuthConfig{TokenTTLHours: 2, MaxFailedAttempts: 5, CredentialLockMinute: 15}, "127.0.0.1", "go-test") + if err != nil { + t.Fatalf("login failed: %v", err) + } + + if ret.AccessToken == "" || !strings.HasPrefix(ret.AccessToken, "ak_") { + t.Fatalf("expected ak_ access token, got %q", ret.AccessToken) + } + if ret.ExpiresAt == "" { + t.Fatal("expected expiresAt to be returned") + } + + var sessions []models.LoginSession + if err := db.Find(&sessions).Error; err != nil { + t.Fatalf("query login sessions: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("expected exactly one session, got %d", len(sessions)) + } + if sessions[0].Token != ret.AccessToken { + t.Fatalf("expected session token %q, got %q", ret.AccessToken, sessions[0].Token) + } + if sessions[0].UserID != user.ID { + t.Fatalf("expected session user %d, got %d", user.ID, sessions[0].UserID) + } + if sessions[0].ClientType != "admin_web" { + t.Fatalf("expected admin_web client type, got %q", sessions[0].ClientType) + } + + logs := findCredentialLogs(t, db) + if len(logs) != 1 { + t.Fatalf("expected one credential log, got %d", len(logs)) + } + if !logs[0].Success || logs[0].Principal != "admin" || logs[0].UserID != user.ID { + t.Fatalf("unexpected success credential log: %+v", logs[0]) + } +} + +func TestAuthServiceLoginFailureWritesCredentialLogs(t *testing.T) { + db := setupAuthServiceTestDB(t) + createAuthTestUser(t, db, "admin", "secret") + svc := newAuthService() + authCfg := config.AuthConfig{TokenTTLHours: 2, MaxFailedAttempts: 5, CredentialLockMinute: 15} + + if _, err := svc.Login(request.LoginRequest{Username: "missing", Password: "secret"}, authCfg, "127.0.0.1", "go-test"); !hasCode(err, errorsx.CodeAuthInvalidAccount) { + t.Fatalf("expected invalid account for missing user, got %v", err) + } + if _, err := svc.Login(request.LoginRequest{Username: "admin", Password: "wrong"}, authCfg, "127.0.0.1", "go-test"); !hasCode(err, errorsx.CodeAuthInvalidAccount) { + t.Fatalf("expected invalid account for password mismatch, got %v", err) + } + + logs := findCredentialLogs(t, db) + if len(logs) != 2 { + t.Fatalf("expected two credential logs, got %d", len(logs)) + } + if logs[0].Reason != "user not found" || logs[0].Success { + t.Fatalf("unexpected missing-user log: %+v", logs[0]) + } + if logs[1].Reason != "password mismatch" || logs[1].Success { + t.Fatalf("unexpected password-mismatch log: %+v", logs[1]) + } +} + +func TestAuthServiceLoginCredentialLockout(t *testing.T) { + db := setupAuthServiceTestDB(t) + user := createAuthTestUser(t, db, "admin", "secret") + now := time.Now() + for i := 0; i < 2; i++ { + if err := db.Create(&models.LoginCredentialLog{ + Principal: "admin", + UserID: user.ID, + Success: false, + ClientIP: "127.0.0.1", + Reason: "password mismatch", + CreatedAt: now.Add(-time.Duration(i+1) * time.Minute), + }).Error; err != nil { + t.Fatalf("seed credential log: %v", err) + } + } + if err := db.Create(&models.LoginCredentialLog{ + Principal: "admin", + UserID: user.ID, + Success: false, + ClientIP: "127.0.0.1", + Reason: "password mismatch", + CreatedAt: now.Add(-30 * time.Minute), + }).Error; err != nil { + t.Fatalf("seed old credential log: %v", err) + } + + svc := newAuthService() + _, err := svc.Login(request.LoginRequest{Username: "admin", Password: "secret"}, config.AuthConfig{ + TokenTTLHours: 2, + MaxFailedAttempts: 2, + CredentialLockMinute: 15, + }, "127.0.0.1", "go-test") + if !hasCode(err, errorsx.CodeAuthCredentialLocked) { + t.Fatalf("expected credential locked error, got %v", err) + } + + var lockedLog models.LoginCredentialLog + if err := db.Order("id DESC").Take(&lockedLog).Error; err != nil { + t.Fatalf("query latest credential log: %v", err) + } + if lockedLog.Reason != "credential locked" || lockedLog.Success { + t.Fatalf("unexpected locked credential log: %+v", lockedLog) + } + + var sessionCount int64 + if err := db.Model(&models.LoginSession{}).Count(&sessionCount).Error; err != nil { + t.Fatalf("count sessions: %v", err) + } + if sessionCount != 0 { + t.Fatalf("expected no session while credential locked, got %d", sessionCount) + } +} + +func TestAuthServiceCredentialLockoutDoesNotExtendWhileLocked(t *testing.T) { + db := setupAuthServiceTestDB(t) + createAuthTestUser(t, db, "admin", "secret") + now := time.Now() + entries := []models.LoginCredentialLog{ + { + Principal: "admin", + UserID: 1, + Success: false, + ClientIP: "127.0.0.1", + Reason: "password mismatch", + CreatedAt: now.Add(-2 * time.Minute), + }, + { + Principal: "admin", + UserID: 0, + Success: false, + ClientIP: "127.0.0.1", + Reason: "credential locked", + CreatedAt: now.Add(-1 * time.Minute), + }, + } + if err := db.Create(&entries).Error; err != nil { + t.Fatalf("seed credential logs: %v", err) + } + + ret, err := newAuthService().Login(request.LoginRequest{Username: "admin", Password: "secret"}, config.AuthConfig{ + TokenTTLHours: 2, + MaxFailedAttempts: 2, + CredentialLockMinute: 15, + }, "127.0.0.1", "go-test") + if err != nil { + t.Fatalf("expected locked attempt logs not to extend lockout, got %v", err) + } + if ret == nil || !strings.HasPrefix(ret.AccessToken, "ak_") { + t.Fatalf("expected login response with ak_ token, got %+v", ret) + } +} + +func TestAuthServiceCredentialLockoutNormalizesPrincipalCase(t *testing.T) { + db := setupAuthServiceTestDB(t) + createAuthTestUser(t, db, "admin", "secret") + if err := db.Create(&models.LoginCredentialLog{ + Principal: "admin", + UserID: 1, + Success: false, + ClientIP: "127.0.0.1", + Reason: "password mismatch", + CreatedAt: time.Now().Add(-time.Minute), + }).Error; err != nil { + t.Fatalf("seed credential log: %v", err) + } + + _, err := newAuthService().Login(request.LoginRequest{Username: "ADMIN", Password: "secret"}, config.AuthConfig{ + TokenTTLHours: 2, + MaxFailedAttempts: 1, + CredentialLockMinute: 15, + }, "127.0.0.1", "go-test") + if !hasCode(err, errorsx.CodeAuthCredentialLocked) { + t.Fatalf("expected normalized principal to be locked, got %v", err) + } + + var lockedLog models.LoginCredentialLog + if err := db.Order("id DESC").Take(&lockedLog).Error; err != nil { + t.Fatalf("query latest credential log: %v", err) + } + if lockedLog.Principal != "admin" || lockedLog.Reason != "credential locked" { + t.Fatalf("unexpected locked log: %+v", lockedLog) + } +} + +// TestAuthServiceCredentialLockoutIsScopedToTheClientAddress is the regression +// test for the denial of service. Keyed on the username alone, anybody who knew a +// username could lock the real account out for the whole window, from anywhere, +// as often as they liked. +func TestAuthServiceCredentialLockoutIsScopedToTheClientAddress(t *testing.T) { + db := setupAuthServiceTestDB(t) + user := createAuthTestUser(t, db, "admin", "secret") + now := time.Now() + for i := 0; i < 5; i++ { + if err := db.Create(&models.LoginCredentialLog{ + Principal: "admin", + UserID: user.ID, + Success: false, + ClientIP: "203.0.113.66", + Reason: "password mismatch", + CreatedAt: now.Add(-time.Duration(i+1) * time.Minute), + }).Error; err != nil { + t.Fatalf("seed credential log: %v", err) + } + } + + authCfg := config.AuthConfig{TokenTTLHours: 2, MaxFailedAttempts: 3, CredentialLockMinute: 15} + + if _, err := newAuthService().Login(request.LoginRequest{Username: "admin", Password: "secret"}, authCfg, "203.0.113.66", "go-test"); !hasCode(err, errorsx.CodeAuthCredentialLocked) { + t.Fatalf("expected the attacking address to be locked, got %v", err) + } + + ret, err := newAuthService().Login(request.LoginRequest{Username: "admin", Password: "secret"}, authCfg, "198.51.100.7", "go-test") + if err != nil { + t.Fatalf("the legitimate owner was locked out by somebody else's failures: %v", err) + } + if ret == nil || ret.AccessToken == "" { + t.Fatalf("expected a session for the legitimate owner, got %+v", ret) + } +} + +// TestAuthServiceCredentialLockoutByAddressAcrossPrincipals covers the window that +// replaces the removed account-wide lock: one address spraying many usernames. +func TestAuthServiceCredentialLockoutByAddressAcrossPrincipals(t *testing.T) { + db := setupAuthServiceTestDB(t) + createAuthTestUser(t, db, "admin", "secret") + now := time.Now() + for i, principal := range []string{"admin", "root", "operator", "support"} { + if err := db.Create(&models.LoginCredentialLog{ + Principal: principal, + UserID: 0, + Success: false, + ClientIP: "203.0.113.66", + Reason: "user not found", + CreatedAt: now.Add(-time.Duration(i+1) * time.Minute), + }).Error; err != nil { + t.Fatalf("seed credential log: %v", err) + } + } + + authCfg := config.AuthConfig{ + TokenTTLHours: 2, + MaxFailedAttempts: 10, + MaxFailedAttemptsPerIP: 4, + CredentialLockMinute: 15, + } + + // No single username has reached MaxFailedAttempts, so only the per-address + // window can catch this. + if _, err := newAuthService().Login(request.LoginRequest{Username: "admin", Password: "secret"}, authCfg, "203.0.113.66", "go-test"); !hasCode(err, errorsx.CodeAuthCredentialLocked) { + t.Fatalf("expected credential stuffing from one address to be locked, got %v", err) + } + + if _, err := newAuthService().Login(request.LoginRequest{Username: "admin", Password: "secret"}, authCfg, "198.51.100.7", "go-test"); err != nil { + t.Fatalf("an unrelated address was locked by another address's failures: %v", err) + } +} + +func TestAuthServiceCredentialLockoutDisabledWhenMaxAttemptsNonPositive(t *testing.T) { + db := setupAuthServiceTestDB(t) + user := createAuthTestUser(t, db, "admin", "secret") + now := time.Now() + for i := 0; i < 3; i++ { + if err := db.Create(&models.LoginCredentialLog{ + Principal: "admin", + UserID: user.ID, + Success: false, + ClientIP: "127.0.0.1", + Reason: "credential locked", + CreatedAt: now.Add(-time.Duration(i+1) * time.Minute), + }).Error; err != nil { + t.Fatalf("seed credential log: %v", err) + } + } + + ret, err := newAuthService().Login(request.LoginRequest{Username: "admin", Password: "secret"}, config.AuthConfig{ + TokenTTLHours: 2, + MaxFailedAttempts: 0, + CredentialLockMinute: 15, + }, "127.0.0.1", "go-test") + if err != nil { + t.Fatalf("expected lockout to be disabled, got %v", err) + } + if ret == nil || ret.AccessToken == "" { + t.Fatalf("expected login response with access token, got %+v", ret) + } +} + +func TestValidateSessionTokenStates(t *testing.T) { + db := setupAuthServiceTestDB(t) + svc := newAuthService() + now := time.Now() + + if _, err := svc.validateSessionToken(" "); !hasCode(err, errorsx.CodeAuthUnauthorized) { + t.Fatalf("expected unauthorized for empty token, got %v", err) + } + if _, err := svc.validateSessionToken("missing"); !hasCode(err, errorsx.CodeAuthInvalidToken) { + t.Fatalf("expected invalid token for missing session, got %v", err) + } + + revokedAt := now + if err := db.Create(&models.LoginSession{ + UserID: 1, + Token: "ak_revoked", + ClientType: "admin_web", + ExpiredAt: now.Add(time.Hour), + RevokedAt: &revokedAt, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + }).Error; err != nil { + t.Fatalf("seed revoked session: %v", err) + } + if _, err := svc.validateSessionToken("ak_revoked"); !hasCode(err, errorsx.CodeAuthInvalidToken) { + t.Fatalf("expected invalid token for revoked session, got %v", err) + } + + if err := db.Create(&models.LoginSession{ + UserID: 1, + Token: "ak_expired", + ClientType: "admin_web", + ExpiredAt: now.Add(-time.Hour), + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + }).Error; err != nil { + t.Fatalf("seed expired session: %v", err) + } + if _, err := svc.validateSessionToken("ak_expired"); !hasCode(err, errorsx.CodeAuthInvalidToken) { + t.Fatalf("expected invalid token for expired session, got %v", err) + } + + if err := db.Create(&models.LoginSession{ + UserID: 1, + Token: "ak_valid", + ClientType: "admin_web", + ExpiredAt: now.Add(time.Hour), + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + }).Error; err != nil { + t.Fatalf("seed valid session: %v", err) + } + session, err := svc.validateSessionToken("ak_valid") + if err != nil { + t.Fatalf("expected valid session token, got %v", err) + } + if session.Token != "ak_valid" { + t.Fatalf("expected valid session token ak_valid, got %q", session.Token) + } +} + +func TestAuthServiceLogoutRevokesCurrentTokenOnly(t *testing.T) { + db := setupAuthServiceTestDB(t) + now := time.Now() + sessions := []models.LoginSession{ + { + UserID: 1, + Token: "ak_current", + ClientType: "admin_web", + ExpiredAt: now.Add(time.Hour), + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + }, + { + UserID: 1, + Token: "ak_other", + ClientType: "admin_web", + ExpiredAt: now.Add(time.Hour), + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + }, + } + if err := db.Create(&sessions).Error; err != nil { + t.Fatalf("seed sessions: %v", err) + } + + if err := newAuthService().Logout("Bearer ak_current"); err != nil { + t.Fatalf("logout failed: %v", err) + } + + var current models.LoginSession + if err := db.Take(¤t, "token = ?", "ak_current").Error; err != nil { + t.Fatalf("query current session: %v", err) + } + if current.RevokedAt == nil { + t.Fatal("expected current session to be revoked") + } + var other models.LoginSession + if err := db.Take(&other, "token = ?", "ak_other").Error; err != nil { + t.Fatalf("query other session: %v", err) + } + if other.RevokedAt != nil { + t.Fatal("expected other session to remain active") + } +} + +func setupAuthServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate( + &models.Organization{}, + &models.OrganizationMember{}, + &models.User{}, + &models.UserIdentity{}, + &models.Role{}, + &models.Permission{}, + &models.UserRole{}, + &models.RolePermission{}, + &models.UserPermission{}, + &models.LoginSession{}, + &models.LoginCredentialLog{}, + ); err != nil { + t.Fatalf("migrate auth tables: %v", err) + } + sqls.SetDB(db) + return db +} + +func createAuthTestUser(t *testing.T, db *gorm.DB, username, password string) *models.User { + t.Helper() + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("hash password: %v", err) + } + now := time.Now() + user := &models.User{ + Username: username, + Nickname: username, + Password: string(passwordHash), + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + } + if err := db.Create(user).Error; err != nil { + t.Fatalf("create auth test user: %v", err) + } + return user +} + +func findCredentialLogs(t *testing.T, db *gorm.DB) []models.LoginCredentialLog { + t.Helper() + var logs []models.LoginCredentialLog + if err := db.Order("id ASC").Find(&logs).Error; err != nil { + t.Fatalf("query credential logs: %v", err) + } + return logs +} + +func hasCode(err error, code int) bool { + if err == nil { + return false + } + var codeErr *web.CodeError + if errors.As(err, &codeErr) { + return codeErr.Code == code + } + return false +} diff --git a/internal/services/branding_service.go b/internal/services/branding_service.go new file mode 100644 index 0000000..8f895f6 --- /dev/null +++ b/internal/services/branding_service.go @@ -0,0 +1,142 @@ +package services + +import ( + "encoding/json" + "strings" + "sync" + "time" + + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +// Branding is the deployment's own name and logo, resolved for display. +type Branding struct { + CompanyName string + CompanyLogoURL string +} + +// brandingCacheTTL bounds how stale the resolved branding may be. The value is +// read on every page load through /api/config, so it is cached rather than +// queried each time, and a short TTL keeps a multi-instance deployment in step +// without a reload. +const brandingCacheTTL = 30 * time.Second + +var ( + brandingCacheMu sync.RWMutex + brandingCache Branding + brandingCachedAt time.Time +) + +// ResolveBranding returns the name and logo every surface should render. +// +// Precedence is deliberate: what an operator saved in the dashboard wins, then +// the configured defaults, then the translated product name. Configuration stays +// in the chain so a deployment that already sets COMPANY_NAME keeps its branding +// the moment this ships, without anyone copying it into the database. +// +// It never fails. This feeds the public /api/config endpoint, so a database +// problem must degrade to the configured or default name rather than take the +// whole interface down with it. +func ResolveBranding() Branding { + brandingCacheMu.RLock() + if !brandingCachedAt.IsZero() && time.Since(brandingCachedAt) < brandingCacheTTL { + cached := brandingCache + brandingCacheMu.RUnlock() + return cached + } + brandingCacheMu.RUnlock() + + resolved := loadBranding() + + brandingCacheMu.Lock() + brandingCache = resolved + brandingCachedAt = time.Now() + brandingCacheMu.Unlock() + return resolved +} + +// InvalidateBrandingCache drops the cached value so the next read goes back to +// the database. Called after a save. +func InvalidateBrandingCache() { + brandingCacheMu.Lock() + brandingCachedAt = time.Time{} + brandingCacheMu.Unlock() +} + +func loadBranding() Branding { + var configuredName, configuredLogo string + if current := config.GetCurrent(); current != nil { + configuredName = strings.TrimSpace(current.Server.CompanyName) + configuredLogo = strings.TrimSpace(current.Server.CompanyLogoURL) + } + + branding := Branding{CompanyName: configuredName, CompanyLogoURL: configuredLogo} + if name := readBrandingString(systemConfigKeyBrandingCompanyName); name != "" { + branding.CompanyName = name + } + if logo := readBrandingString(systemConfigKeyBrandingCompanyLogo); logo != "" { + branding.CompanyLogoURL = logo + } + if branding.CompanyName == "" { + branding.CompanyName = defaultBrandingCompanyName() + } + return branding +} + +// readBrandingString returns the saved value, or an empty string when nothing is +// stored, the database is unavailable, or the stored value cannot be read. +func readBrandingString(key string) string { + if sqls.DB() == nil { + return "" + } + item := repositories.SystemConfigRepository.FindByGroupAndKey( + sqls.DB(), systemConfigGroupBranding, key) + if item == nil { + return "" + } + var value string + if err := json.Unmarshal([]byte(item.ConfigValue), &value); err != nil { + return "" + } + return strings.TrimSpace(value) +} + +func (s *systemConfigService) GetDashboardBranding() response.DashboardBrandingResponse { + savedName := readBrandingString(systemConfigKeyBrandingCompanyName) + savedLogo := readBrandingString(systemConfigKeyBrandingCompanyLogo) + + var configuredName, configuredLogo string + if current := config.GetCurrent(); current != nil { + configuredName = strings.TrimSpace(current.Server.CompanyName) + configuredLogo = strings.TrimSpace(current.Server.CompanyLogoURL) + } + + return response.DashboardBrandingResponse{ + // The form shows what is actually in effect, not only what is stored, so an + // operator who has never opened this page sees the real current state. + CompanyName: firstNonBlank(savedName, configuredName), + CompanyLogoURL: firstNonBlank(savedLogo, configuredLogo), + SavedCompanyName: savedName, + SavedCompanyLogoURL: savedLogo, + ConfiguredCompanyName: configuredName, + ConfiguredCompanyLogo: configuredLogo, + EffectiveCompanyName: ResolveBranding().CompanyName, + UsesConfiguredFallback: savedName == "" && configuredName != "", + } +} + +// SaveBranding stores the operator's values. An empty string clears the saved +// value and hands control back to configuration, which is how an operator undoes +// an override without deleting rows by hand. +func (s *systemConfigService) SaveBranding(payload map[string]json.RawMessage, operator *dto.AuthPrincipal) (response.DashboardBrandingResponse, error) { + if err := s.SaveGroupConfig(systemConfigGroupBranding, payload, operator); err != nil { + return response.DashboardBrandingResponse{}, err + } + InvalidateBrandingCache() + return s.GetDashboardBranding(), nil +} diff --git a/internal/services/business_tool_executor.go b/internal/services/business_tool_executor.go new file mode 100644 index 0000000..64e2897 --- /dev/null +++ b/internal/services/business_tool_executor.go @@ -0,0 +1,152 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + aitooling "agent-desk/internal/ai/tooling" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/toolx" +) + +// BusinessToolExecutor is the write boundary for built-in business tools. +// AgentDesk services remain the write boundary. Workflow nodes invoke this +// executor only after their human-confirm node has completed. +var BusinessToolExecutor = newBusinessToolExecutor(aitooling.DefaultRegistry) + +type BusinessToolInput struct { + Conversation models.Conversation + AIAgent models.AIAgent + ToolCode string + Arguments map[string]any + IdempotencyKey string + Confirmed bool +} + +type BusinessToolResult struct { + Definition aitooling.Definition + ResultData string + Reused bool +} + +type businessToolExecutor struct { + registry *aitooling.Registry +} + +func newBusinessToolExecutor(registry *aitooling.Registry) *businessToolExecutor { + return &businessToolExecutor{registry: registry} +} + +func (e *businessToolExecutor) Execute(_ context.Context, input BusinessToolInput) (*BusinessToolResult, error) { + toolCode := toolx.NormalizeToolCodeAlias(strings.TrimSpace(input.ToolCode)) + definition, err := e.registry.Resolve(toolCode) + if err != nil { + return nil, err + } + if err := e.registry.Authorize(definition, aitooling.Policy{AllowedToolCodes: []string{definition.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelWrite}, Confirmed: input.Confirmed}); err != nil { + return nil, err + } + if input.Conversation.ID <= 0 || strings.TrimSpace(input.IdempotencyKey) == "" { + return nil, fmt.Errorf("business tool invocation requires conversation and idempotency key") + } + claim, err := AgentToolInvocationService.Claim(input.Conversation.ID, input.AIAgent.ID, definition.Code, input.IdempotencyKey) + if err != nil { + return nil, err + } + if claim == nil || claim.Item == nil { + return nil, fmt.Errorf("business tool invocation could not be claimed") + } + if claim.Completed { + return &BusinessToolResult{Definition: definition, ResultData: claim.Item.ResultData, Reused: true}, nil + } + if !claim.Acquired { + return nil, fmt.Errorf("business tool invocation is already running: %s", definition.Code) + } + + resultData, err := e.execute(definition.Code, input) + if err != nil { + _ = AgentToolInvocationService.Fail(claim.Item, err) + return nil, err + } + if err := AgentToolInvocationService.Complete(claim.Item, resultData); err != nil { + return nil, err + } + return &BusinessToolResult{Definition: definition, ResultData: resultData}, nil +} + +func (e *businessToolExecutor) execute(toolCode string, input BusinessToolInput) (string, error) { + switch toolCode { + case toolx.GraphCreateTicketConfirm.Code: + item, err := TicketService.CreateFromConversation(request.CreateTicketFromConversationRequest{ + ConversationID: input.Conversation.ID, + Title: businessToolString(input.Arguments["title"]), + Description: businessToolString(input.Arguments["description"]), + TagIDs: businessToolInt64Slice(input.Arguments["tagIds"]), + CurrentAssigneeID: businessToolInt64(input.Arguments["assigneeId"]), + }, businessToolPrincipal(input.AIAgent)) + if err != nil { + return "", err + } + return businessToolJSON(map[string]any{"ticketId": item.ID, "ticketNo": item.TicketNo, "created": true}) + case toolx.GraphHandoffConversation.Code: + result, err := ConversationHumanDispatchService.HandoffByAIWithRequestID(input.Conversation.ID, input.AIAgent, businessToolString(input.Arguments["reason"]), input.IdempotencyKey) + if err != nil { + return "", err + } + return businessToolJSON(map[string]any{"decision": result.Decision, "teamId": result.TeamID, "assigneeId": result.AssigneeID, "message": result.Message}) + default: + return "", fmt.Errorf("business tool is not executable: %s", toolCode) + } +} + +func businessToolString(value any) string { + text, _ := value.(string) + return strings.TrimSpace(text) +} + +func businessToolInt64(value any) int64 { + switch typed := value.(type) { + case int64: + return typed + case int: + return int64(typed) + case float64: + return int64(typed) + default: + return 0 + } +} + +func businessToolInt64Slice(value any) []int64 { + switch typed := value.(type) { + case []int64: + return typed + case []any: + ret := make([]int64, 0, len(typed)) + for _, item := range typed { + if id := businessToolInt64(item); id > 0 { + ret = append(ret, id) + } + } + return ret + default: + return nil + } +} + +func businessToolPrincipal(agent models.AIAgent) *dto.AuthPrincipal { + name := strings.TrimSpace(agent.Name) + if name == "" { + name = "AI" + } + return &dto.AuthPrincipal{Username: name, Nickname: name} +} + +func businessToolJSON(value any) (string, error) { + data, err := json.Marshal(value) + return string(data), err +} diff --git a/internal/services/channel_message_outbox_service.go b/internal/services/channel_message_outbox_service.go new file mode 100644 index 0000000..bec7f42 --- /dev/null +++ b/internal/services/channel_message_outbox_service.go @@ -0,0 +1,426 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "encoding/json" + "log/slog" + "strings" + "time" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var ChannelMessageOutboxService = newChannelMessageOutboxService() + +func newChannelMessageOutboxService() *channelMessageOutboxService { + return &channelMessageOutboxService{} +} + +type channelMessageOutboxService struct { +} + +func (s *channelMessageOutboxService) Get(id int64) *models.ChannelMessageOutbox { + return repositories.ChannelMessageOutboxRepository.Get(sqls.DB(), id) +} + +func (s *channelMessageOutboxService) Take(where ...interface{}) *models.ChannelMessageOutbox { + return repositories.ChannelMessageOutboxRepository.Take(sqls.DB(), where...) +} + +func (s *channelMessageOutboxService) Find(cnd *sqls.Cnd) []models.ChannelMessageOutbox { + return repositories.ChannelMessageOutboxRepository.Find(sqls.DB(), cnd) +} + +func (s *channelMessageOutboxService) FindOne(cnd *sqls.Cnd) *models.ChannelMessageOutbox { + return repositories.ChannelMessageOutboxRepository.FindOne(sqls.DB(), cnd) +} + +func (s *channelMessageOutboxService) FindPageByParams(params *params.QueryParams) (list []models.ChannelMessageOutbox, paging *sqls.Paging) { + return repositories.ChannelMessageOutboxRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *channelMessageOutboxService) FindPageByCnd(cnd *sqls.Cnd) (list []models.ChannelMessageOutbox, paging *sqls.Paging) { + return repositories.ChannelMessageOutboxRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *channelMessageOutboxService) Count(cnd *sqls.Cnd) int64 { + return repositories.ChannelMessageOutboxRepository.Count(sqls.DB(), cnd) +} + +func (s *channelMessageOutboxService) Create(t *models.ChannelMessageOutbox) error { + return repositories.ChannelMessageOutboxRepository.Create(sqls.DB(), t) +} + +func (s *channelMessageOutboxService) Update(t *models.ChannelMessageOutbox) error { + return repositories.ChannelMessageOutboxRepository.Update(sqls.DB(), t) +} + +func (s *channelMessageOutboxService) Updates(id int64, columns map[string]interface{}) error { + return repositories.ChannelMessageOutboxRepository.Updates(sqls.DB(), id, columns) +} + +func (s *channelMessageOutboxService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.ChannelMessageOutboxRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *channelMessageOutboxService) Delete(id int64) { + repositories.ChannelMessageOutboxRepository.Delete(sqls.DB(), id) +} + +// GetByMessageID retrieves the outbox entry by message ID and channel type. +func (s *channelMessageOutboxService) GetByMessageID(channelType string, messageID int64) *models.ChannelMessageOutbox { + return repositories.ChannelMessageOutboxRepository.Take(sqls.DB(), "channel_type = ? AND message_id = ?", channelType, messageID) +} + +func (s *channelMessageOutboxService) EnqueueTelegramMessage(conversation *models.Conversation, message *models.Message) error { + if conversation == nil || message == nil { + return nil + } + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.ChannelType != enums.ChannelTypeTelegram { + return nil + } + if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { + return nil + } + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML { + return nil + } + if existing := s.GetByMessageID(enums.ChannelTypeTelegram, message.ID); existing != nil { + return nil + } + + payload, err := json.Marshal(map[string]any{ + "conversationId": conversation.ID, + "messageId": message.ID, + "messageType": message.MessageType, + "content": strings.TrimSpace(message.Content), + "payload": strings.TrimSpace(message.Payload), + "senderId": message.SenderID, + }) + if err != nil { + return err + } + + now := time.Now() + err = s.Create(&models.ChannelMessageOutbox{ + ChannelType: enums.ChannelTypeTelegram, + ConversationID: conversation.ID, + MessageID: message.ID, + Payload: string(payload), + SendStatus: string(enums.ChannelMessageOutboxStatusPending), + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: message.UpdateUserID, + CreateUserName: message.UpdateUserName, + UpdatedAt: now, + UpdateUserID: message.UpdateUserID, + UpdateUserName: message.UpdateUserName, + }, + }) + if err != nil { + return err + } + + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("recovered from panic in telegram outbound dispatch", "error", r) + } + }() + TelegramOutboundService.DispatchPendingOutbox() + }() + + return nil +} + +func (s *channelMessageOutboxService) EnqueueZaloOAMessage(conversation *models.Conversation, message *models.Message) error { + if conversation == nil || message == nil { + return nil + } + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.ChannelType != enums.ChannelTypeZaloOA { + return nil + } + if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { + return nil + } + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML { + return nil + } + if existing := s.GetByMessageID(enums.ChannelTypeZaloOA, message.ID); existing != nil { + return nil + } + + payload, err := json.Marshal(map[string]any{ + "conversationId": conversation.ID, + "messageId": message.ID, + "messageType": message.MessageType, + "content": strings.TrimSpace(message.Content), + "payload": strings.TrimSpace(message.Payload), + "senderId": message.SenderID, + }) + if err != nil { + return err + } + + now := time.Now() + err = s.Create(&models.ChannelMessageOutbox{ + ChannelType: enums.ChannelTypeZaloOA, + ConversationID: conversation.ID, + MessageID: message.ID, + Payload: string(payload), + SendStatus: string(enums.ChannelMessageOutboxStatusPending), + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: message.UpdateUserID, + CreateUserName: message.UpdateUserName, + UpdatedAt: now, + UpdateUserID: message.UpdateUserID, + UpdateUserName: message.UpdateUserName, + }, + }) + if err != nil { + return err + } + + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("recovered from panic in zalo oa outbound dispatch", "error", r) + } + }() + ZaloOAOutboundService.DispatchPendingOutbox() + }() + + return nil +} + +func (s *channelMessageOutboxService) EnqueueSlackMessage(conversation *models.Conversation, message *models.Message) error { + if conversation == nil || message == nil { + return nil + } + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.ChannelType != enums.ChannelTypeSlack { + return nil + } + if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { + return nil + } + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { + return nil + } + if existing := s.GetByMessageID(enums.ChannelTypeSlack, message.ID); existing != nil { + return nil + } + + payload, err := json.Marshal(map[string]any{ + "conversationId": conversation.ID, + "messageId": message.ID, + "messageType": message.MessageType, + "content": strings.TrimSpace(message.Content), + "payload": strings.TrimSpace(message.Payload), + "senderId": message.SenderID, + }) + if err != nil { + return err + } + + now := time.Now() + err = s.Create(&models.ChannelMessageOutbox{ + ChannelType: enums.ChannelTypeSlack, + ConversationID: conversation.ID, + MessageID: message.ID, + Payload: string(payload), + SendStatus: string(enums.ChannelMessageOutboxStatusPending), + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: message.UpdateUserID, + CreateUserName: message.UpdateUserName, + UpdatedAt: now, + UpdateUserID: message.UpdateUserID, + UpdateUserName: message.UpdateUserName, + }, + }) + if err != nil { + return err + } + + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("recovered from panic in slack outbound dispatch", "error", r) + } + }() + SlackOutboundService.DispatchPendingOutbox() + }() + + return nil +} + +func (s *channelMessageOutboxService) EnqueueEmailMessage(conversation *models.Conversation, message *models.Message) error { + if conversation == nil { + return nil + } + return s.EnqueueEmailMessageVia(ChannelService.Get(conversation.ChannelID), conversation, message) +} + +// EnqueueEmailMessageVia queues a message for email delivery through an explicit +// channel. +// +// The channel is a parameter because the delivery medium and the conversation's +// channel are not always the same thing. Telling a visitor that a request they +// sent by email landed in a chat they left has to go out over email even though +// that conversation belongs to the web channel; deriving the channel from the +// conversation, as the original code did, silently dropped exactly those notices. +func (s *channelMessageOutboxService) EnqueueEmailMessageVia(channel *models.Channel, conversation *models.Conversation, message *models.Message) error { + if conversation == nil || message == nil { + return nil + } + if channel == nil || channel.ChannelType != enums.ChannelTypeEmail { + return nil + } + if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { + return nil + } + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML { + return nil + } + if existing := s.GetByMessageID(enums.ChannelTypeEmail, message.ID); existing != nil { + return nil + } + + payload, err := json.Marshal(map[string]any{ + "conversationId": conversation.ID, + "messageId": message.ID, + "messageType": message.MessageType, + "content": strings.TrimSpace(message.Content), + "payload": strings.TrimSpace(message.Payload), + "senderId": message.SenderID, + }) + if err != nil { + return err + } + + now := time.Now() + err = s.Create(&models.ChannelMessageOutbox{ + ChannelType: enums.ChannelTypeEmail, + ConversationID: conversation.ID, + MessageID: message.ID, + Payload: string(payload), + SendStatus: string(enums.ChannelMessageOutboxStatusPending), + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: message.UpdateUserID, + CreateUserName: message.UpdateUserName, + UpdatedAt: now, + UpdateUserID: message.UpdateUserID, + UpdateUserName: message.UpdateUserName, + }, + }) + if err != nil { + return err + } + + // Trigger async dispatch immediately + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("recovered from panic in email outbound dispatch", "error", r) + } + }() + EmailOutboundService.DispatchPendingOutbox() + }() + + return nil +} + +func (s *channelMessageOutboxService) EnqueueDiscordMessage(conversation *models.Conversation, message *models.Message) error { + if conversation == nil || message == nil { + return nil + } + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.ChannelType != enums.ChannelTypeDiscord { + return nil + } + if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { + return nil + } + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { + return nil + } + if existing := s.GetByMessageID(enums.ChannelTypeDiscord, message.ID); existing != nil { + return nil + } + + payload, err := json.Marshal(map[string]any{ + "conversationId": conversation.ID, + "messageId": message.ID, + "messageType": message.MessageType, + "content": strings.TrimSpace(message.Content), + "payload": strings.TrimSpace(message.Payload), + "senderId": message.SenderID, + }) + if err != nil { + return err + } + + now := time.Now() + err = s.Create(&models.ChannelMessageOutbox{ + ChannelType: enums.ChannelTypeDiscord, + ConversationID: conversation.ID, + MessageID: message.ID, + Payload: string(payload), + SendStatus: string(enums.ChannelMessageOutboxStatusPending), + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: message.UpdateUserID, + CreateUserName: message.UpdateUserName, + UpdatedAt: now, + UpdateUserID: message.UpdateUserID, + UpdateUserName: message.UpdateUserName, + }, + }) + if err != nil { + return err + } + + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("recovered from panic in discord outbound dispatch", "error", r) + } + }() + DiscordOutboundService.DispatchPendingOutbox() + }() + + return nil +} + +func (s *channelMessageOutboxService) ListPending(channelType string, limit int) []models.ChannelMessageOutbox { + if limit <= 0 { + limit = 20 + } + now := time.Now() + cnd := sqls.NewCnd(). + Eq("channel_type", strings.TrimSpace(channelType)). + In("send_status", []string{ + string(enums.ChannelMessageOutboxStatusPending), + string(enums.ChannelMessageOutboxStatusFailed), + }). + Asc("next_retry_at"). + Asc("id"). + Limit(limit) + // A row with no next_retry_at has never been scheduled, so it is due + // immediately. Comparing it directly would not work: NULL <= ? is unknown in + // SQL, never true, so a plain Lte silently drops every freshly queued message + // for every channel. Only rows whose backoff has elapsed are held back, and + // ordering by next_retry_at keeps a backlog of not-yet-due retries from + // starving newer pending sends. + cnd.Where("next_retry_at IS NULL OR next_retry_at <= ?", now) + return s.Find(cnd) +} diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go new file mode 100644 index 0000000..f5248b9 --- /dev/null +++ b/internal/services/channel_service.go @@ -0,0 +1,685 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/httpx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + "crypto/rand" + "encoding/base64" + "encoding/json" + "strings" + "time" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/gin-gonic/gin" + "github.com/mlogclub/simple/common/strs" + "github.com/mlogclub/simple/sqls" +) + +var ChannelService = newChannelService() + +func newChannelService() *channelService { + return &channelService{} +} + +type channelService struct { +} + +func (s *channelService) Get(id int64) *models.Channel { + return repositories.ChannelRepository.Get(sqls.DB(), id) +} + +func (s *channelService) Take(where ...interface{}) *models.Channel { + return repositories.ChannelRepository.Take(sqls.DB(), where...) +} + +func (s *channelService) Find(cnd *sqls.Cnd) []models.Channel { + return repositories.ChannelRepository.Find(sqls.DB(), cnd) +} + +func (s *channelService) FindOne(cnd *sqls.Cnd) *models.Channel { + return repositories.ChannelRepository.FindOne(sqls.DB(), cnd) +} + +func (s *channelService) FindPageByParams(params *params.QueryParams) (list []models.Channel, paging *sqls.Paging) { + return repositories.ChannelRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *channelService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Channel, paging *sqls.Paging) { + return repositories.ChannelRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *channelService) Count(cnd *sqls.Cnd) int64 { + return repositories.ChannelRepository.Count(sqls.DB(), cnd) +} + +func (s *channelService) Create(t *models.Channel) error { + return repositories.ChannelRepository.Create(sqls.DB(), t) +} + +func (s *channelService) Update(t *models.Channel) error { + return repositories.ChannelRepository.Update(sqls.DB(), t) +} + +func (s *channelService) Updates(id int64, columns map[string]interface{}) error { + return repositories.ChannelRepository.Updates(sqls.DB(), id, columns) +} + +func (s *channelService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.ChannelRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *channelService) CreateChannel(req request.CreateChannelRequest, operator *dto.AuthPrincipal) (*models.Channel, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + item, err := s.buildChannelModel(0, req) + if err != nil { + return nil, err + } + item.AuditFields = utils.BuildAuditFields(operator) + if err := repositories.ChannelRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return item, nil +} + +func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + current := s.Get(req.ID) + if current == nil || current.Status == enums.StatusDeleted { + return errorsx.InvalidParamI18n("error.e0208") + } + item, err := s.buildChannelModel(req.ID, req.CreateChannelRequest) + if err != nil { + return err + } + columns := map[string]any{ + "channel_type": item.ChannelType, + "channel_id": item.ChannelID, + "ai_agent_id": item.AIAgentID, + "ai_agent_rollout_percent": item.AIAgentRolloutPercent, + "name": item.Name, + "config_json": item.ConfigJSON, + "status": item.Status, + "remark": item.Remark, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + } + if item.AIAgentRolloutPercent != current.AIAgentRolloutPercent { + columns["previous_ai_agent_rollout_percent"] = current.AIAgentRolloutPercent + } + return repositories.ChannelRepository.Updates(sqls.DB(), req.ID, columns) +} + +// RollbackChannelAIAgentRollout restores the last channel-level rollout value +// and swaps it into history so the action itself is reversible. +func (s *channelService) RollbackChannelAIAgentRollout(id int64, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + if id <= 0 { + return errorsx.InvalidParam("channel id is required") + } + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + channel := repositories.ChannelRepository.Get(ctx.Tx, id) + if channel == nil || channel.Status == enums.StatusDeleted { + return errorsx.InvalidParamI18n("error.e0208") + } + if channel.PreviousAIAgentRolloutPercent < 1 || channel.PreviousAIAgentRolloutPercent > 100 { + return errorsx.InvalidParam("channel rollout has no previous value to restore") + } + return repositories.ChannelRepository.Updates(ctx.Tx, channel.ID, map[string]any{ + "ai_agent_rollout_percent": channel.PreviousAIAgentRolloutPercent, + "previous_ai_agent_rollout_percent": channel.AIAgentRolloutPercent, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) + }) +} + +func (s *channelService) UpdateStatus(id int64, status int, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + item := s.Get(id) + if item == nil || item.Status == enums.StatusDeleted { + return errorsx.InvalidParamI18n("error.e0208") + } + if status != int(enums.StatusOk) && status != int(enums.StatusDisabled) { + return errorsx.InvalidParamI18n("error.e0254") + } + return s.Updates(id, map[string]any{ + "status": status, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) +} + +func (s *channelService) DeleteChannel(id int64, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + item := s.Get(id) + if item == nil || item.Status == enums.StatusDeleted { + return errorsx.InvalidParamI18n("error.e0208") + } + return s.Updates(id, map[string]any{ + "status": enums.StatusDeleted, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) +} + +func (s *channelService) ParseWebChannelConfig(raw string) (*dto.WebChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.WebChannelConfig{ + Title: "Support", + Subtitle: "How can we help?", + ThemeColor: "#2563eb", + Position: "right", + Width: "380px", + } + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.Title = strings.TrimSpace(cfg.Title) + if cfg.Title == "" { + cfg.Title = "Support" + } + cfg.Subtitle = strings.TrimSpace(cfg.Subtitle) + cfg.ThemeColor = strings.TrimSpace(cfg.ThemeColor) + if cfg.ThemeColor == "" { + cfg.ThemeColor = "#2563eb" + } + cfg.Position = strings.TrimSpace(cfg.Position) + if cfg.Position == "" { + cfg.Position = "right" + } + if cfg.Position != "left" && cfg.Position != "right" { + return nil, errorsx.InvalidParamI18n("error.e0059") + } + cfg.Width = strings.TrimSpace(cfg.Width) + if cfg.Width == "" { + cfg.Width = "380px" + } + cfg.UserTokenSecret = strings.TrimSpace(cfg.UserTokenSecret) + return cfg, nil +} + +func (s *channelService) ParseTelegramChannelConfig(raw string) (*dto.TelegramChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.TelegramChannelConfig{} + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.BotToken = strings.TrimSpace(cfg.BotToken) + cfg.BotUsername = strings.TrimSpace(cfg.BotUsername) + cfg.WebhookSecret = strings.TrimSpace(cfg.WebhookSecret) + cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) + return cfg, nil +} + +func (s *channelService) ParseZaloOAChannelConfig(raw string) (*dto.ZaloOAChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.ZaloOAChannelConfig{} + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.AppID = strings.TrimSpace(cfg.AppID) + cfg.OAID = strings.TrimSpace(cfg.OAID) + cfg.SecretKey = strings.TrimSpace(cfg.SecretKey) + cfg.AccessToken = strings.TrimSpace(cfg.AccessToken) + cfg.RefreshToken = strings.TrimSpace(cfg.RefreshToken) + cfg.WebhookSecret = strings.TrimSpace(cfg.WebhookSecret) + cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) + return cfg, nil +} + +func (s *channelService) ParseSlackChannelConfig(raw string) (*dto.SlackChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.SlackChannelConfig{} + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.BotToken = strings.TrimSpace(cfg.BotToken) + cfg.SigningSecret = strings.TrimSpace(cfg.SigningSecret) + cfg.AppID = strings.TrimSpace(cfg.AppID) + cfg.TeamID = strings.TrimSpace(cfg.TeamID) + cfg.TeamName = strings.TrimSpace(cfg.TeamName) + cfg.DefaultChannel = strings.TrimSpace(cfg.DefaultChannel) + return cfg, nil +} + +func (s *channelService) ParseDiscordChannelConfig(raw string) (*dto.DiscordChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.DiscordChannelConfig{} + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.GuildID = strings.TrimSpace(cfg.GuildID) + cfg.GuildName = strings.TrimSpace(cfg.GuildName) + cfg.ChannelScope = strings.TrimSpace(cfg.ChannelScope) + cfg.BotToken = strings.TrimSpace(cfg.BotToken) + cfg.ApplicationID = strings.TrimSpace(cfg.ApplicationID) + cfg.PublicKey = strings.TrimSpace(cfg.PublicKey) + cfg.WebhookSecret = strings.TrimSpace(cfg.WebhookSecret) + cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) + return cfg, nil +} + +func (s *channelService) ParseEmailChannelConfig(raw string) (*dto.EmailChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.EmailChannelConfig{ + Provider: "smtp", + } + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.EmailAddress = strings.ToLower(strings.TrimSpace(cfg.EmailAddress)) + cfg.ForwardingAddress = strings.ToLower(strings.TrimSpace(cfg.ForwardingAddress)) + cfg.SenderName = strings.TrimSpace(cfg.SenderName) + cfg.Provider = strings.ToLower(strings.TrimSpace(cfg.Provider)) + if cfg.Provider == "" { + if cfg.APIKey != "" { + cfg.Provider = "brevo" + } else { + cfg.Provider = "smtp" + } + } + cfg.APIKey = strings.TrimSpace(cfg.APIKey) + cfg.SMTPHost = strings.TrimSpace(cfg.SMTPHost) + cfg.SMTPUser = strings.TrimSpace(cfg.SMTPUser) + cfg.SMTPPassword = strings.TrimSpace(cfg.SMTPPassword) + cfg.WebhookSecret = strings.TrimSpace(cfg.WebhookSecret) + cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) + return cfg, nil +} + +func (s *channelService) GetEnabledEmailChannelByAddress(emailAddress string) *models.Channel { + emailAddress = strings.ToLower(strings.TrimSpace(emailAddress)) + if emailAddress == "" { + return nil + } + channels := s.Find(sqls.NewCnd(). + Eq("channel_type", enums.ChannelTypeEmail). + Eq("status", enums.StatusOk). + Asc("id")) + if len(channels) == 0 { + return nil + } + + // 1. Pass 1: Exact match with EmailAddress or ForwardingAddress + for i := range channels { + cfg, err := s.ParseEmailChannelConfig(channels[i].ConfigJSON) + if err != nil { + continue + } + if cfg != nil { + if strings.ToLower(strings.TrimSpace(cfg.EmailAddress)) == emailAddress || + strings.ToLower(strings.TrimSpace(cfg.ForwardingAddress)) == emailAddress { + return &channels[i] + } + } + } + + // 2. Pass 2: Extract tenant slug (e.g. help@dos.example.com -> "dos", help+dos@example.com -> "dos") + slug := extractTenantSlugFromEmail(emailAddress) + if slug != "" { + for i := range channels { + cfg, err := s.ParseEmailChannelConfig(channels[i].ConfigJSON) + if err != nil { + continue + } + channelIDLower := strings.ToLower(channels[i].ChannelID) + if channelIDLower == "email_"+slug || channelIDLower == slug || strings.Contains(channelIDLower, slug) { + return &channels[i] + } + if cfg != nil { + cfgEmailLower := strings.ToLower(cfg.EmailAddress) + cfgFwdLower := strings.ToLower(cfg.ForwardingAddress) + if strings.Contains(cfgEmailLower, "@"+slug+".") || + strings.Contains(cfgEmailLower, "+"+slug+"@") || + strings.Contains(cfgFwdLower, "@"+slug+".") || + strings.Contains(cfgFwdLower, "+"+slug+"@") { + return &channels[i] + } + } + } + + // Also check if Organization exists with code == slug + org := repositories.OrganizationRepository.GetByCode(sqls.DB(), slug) + if org != nil { + for i := range channels { + if strings.EqualFold(channels[i].Name, org.Name) || + strings.Contains(strings.ToLower(channels[i].Name), slug) { + return &channels[i] + } + } + } + } + + // 3. Pass 3: Fallback to first active email channel + return &channels[0] +} + +func extractTenantSlugFromEmail(emailAddress string) string { + emailAddress = strings.ToLower(strings.TrimSpace(emailAddress)) + parts := strings.Split(emailAddress, "@") + if len(parts) != 2 { + return "" + } + localPart, domain := parts[0], parts[1] + + // Check plus addressing (e.g. help+dos@example.com -> "dos") + if strings.Contains(localPart, "+") { + plusParts := strings.Split(localPart, "+") + if len(plusParts) > 1 && plusParts[1] != "" { + return plusParts[1] + } + } + + // Check subdomains (e.g. desk.dos.example.com -> "dos") + domainParts := strings.Split(domain, ".") + if len(domainParts) >= 3 { + if domainParts[0] != "mail" && domainParts[0] != "smtp" && domainParts[0] != "email" && domainParts[0] != "inbound" { + return domainParts[0] + } + } + + return "" +} + +func (s *channelService) GetUserTokenSecret(channel *models.Channel) string { + if channel == nil { + return "" + } + switch channel.ChannelType { + case enums.ChannelTypeWeb: + cfg, err := s.ParseWebChannelConfig(channel.ConfigJSON) + if err != nil { + return "" + } + return strings.TrimSpace(cfg.UserTokenSecret) + default: + return "" + } +} + +func (s *channelService) ResetUserTokenSecret(channelID int64, operator *dto.AuthPrincipal) (string, error) { + if operator == nil { + return "", errorsx.UnauthorizedI18n("error.auth.expired") + } + channel := s.Get(channelID) + if channel == nil || channel.Status == enums.StatusDeleted { + return "", errorsx.InvalidParamI18n("error.e0208") + } + if channel.ChannelType != enums.ChannelTypeWeb { + return "", errorsx.InvalidParamI18n("error.e0196") + } + secret, err := generateUserTokenSecret() + if err != nil { + return "", err + } + var configJSON string + switch channel.ChannelType { + case enums.ChannelTypeWeb: + cfg, err := s.ParseWebChannelConfig(channel.ConfigJSON) + if err != nil { + return "", err + } + cfg.UserTokenSecret = secret + raw, err := json.Marshal(cfg) + if err != nil { + return "", err + } + configJSON = string(raw) + } + if err := repositories.ChannelRepository.Updates(sqls.DB(), channelID, map[string]any{ + "config_json": configJSON, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }); err != nil { + return "", err + } + return secret, nil +} + +func generateUserTokenSecret() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func (s *channelService) GetEnabledChannel(ctx *gin.Context) *models.Channel { + channelID := httpx.GetChannelID(ctx) + channel := repositories.ChannelRepository.GetByChannelID(sqls.DB(), channelID) + if channel == nil { + return nil + } + if channel.Status != enums.StatusOk { + return nil + } + return channel +} + +func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRequest) (*models.Channel, error) { + channelType := strings.TrimSpace(req.ChannelType) + if channelType != enums.ChannelTypeWeb && channelType != enums.ChannelTypeTelegram && channelType != enums.ChannelTypeZaloOA && channelType != enums.ChannelTypeSlack && channelType != enums.ChannelTypeDiscord && channelType != enums.ChannelTypeEmail { + return nil, errorsx.InvalidParamI18n("error.e0250") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errorsx.InvalidParamI18n("error.e0247") + } + if req.AIAgentID <= 0 { + return nil, errorsx.InvalidParamI18n("error.e0321") + } + if req.AIAgentRolloutPercent == 0 { + req.AIAgentRolloutPercent = 100 + } + if req.AIAgentRolloutPercent < 1 || req.AIAgentRolloutPercent > 100 { + return nil, errorsx.InvalidParam("channel ai agent rollout percent must be between 1 and 100") + } + aiAgent := AIAgentService.Get(req.AIAgentID) + if aiAgent == nil || aiAgent.Status != enums.StatusOk { + return nil, errorsx.InvalidParamI18n("error.e0004") + } + if aiAgent.PublishedRevisionID <= 0 { + return nil, errorsx.InvalidParam("ai agent must be published before binding channel") + } + status := enums.Status(req.Status) + if req.Status == 0 { + status = enums.StatusOk + } + if status != enums.StatusOk && status != enums.StatusDisabled { + return nil, errorsx.InvalidParamI18n("error.e0249") + } + + channelID := "" + if id > 0 { + current := s.Get(id) + if current == nil || current.Status == enums.StatusDeleted { + return nil, errorsx.InvalidParamI18n("error.e0208") + } + channelID = strings.TrimSpace(current.ChannelID) + } + configJSON := strings.TrimSpace(req.ConfigJSON) + switch channelType { + case enums.ChannelTypeWeb: + if channelID == "" { + channelID = strs.UUID() + } + if exists := s.Take("channel_id = ? AND status <> ? AND id <> ?", channelID, enums.StatusDeleted, id); exists != nil { + return nil, errorsx.InvalidParamI18n("error.e0248") + } + cfg, err := s.ParseWebChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParamI18n("error.e0060") + } + if strings.TrimSpace(cfg.UserTokenSecret) == "" { + secret, err := generateUserTokenSecret() + if err != nil { + return nil, err + } + cfg.UserTokenSecret = secret + } + configBytes, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + configJSON = string(configBytes) + case enums.ChannelTypeTelegram: + if channelID == "" { + channelID = strs.UUID() + } + if exists := s.Take("channel_id = ? AND status <> ? AND id <> ?", channelID, enums.StatusDeleted, id); exists != nil { + return nil, errorsx.InvalidParamI18n("error.e0248") + } + cfg, err := s.ParseTelegramChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParam("invalid telegram configuration") + } + if cfg == nil || cfg.BotToken == "" { + return nil, errorsx.InvalidParam("telegram botToken is required") + } + if cfg.WebhookSecret == "" { + if secret, err := generateUserTokenSecret(); err == nil { + cfg.WebhookSecret = secret + } + } + configBytes, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + configJSON = string(configBytes) + case enums.ChannelTypeZaloOA: + if channelID == "" { + channelID = strs.UUID() + } + if exists := s.Take("channel_id = ? AND status <> ? AND id <> ?", channelID, enums.StatusDeleted, id); exists != nil { + return nil, errorsx.InvalidParamI18n("error.e0248") + } + cfg, err := s.ParseZaloOAChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParam("invalid zalo oa configuration") + } + if cfg == nil || cfg.AccessToken == "" { + return nil, errorsx.InvalidParam("zalo oa accessToken is required") + } + configBytes, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + configJSON = string(configBytes) + case enums.ChannelTypeSlack: + if channelID == "" { + channelID = strs.UUID() + } + if exists := s.Take("channel_id = ? AND status <> ? AND id <> ?", channelID, enums.StatusDeleted, id); exists != nil { + return nil, errorsx.InvalidParamI18n("error.e0248") + } + cfg, err := s.ParseSlackChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParam("invalid slack configuration") + } + // Without a bot token the channel can receive events but can never reply, + // so it is required the same way Telegram's bot token is. + if cfg == nil || cfg.BotToken == "" { + return nil, errorsx.InvalidParam("slack botToken is required") + } + configBytes, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + configJSON = string(configBytes) + case enums.ChannelTypeDiscord: + if channelID == "" { + channelID = strs.UUID() + } + if exists := s.Take("channel_id = ? AND status <> ? AND id <> ?", channelID, enums.StatusDeleted, id); exists != nil { + return nil, errorsx.InvalidParamI18n("error.e0248") + } + cfg, err := s.ParseDiscordChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParam("invalid discord configuration") + } + // A channel may rely on the deployment-wide bot token instead of carrying + // its own, so the token is not required here the way Telegram's is. + if cfg.ChannelScope != "" && cfg.ChannelScope != "all" && cfg.ChannelScope != "dm_only" { + return nil, errorsx.InvalidParam("discord channelScope must be all or dm_only") + } + if cfg.WebhookSecret == "" { + if secret, err := generateUserTokenSecret(); err == nil { + cfg.WebhookSecret = secret + } + } + configBytes, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + configJSON = string(configBytes) + case enums.ChannelTypeEmail: + if channelID == "" { + channelID = strs.UUID() + } + if exists := s.Take("channel_id = ? AND status <> ? AND id <> ?", channelID, enums.StatusDeleted, id); exists != nil { + return nil, errorsx.InvalidParamI18n("error.e0248") + } + cfg, err := s.ParseEmailChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParam("invalid email channel configuration") + } + if cfg == nil || cfg.EmailAddress == "" { + return nil, errorsx.InvalidParam("emailAddress is required") + } + if cfg.WebhookSecret == "" { + if secret, err := generateUserTokenSecret(); err == nil { + cfg.WebhookSecret = secret + } + } + configBytes, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + configJSON = string(configBytes) + } + + return &models.Channel{ + ChannelType: channelType, + ChannelID: channelID, + AIAgentID: req.AIAgentID, + AIAgentRolloutPercent: req.AIAgentRolloutPercent, + Name: name, + ConfigJSON: configJSON, + Status: status, + Remark: strings.TrimSpace(req.Remark), + }, nil +} diff --git a/internal/services/channel_service_test.go b/internal/services/channel_service_test.go new file mode 100644 index 0000000..7ede0c2 --- /dev/null +++ b/internal/services/channel_service_test.go @@ -0,0 +1,139 @@ +package services + +import ( + "strings" + "testing" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestChannelServiceRejectsAgentWithoutPublishedRevision(t *testing.T) { + db := setupChannelServiceTestDB(t) + agent := createChannelServiceTestAgent(t, db, 0) + + _, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, + AIAgentID: agent.ID, + Name: "官网客服", + Status: int(enums.StatusOk), + }, channelServiceTestOperator()) + if err == nil { + t.Fatalf("expected channel creation to reject unpublished ai agent") + } +} + +func TestChannelServiceAllowsAgentWithPublishedRevision(t *testing.T) { + db := setupChannelServiceTestDB(t) + agent := createChannelServiceTestAgent(t, db, 1001) + + item, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, + AIAgentID: agent.ID, + Name: "官网客服", + Status: int(enums.StatusOk), + }, channelServiceTestOperator()) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if item == nil || item.AIAgentID != agent.ID { + t.Fatalf("unexpected channel: %#v", item) + } +} + +func TestChannelServiceStoresAIAgentRolloutPercent(t *testing.T) { + db := setupChannelServiceTestDB(t) + agent := createChannelServiceTestAgent(t, db, 1001) + item, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, AIAgentRolloutPercent: 25, + Name: "灰度渠道", Status: int(enums.StatusOk), + }, channelServiceTestOperator()) + if err != nil || item == nil || item.AIAgentRolloutPercent != 25 { + t.Fatalf("expected persisted rollout percent, item=%#v err=%v", item, err) + } + if _, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, AIAgentRolloutPercent: 101, + Name: "错误灰度渠道", Status: int(enums.StatusOk), + }, channelServiceTestOperator()); err == nil { + t.Fatal("expected invalid rollout percent to be rejected") + } +} + +func TestChannelServiceRollsBackPreviousAIAgentRolloutPercent(t *testing.T) { + db := setupChannelServiceTestDB(t) + agent := createChannelServiceTestAgent(t, db, 1001) + channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, AIAgentRolloutPercent: 20, + Name: "渠道灰度回滚", Status: int(enums.StatusOk), + }, channelServiceTestOperator()) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if err := db.Model(&models.Channel{}).Where("id = ?", channel.ID).Update("previous_ai_agent_rollout_percent", 100).Error; err != nil { + t.Fatalf("set previous rollout: %v", err) + } + operator := channelServiceTestOperator() + if err := ChannelService.RollbackChannelAIAgentRollout(channel.ID, operator); err != nil { + t.Fatalf("RollbackChannelAIAgentRollout: %v", err) + } + updated := ChannelService.Get(channel.ID) + if updated == nil || updated.AIAgentRolloutPercent != 100 || updated.PreviousAIAgentRolloutPercent != 20 { + t.Fatalf("unexpected channel rollout rollback: %#v", updated) + } + if err := ChannelService.RollbackChannelAIAgentRollout(channel.ID, operator); err != nil { + t.Fatalf("second RollbackChannelAIAgentRollout: %v", err) + } + updated = ChannelService.Get(channel.ID) + if updated == nil || updated.AIAgentRolloutPercent != 20 || updated.PreviousAIAgentRolloutPercent != 100 { + t.Fatalf("unexpected channel rollout redo: %#v", updated) + } +} + +func setupChannelServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.AIAgent{}, &models.AgentRevision{}, &models.AIAgentWorkflowBinding{}, &models.Channel{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + return db +} + +func createChannelServiceTestAgent(t *testing.T, db *gorm.DB, publishedRevisionID int64) models.AIAgent { + t.Helper() + item := models.AIAgent{ + Name: "测试 AI", + Status: enums.StatusOk, + PublishedRevisionID: publishedRevisionID, + } + if err := db.Create(&item).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + return item +} + +func channelServiceTestOperator() *dto.AuthPrincipal { + return &dto.AuthPrincipal{UserID: 1, Username: "admin"} +} diff --git a/internal/services/company_service.go b/internal/services/company_service.go new file mode 100644 index 0000000..6783982 --- /dev/null +++ b/internal/services/company_service.go @@ -0,0 +1,150 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + "strings" + "time" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var CompanyService = newCompanyService() + +func newCompanyService() *companyService { + return &companyService{} +} + +type companyService struct { +} + +func (s *companyService) Get(id int64) *models.Company { + if id <= 0 { + return nil + } + return repositories.CompanyRepository.Get(sqls.DB(), id) +} + +func (s *companyService) Take(where ...interface{}) *models.Company { + return repositories.CompanyRepository.Take(sqls.DB(), where...) +} + +func (s *companyService) Find(cnd *sqls.Cnd) []models.Company { + return repositories.CompanyRepository.Find(sqls.DB(), cnd) +} + +func (s *companyService) FindOne(cnd *sqls.Cnd) *models.Company { + return repositories.CompanyRepository.FindOne(sqls.DB(), cnd) +} + +func (s *companyService) FindPageByParams(params *params.QueryParams) (list []models.Company, paging *sqls.Paging) { + return repositories.CompanyRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *companyService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Company, paging *sqls.Paging) { + return repositories.CompanyRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *companyService) Count(cnd *sqls.Cnd) int64 { + return repositories.CompanyRepository.Count(sqls.DB(), cnd) +} + +func (s *companyService) CreateCompany(req request.CreateCompanyRequest, operator *dto.AuthPrincipal) (*models.Company, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errorsx.InvalidParamI18n("error.e0125") + } + + existing := repositories.CompanyRepository.GetByName(sqls.DB(), name) + if existing != nil && existing.Status != enums.StatusDeleted { + return nil, errorsx.InvalidParamI18n("error.e0126") + } + + item := &models.Company{ + Name: name, + Code: strings.TrimSpace(req.Code), + Status: enums.StatusOk, + Remark: strings.TrimSpace(req.Remark), + AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.CompanyRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return item, nil +} + +func (s *companyService) UpdateCompany(req request.UpdateCompanyRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + item := s.Get(req.ID) + if item == nil { + return errorsx.InvalidParamI18n("error.e0124") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return errorsx.InvalidParamI18n("error.e0125") + } + + existing := repositories.CompanyRepository.GetByName(sqls.DB(), name) + if existing != nil && existing.ID != req.ID { + return errorsx.InvalidParamI18n("error.e0126") + } + + now := time.Now() + if err := repositories.CompanyRepository.Updates(sqls.DB(), req.ID, map[string]any{ + "name": name, + "code": strings.TrimSpace(req.Code), + "remark": strings.TrimSpace(req.Remark), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + return nil +} + +func (s *companyService) DeleteCompany(id int64, operator dto.AuthPrincipal) error { + item := s.Get(id) + if item == nil { + return errorsx.InvalidParamI18n("error.e0124") + } + + return repositories.CompanyRepository.Updates(sqls.DB(), id, map[string]any{ + "status": enums.StatusDeleted, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) +} + +func (s *companyService) UpdateStatus(id int64, status int, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + item := s.Get(id) + if item == nil { + return errorsx.InvalidParamI18n("error.e0124") + } + if status != int(enums.StatusOk) && status != int(enums.StatusDisabled) { + return errorsx.InvalidParamI18n("error.e0254") + } + now := time.Now() + return repositories.CompanyRepository.Updates(sqls.DB(), id, map[string]any{ + "status": status, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }) +} diff --git a/internal/services/conversation_assignment_service.go b/internal/services/conversation_assignment_service.go new file mode 100644 index 0000000..84533ef --- /dev/null +++ b/internal/services/conversation_assignment_service.go @@ -0,0 +1,76 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "strings" + "time" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var ConversationAssignmentService = newConversationAssignmentService() + +func newConversationAssignmentService() *conversationAssignmentService { + return &conversationAssignmentService{} +} + +type conversationAssignmentService struct { +} + +func (s *conversationAssignmentService) Get(id int64) *models.ConversationAssignment { + return repositories.ConversationAssignmentRepository.Get(sqls.DB(), id) +} + +func (s *conversationAssignmentService) Take(where ...interface{}) *models.ConversationAssignment { + return repositories.ConversationAssignmentRepository.Take(sqls.DB(), where...) +} + +func (s *conversationAssignmentService) Find(cnd *sqls.Cnd) []models.ConversationAssignment { + return repositories.ConversationAssignmentRepository.Find(sqls.DB(), cnd) +} + +func (s *conversationAssignmentService) FindOne(cnd *sqls.Cnd) *models.ConversationAssignment { + return repositories.ConversationAssignmentRepository.FindOne(sqls.DB(), cnd) +} + +func (s *conversationAssignmentService) FindPageByParams(params *params.QueryParams) (list []models.ConversationAssignment, paging *sqls.Paging) { + return repositories.ConversationAssignmentRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *conversationAssignmentService) FindPageByCnd(cnd *sqls.Cnd) (list []models.ConversationAssignment, paging *sqls.Paging) { + return repositories.ConversationAssignmentRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *conversationAssignmentService) Count(cnd *sqls.Cnd) int64 { + return repositories.ConversationAssignmentRepository.Count(sqls.DB(), cnd) +} + +func (s *conversationAssignmentService) FinishActiveAssignments(ctx *sqls.TxContext, conversationID int64, finishedAt time.Time) error { + return ctx.Tx.Model(&models.ConversationAssignment{}). + Where("conversation_id = ? AND status = ?", conversationID, enums.IMAssignmentStatusActive). + Updates(map[string]any{ + "status": enums.IMAssignmentStatusInactive, + "finished_at": finishedAt, + }).Error +} + +func (s *conversationAssignmentService) CreateAssignment(ctx *sqls.TxContext, conversationID, fromUserID, toUserID int64, assignType enums.IMAssignmentType, reason string, operator *dto.AuthPrincipal, now time.Time) error { + assignment := &models.ConversationAssignment{ + ConversationID: conversationID, + FromUserID: fromUserID, + ToUserID: toUserID, + AssignType: strings.TrimSpace(string(assignType)), + Reason: strings.TrimSpace(reason), + Status: enums.IMAssignmentStatusActive, + CreatedAt: now, + } + if operator != nil { + assignment.OperatorID = operator.UserID + } + return ctx.Tx.Create(assignment).Error +} diff --git a/internal/services/conversation_customer_service_test.go b/internal/services/conversation_customer_service_test.go new file mode 100644 index 0000000..9cc38e1 --- /dev/null +++ b/internal/services/conversation_customer_service_test.go @@ -0,0 +1,376 @@ +package services + +import ( + "strings" + "testing" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/openidentity" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +// The whole point of CreateNew is that it does not behave like Create: it must +// start a second thread instead of handing back the unfinished one. +func TestCreateNewDoesNotResumeUnfinishedConversation(t *testing.T) { + svc, external, aiAgentID := setupCustomerConversationTest(t, 3) + + first, err := svc.CreateNew(external, 1, aiAgentID, "Проблема с оплатой") + if err != nil { + t.Fatalf("CreateNew() error = %v", err) + } + second, err := svc.CreateNew(external, 1, aiAgentID, "Не приходит письмо") + if err != nil { + t.Fatalf("second CreateNew() error = %v", err) + } + + if first.ID == second.ID { + t.Fatalf("CreateNew reused conversation %d instead of starting a new one", first.ID) + } + if first.Subject != "Проблема с оплатой" || second.Subject != "Не приходит письмо" { + t.Fatalf("subjects = %q / %q", first.Subject, second.Subject) + } + + // The resume path must still rejoin the newest one, so a page reload lands + // the visitor back in their live thread. + resumed, err := svc.Create(external, 1, aiAgentID) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if resumed.ID != second.ID { + t.Fatalf("Create() resumed conversation %d, want the newest %d", resumed.ID, second.ID) + } +} + +func TestCreateNewStoresANormalizedSubject(t *testing.T) { + svc, external, aiAgentID := setupCustomerConversationTest(t, 3) + + item, err := svc.CreateNew(external, 1, aiAgentID, " Проблема с\nоплатой ") + if err != nil { + t.Fatalf("CreateNew() error = %v", err) + } + if item.Subject != "Проблема с оплатой" { + t.Fatalf("Subject = %q, want collapsed single spaces", item.Subject) + } +} + +// An empty subject stays empty rather than becoming a blank-looking row, so the +// frontend can substitute its own fallback text. +func TestCreateNewKeepsAnEmptySubjectEmpty(t *testing.T) { + svc, external, aiAgentID := setupCustomerConversationTest(t, 3) + + item, err := svc.CreateNew(external, 1, aiAgentID, " \n\t ") + if err != nil { + t.Fatalf("CreateNew() error = %v", err) + } + if item.Subject != "" { + t.Fatalf("Subject = %q, want empty", item.Subject) + } +} + +func TestCreateNewEnforcesTheOpenConversationCap(t *testing.T) { + svc, external, aiAgentID := setupCustomerConversationTest(t, 2) + + for i := 0; i < 2; i++ { + if _, err := svc.CreateNew(external, 1, aiAgentID, ""); err != nil { + t.Fatalf("CreateNew() #%d error = %v", i+1, err) + } + } + + _, err := svc.CreateNew(external, 1, aiAgentID, "") + if err == nil { + t.Fatal("expected the third open conversation to be refused") + } + if !strings.Contains(err.Error(), "2") { + t.Fatalf("error %q should state the cap", err) + } + + // Closing one frees a slot: the cap protects the queue, it is not a lifetime + // limit on how many requests a visitor may ever file. + if err := svc.CloseCustomerConversation(firstCustomerConversationID(t, svc, external), external); err != nil { + t.Fatalf("CloseCustomerConversation() error = %v", err) + } + if _, err := svc.CreateNew(external, 1, aiAgentID, ""); err != nil { + t.Fatalf("CreateNew() after closing one error = %v", err) + } +} + +func TestCreateNewHonoursADisabledCap(t *testing.T) { + svc, external, aiAgentID := setupCustomerConversationTest(t, -1) + + for i := 0; i < 6; i++ { + if _, err := svc.CreateNew(external, 1, aiAgentID, ""); err != nil { + t.Fatalf("CreateNew() #%d error = %v with the cap disabled", i+1, err) + } + } +} + +// A second visitor must never see the first visitor's requests. +func TestListCustomerConversationsIsScopedToTheVisitor(t *testing.T) { + svc, alice, aiAgentID := setupCustomerConversationTest(t, 5) + bob := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceUser, + ExternalID: "u_bob", + ExternalName: "Bob", + } + + if _, err := svc.CreateNew(alice, 1, aiAgentID, "Alice request"); err != nil { + t.Fatalf("CreateNew(alice) error = %v", err) + } + if _, err := svc.CreateNew(alice, 1, aiAgentID, "Alice second"); err != nil { + t.Fatalf("CreateNew(alice) error = %v", err) + } + bobItem, err := svc.CreateNew(bob, 1, aiAgentID, "Bob request") + if err != nil { + t.Fatalf("CreateNew(bob) error = %v", err) + } + + aliceList, paging, err := svc.ListCustomerConversations(alice, sqls.NewCnd().Page(1, 10)) + if err != nil { + t.Fatalf("ListCustomerConversations(alice) error = %v", err) + } + if len(aliceList) != 2 { + t.Fatalf("alice sees %d conversations, want 2", len(aliceList)) + } + if paging == nil || paging.Total != 2 { + t.Fatalf("unexpected paging for alice: %+v", paging) + } + + bobList, _, err := svc.ListCustomerConversations(bob, sqls.NewCnd().Page(1, 10)) + if err != nil { + t.Fatalf("ListCustomerConversations(bob) error = %v", err) + } + if len(bobList) != 1 || bobList[0].ID != bobItem.ID { + t.Fatalf("bob sees %+v, want only his own", bobList) + } +} + +// A visitor who has never talked to us has no identity row. That is an empty +// list, not an error and not somebody else's data. +func TestListCustomerConversationsIsEmptyForAnUnknownVisitor(t *testing.T) { + svc, _, _ := setupCustomerConversationTest(t, 3) + + unknown := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceGuest, + ExternalID: "never_seen", + } + list, paging, err := svc.ListCustomerConversations(unknown, sqls.NewCnd().Page(1, 10)) + if err != nil { + t.Fatalf("ListCustomerConversations() error = %v", err) + } + if list == nil { + t.Fatal("list must be an empty slice, not nil, so the client gets [] instead of null") + } + if len(list) != 0 { + t.Fatalf("list = %+v, want empty", list) + } + if paging == nil { + t.Fatal("paging must be present so the client can render pagination") + } +} + +// Newest activity first: the request the visitor is looking at should be on top. +func TestListCustomerConversationsOrdersByRecentActivity(t *testing.T) { + svc, external, aiAgentID := setupCustomerConversationTest(t, 5) + + older, err := svc.CreateNew(external, 1, aiAgentID, "older") + if err != nil { + t.Fatalf("CreateNew() error = %v", err) + } + newer, err := svc.CreateNew(external, 1, aiAgentID, "newer") + if err != nil { + t.Fatalf("CreateNew() error = %v", err) + } + if err := sqls.DB().Model(&models.Conversation{}).Where("id = ?", older.ID). + Update("last_active_at", older.LastActiveAt.Add(-24*60*60*1e9)).Error; err != nil { + t.Fatalf("backdate conversation error = %v", err) + } + + list, _, err := svc.ListCustomerConversations(external, sqls.NewCnd().Page(1, 10)) + if err != nil { + t.Fatalf("ListCustomerConversations() error = %v", err) + } + if len(list) != 2 { + t.Fatalf("list length = %d, want 2", len(list)) + } + if list[0].ID != newer.ID { + t.Fatalf("first row = %d, want the most recently active %d", list[0].ID, newer.ID) + } +} + +func TestListCustomerConversationsRespectsPagination(t *testing.T) { + svc, external, aiAgentID := setupCustomerConversationTest(t, 5) + + for i := 0; i < 3; i++ { + if _, err := svc.CreateNew(external, 1, aiAgentID, ""); err != nil { + t.Fatalf("CreateNew() #%d error = %v", i+1, err) + } + } + + firstPage, paging, err := svc.ListCustomerConversations(external, sqls.NewCnd().Page(1, 2)) + if err != nil { + t.Fatalf("ListCustomerConversations() error = %v", err) + } + if len(firstPage) != 2 { + t.Fatalf("first page length = %d, want 2", len(firstPage)) + } + if paging.Total != 3 { + t.Fatalf("paging.Total = %d, want 3", paging.Total) + } + + secondPage, _, err := svc.ListCustomerConversations(external, sqls.NewCnd().Page(2, 2)) + if err != nil { + t.Fatalf("ListCustomerConversations() page 2 error = %v", err) + } + if len(secondPage) != 1 { + t.Fatalf("second page length = %d, want 1", len(secondPage)) + } + for _, item := range firstPage { + for _, other := range secondPage { + if item.ID == other.ID { + t.Fatalf("conversation %d appeared on both pages", item.ID) + } + } + } +} + +// The cap must not block the resume path: reloading a page with three open +// conversations has to rejoin one, not fail. +func TestCreateStillResumesWhenTheCapIsReached(t *testing.T) { + svc, external, aiAgentID := setupCustomerConversationTest(t, 1) + + only, err := svc.CreateNew(external, 1, aiAgentID, "only") + if err != nil { + t.Fatalf("CreateNew() error = %v", err) + } + resumed, err := svc.Create(external, 1, aiAgentID) + if err != nil { + t.Fatalf("Create() at the cap error = %v", err) + } + if resumed.ID != only.ID { + t.Fatalf("Create() returned %d, want %d", resumed.ID, only.ID) + } +} + +func TestNormalizeConversationSubject(t *testing.T) { + tests := []struct { + name string + subject string + want string + }{ + {name: "empty", subject: "", want: ""}, + {name: "whitespace only", subject: " \t\n ", want: ""}, + {name: "inner runs collapse", subject: "a b\t\tc", want: "a b c"}, + {name: "edges trimmed", subject: " hello ", want: "hello"}, + {name: "newlines become spaces", subject: "line one\nline two", want: "line one line two"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeConversationSubject(tt.subject); got != tt.want { + t.Fatalf("normalizeConversationSubject(%q) = %q, want %q", tt.subject, got, tt.want) + } + }) + } +} + +// Truncation must count runes, not bytes. A byte-wise cut can split a multi-byte +// character and produce invalid UTF-8, and it would also cut far too early: +// MySQL counts a VARCHAR(255) in characters, so 255 Cyrillic runes is 510 bytes +// yet still fits the column. +func TestNormalizeConversationSubjectTruncatesOnRuneBoundaries(t *testing.T) { + long := strings.Repeat("я", conversationSubjectMaxLength+50) + + got := normalizeConversationSubject(long) + + if len([]rune(got)) != conversationSubjectMaxLength { + t.Fatalf("rune length = %d, want %d", len([]rune(got)), conversationSubjectMaxLength) + } + if !strings.HasPrefix(long, got) { + t.Fatal("truncation must keep a prefix of the input") + } +} + +func firstCustomerConversationID(t *testing.T, svc *conversationService, external openidentity.ExternalUser) int64 { + t.Helper() + list, _, err := svc.ListCustomerConversations(external, sqls.NewCnd().Page(1, 10)) + if err != nil { + t.Fatalf("ListCustomerConversations() error = %v", err) + } + if len(list) == 0 { + t.Fatal("expected at least one conversation") + } + return list[0].ID +} + +// setupCustomerConversationTest gives each test its own in-memory database and +// restores the process-wide singletons it touches, so tests stay independent +// even though config and the DB handle are globals. +func setupCustomerConversationTest(t *testing.T, maxOpen int) (*conversationService, openidentity.ExternalUser, int64) { + t.Helper() + + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate( + &models.Customer{}, + &models.CustomerIdentity{}, + &models.Channel{}, + &models.AIAgent{}, + &models.Conversation{}, + &models.ConversationAssignment{}, + &models.ConversationParticipant{}, + &models.ConversationEventLog{}, + &models.ConversationReadState{}, + &models.Message{}, + ); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + + previousConfig := config.GetCurrent() + config.SetCurrent(&config.Config{ + Conversation: config.ConversationConfig{CustomerMaxOpen: maxOpen}, + }) + previousWs := WsService + WsService = newWsService() + t.Cleanup(func() { + config.SetCurrent(previousConfig) + WsService = previousWs + }) + + // AI-first keeps the test off the human dispatch path, which needs teams, + // schedules and agent profiles that are irrelevant here. + aiAgent := models.AIAgent{ + Name: "测试AI", + ServiceMode: enums.IMConversationServiceModeAIFirst, + Status: enums.StatusOk, + } + if err := db.Create(&aiAgent).Error; err != nil { + t.Fatalf("create ai agent error = %v", err) + } + + external := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceUser, + ExternalID: "u_alice", + ExternalName: "Alice", + } + return ConversationService, external, aiAgent.ID +} diff --git a/internal/services/conversation_dispatch_service.go b/internal/services/conversation_dispatch_service.go new file mode 100644 index 0000000..4ff7e35 --- /dev/null +++ b/internal/services/conversation_dispatch_service.go @@ -0,0 +1,476 @@ +package services + +import ( + "context" + "errors" + "log/slog" + "math" + "slices" + "strings" + "sync/atomic" + "time" + + "agent-desk/internal/events" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/eventbus" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +var ConversationDispatchService = newConversationDispatchService() + +func newConversationDispatchService() *conversationDispatchService { + return &conversationDispatchService{} +} + +type conversationDispatchService struct{} + +type dispatchCandidate struct { + profile models.AgentProfile + activeCount int + loadRate float64 +} + +type agentActiveConversationCount struct { + CurrentAssigneeID int64 `gorm:"column:current_assignee_id"` + ActiveCount int `gorm:"column:active_count"` +} + +type dispatchPoolReport struct { + RequestedTeamIDs []int64 + ActiveScheduleTeams []int64 + MatchedProfiles int + EligibleProfiles int + CandidateCount int + Reason string +} + +var errConversationDispatchConflict = errors.New("conversation dispatch conflict") + +const pendingDispatchBatchLimit = 50 + +var pendingDispatchRunning atomic.Bool + +func (s *conversationDispatchService) DispatchConversation(conversationID int64) (*models.Conversation, error) { + if conversationID <= 0 { + return nil, nil + } + conversation := ConversationService.Get(conversationID) + if conversation == nil { + return nil, nil + } + if conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 { + return nil, nil + } + aiAgent := AIAgentService.Get(conversation.AIAgentID) + if aiAgent == nil || aiAgent.Status != enums.StatusOk { + return nil, nil + } + return s.DispatchPendingConversation(conversation, aiAgent) +} + +func (s *conversationDispatchService) DispatchPendingConversation(conversation *models.Conversation, aiAgent *models.AIAgent) (*models.Conversation, error) { + if conversation == nil || aiAgent == nil { + return nil, nil + } + if conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 { + return nil, nil + } + + teamIDs := resolveDispatchTeamIDs(aiAgent.TeamIDs) + if len(teamIDs) == 0 { + slog.Debug("skip auto dispatch due to empty ai agent team ids", + "conversation_id", conversation.ID, + "ai_agent_id", aiAgent.ID, + ) + return nil, nil + } + + candidates, report, err := s.pickDispatchCandidates(teamIDs, time.Now()) + if err != nil { + return nil, err + } + if len(candidates) == 0 { + slog.Debug("no dispatch candidate available", + "conversation_id", conversation.ID, + "ai_agent_id", aiAgent.ID, + "requested_team_ids", report.RequestedTeamIDs, + "active_schedule_team_ids", report.ActiveScheduleTeams, + "matched_profiles", report.MatchedProfiles, + "eligible_profiles", report.EligibleProfiles, + "reason", report.Reason, + ) + return nil, nil + } + + for _, candidate := range candidates { + dispatched, err := s.tryAssignConversation(conversation.ID, candidate.profile, "自动分配") + if err != nil { + if errors.Is(err, errConversationDispatchConflict) { + return nil, nil + } + return nil, err + } + if dispatched != nil { + slog.Info("conversation auto dispatched", + "conversation_id", dispatched.ID, + "ai_agent_id", aiAgent.ID, + "assignee_id", dispatched.CurrentAssigneeID, + "team_id", dispatched.CurrentTeamID, + "candidate_count", report.CandidateCount, + "requested_team_ids", report.RequestedTeamIDs, + ) + WsService.PublishConversationChanged(dispatched, enums.IMRealtimeEventConversationAssigned) + eventbus.PublishAsync(context.Background(), events.ConversationAssignedEvent{ + ConversationID: dispatched.ID, + ToUserID: dispatched.CurrentAssigneeID, + OperatorID: systemDispatchPrincipal().UserID, + Reason: "自动分配", + AssignType: events.ConversationAssignTypeAutoAssign, + }) + return dispatched, nil + } + } + slog.Debug("auto dispatch candidate list exhausted without assignment", + "conversation_id", conversation.ID, + "ai_agent_id", aiAgent.ID, + "candidate_count", report.CandidateCount, + ) + return nil, nil +} + +func (s *conversationDispatchService) DispatchPendingConversations(limit int) (int, error) { + if !pendingDispatchRunning.CompareAndSwap(false, true) { + return 0, nil + } + defer pendingDispatchRunning.Store(false) + + if limit <= 0 { + limit = pendingDispatchBatchLimit + } + conversations := ConversationService.Find(sqls.NewCnd(). + Eq("status", enums.IMConversationStatusPending). + Eq("current_assignee_id", 0). + Desc("id")) + if len(conversations) == 0 { + return 0, nil + } + + dispatchedCount := 0 + scannedCount := 0 + for i, conversation := range conversations { + if i >= limit { + break + } + scannedCount++ + dispatched, err := s.DispatchConversation(conversation.ID) + if err != nil { + return dispatchedCount, err + } + if dispatched != nil { + dispatchedCount++ + } + } + if scannedCount > 0 { + slog.Info("pending conversation dispatch scan completed", + "scanned_count", scannedCount, + "dispatched_count", dispatchedCount, + "limit", limit, + ) + } + return dispatchedCount, nil +} + +func (s *conversationDispatchService) RunPendingDispatchLoop(interval time.Duration) { + if interval <= 0 { + interval = 30 * time.Second + } + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + slog.Info("pending conversation dispatch loop started", + "interval_seconds", int(interval/time.Second), + ) + + for { + if _, err := s.DispatchPendingConversations(0); err != nil { + slog.Warn("dispatch pending conversations loop failed", "error", err) + } + <-ticker.C + } + }() +} + +// pickDispatchCandidates returns the eligible dispatch candidates for the given teamIDs at the given time, along with a report for debugging and analysis. +func (s *conversationDispatchService) pickDispatchCandidates(teamIDs []int64, now time.Time) ([]dispatchCandidate, dispatchPoolReport, error) { + report := dispatchPoolReport{ + RequestedTeamIDs: append([]int64(nil), teamIDs...), + } + + // 1. filter teams with active schedule + activeTeamIDs := s.findActiveScheduleTeamIDs(teamIDs, now) + report.ActiveScheduleTeams = activeTeamIDs + if len(activeTeamIDs) == 0 { + report.Reason = "no_active_schedule_team" + return nil, report, nil + } + + // 2. find agent profiles for the active teams + profiles := AgentProfileService.GetDispatchAgents(activeTeamIDs) + report.MatchedProfiles = len(profiles) + if len(profiles) == 0 { + report.Reason = "no_matched_profile" + return nil, report, nil + } + + enabledProfiles, enabledUserIDs, reason := s.filterEnabledDispatchProfiles(profiles) + if reason != "" { + report.Reason = reason + return nil, report, nil + } + report.EligibleProfiles = len(enabledProfiles) + + activeCounts, err := s.findActiveConversationCountMap(enabledUserIDs) + if err != nil { + return nil, report, err + } + + candidates := make([]dispatchCandidate, 0, len(enabledProfiles)) + for _, profile := range enabledProfiles { + activeCount := activeCounts[profile.UserID] + if profile.MaxConcurrentCount > 0 && activeCount >= profile.MaxConcurrentCount { + continue + } + loadRate := float64(activeCount) / math.Max(float64(profile.MaxConcurrentCount), 1) + candidates = append(candidates, dispatchCandidate{ + profile: profile, + activeCount: activeCount, + loadRate: loadRate, + }) + } + report.CandidateCount = len(candidates) + if len(candidates) == 0 { + report.Reason = "all_candidates_at_capacity" + return nil, report, nil + } + + slices.SortFunc(candidates, func(a, b dispatchCandidate) int { + switch { + case a.loadRate < b.loadRate: + return -1 + case a.loadRate > b.loadRate: + return 1 + } + switch { + case a.activeCount < b.activeCount: + return -1 + case a.activeCount > b.activeCount: + return 1 + } + switch { + case a.profile.PriorityLevel > b.profile.PriorityLevel: + return -1 + case a.profile.PriorityLevel < b.profile.PriorityLevel: + return 1 + } + aLastStatusAt := zeroTime(a.profile.LastStatusAt) + bLastStatusAt := zeroTime(b.profile.LastStatusAt) + switch { + case aLastStatusAt.Before(bLastStatusAt): + return -1 + case aLastStatusAt.After(bLastStatusAt): + return 1 + } + switch { + case a.profile.UserID < b.profile.UserID: + return -1 + case a.profile.UserID > b.profile.UserID: + return 1 + default: + return 0 + } + }) + report.Reason = "ok" + return candidates, report, nil +} + +func (s *conversationDispatchService) filterEnabledDispatchProfiles(profiles []models.AgentProfile) ([]models.AgentProfile, []int64, string) { + userIDs := make([]int64, 0, len(profiles)) + for _, profile := range profiles { + if profile.UserID > 0 { + userIDs = append(userIDs, profile.UserID) + } + } + if len(userIDs) == 0 { + return nil, nil, "no_profile_with_capacity_config" + } + + enabledUsers := UserService.Find(sqls.NewCnd(). + In("id", userIDs). + Eq("status", enums.StatusOk)) + if len(enabledUsers) == 0 { + return nil, nil, "no_enabled_user" + } + + enabledUserSet := make(map[int64]struct{}, len(enabledUsers)) + for _, user := range enabledUsers { + enabledUserSet[user.ID] = struct{}{} + } + + enabledProfiles := make([]models.AgentProfile, 0, len(profiles)) + enabledUserIDs := make([]int64, 0, len(profiles)) + for _, profile := range profiles { + if _, exists := enabledUserSet[profile.UserID]; !exists { + continue + } + enabledProfiles = append(enabledProfiles, profile) + enabledUserIDs = append(enabledUserIDs, profile.UserID) + } + if len(enabledProfiles) == 0 { + return nil, nil, "no_profile_for_enabled_user" + } + return enabledProfiles, enabledUserIDs, "" +} + +// findActiveScheduleTeamIDs returns the subset of teamIDs that have active schedule at the given time. +func (s *conversationDispatchService) findActiveScheduleTeamIDs(teamIDs []int64, now time.Time) []int64 { + if len(teamIDs) == 0 { + return nil + } + + teams := AgentTeamService.Find(sqls.NewCnd(). + In("id", teamIDs). + Eq("status", enums.StatusOk)) + if len(teams) == 0 { + return nil + } + + enabledTeamIDs := make([]int64, 0, len(teams)) + for _, team := range teams { + enabledTeamIDs = append(enabledTeamIDs, team.ID) + } + + schedules := AgentTeamScheduleService.Find(sqls.NewCnd(). + In("team_id", enabledTeamIDs). + Eq("status", enums.StatusOk). + Lte("start_at", now). + Gt("end_at", now)) + + activeSet := make(map[int64]struct{}, len(schedules)) + for _, schedule := range schedules { + activeSet[schedule.TeamID] = struct{}{} + } + + ret := make([]int64, 0, len(teamIDs)) + seen := make(map[int64]struct{}) + for _, teamID := range teamIDs { + if _, active := activeSet[teamID]; !active { + continue + } + if _, exists := seen[teamID]; exists { + continue + } + seen[teamID] = struct{}{} + ret = append(ret, teamID) + } + return ret +} + +func (s *conversationDispatchService) findActiveConversationCountMap(userIDs []int64) (map[int64]int, error) { + ret := make(map[int64]int, len(userIDs)) + if len(userIDs) == 0 { + return ret, nil + } + + rows := make([]agentActiveConversationCount, 0) + if err := sqls.DB(). + Model(&models.Conversation{}). + Select("current_assignee_id, COUNT(1) AS active_count"). + Where("status = ? AND current_assignee_id IN ?", enums.IMConversationStatusActive, userIDs). + Group("current_assignee_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + if row.CurrentAssigneeID <= 0 { + continue + } + ret[row.CurrentAssigneeID] = row.ActiveCount + } + return ret, nil +} + +func (s *conversationDispatchService) tryAssignConversation(conversationID int64, candidate models.AgentProfile, reason string) (*models.Conversation, error) { + now := time.Now() + operator := systemDispatchPrincipal() + + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID) + if conversation == nil { + return errConversationDispatchConflict + } + if conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 { + return errConversationDispatchConflict + } + + if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil { + return err + } + if err := ConversationAssignmentService.CreateAssignment(ctx, conversationID, conversation.CurrentAssigneeID, candidate.UserID, enums.IMAssignmentTypeAssign, reason, operator, now); err != nil { + return err + } + + result := ctx.Tx.Model(&models.Conversation{}). + Where("id = ? AND status = ? AND current_assignee_id = ?", conversationID, enums.IMConversationStatusPending, 0). + Updates(map[string]any{ + "current_assignee_id": candidate.UserID, + "current_team_id": candidate.TeamID, + "status": enums.IMConversationStatusActive, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errConversationDispatchConflict + } + + return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeAssign, enums.IMSenderTypeSystem, operator.UserID, "会话已自动分配", buildDispatchEventPayload(conversation.CurrentAssigneeID, candidate.UserID, candidate.TeamID, reason)) + }) + if err != nil { + return nil, err + } + return ConversationService.Get(conversationID), nil +} + +func buildDispatchEventPayload(fromAssigneeID, toAssigneeID, toTeamID int64, reason string) string { + return ConversationService.buildEventPayload(map[string]any{ + "fromStatus": enums.IMConversationStatusPending, + "toStatus": enums.IMConversationStatusActive, + "fromAssigneeId": fromAssigneeID, + "toAssigneeId": toAssigneeID, + "toTeamId": toTeamID, + "reason": strings.TrimSpace(reason), + }) +} + +func systemDispatchPrincipal() *dto.AuthPrincipal { + return &dto.AuthPrincipal{ + UserID: 0, + Username: "system", + Nickname: "system", + } +} + +func zeroTime(value *time.Time) time.Time { + if value == nil { + return time.Time{} + } + return *value +} diff --git a/internal/services/conversation_event_log_service.go b/internal/services/conversation_event_log_service.go new file mode 100644 index 0000000..84a6aa4 --- /dev/null +++ b/internal/services/conversation_event_log_service.go @@ -0,0 +1,88 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/tracex" + "agent-desk/internal/repositories" + "strings" + "time" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var ConversationEventLogService = newConversationEventLogService() + +func newConversationEventLogService() *conversationEventLogService { + return &conversationEventLogService{} +} + +type conversationEventLogService struct { +} + +func (s *conversationEventLogService) Get(id int64) *models.ConversationEventLog { + return repositories.ConversationEventLogRepository.Get(sqls.DB(), id) +} + +func (s *conversationEventLogService) Take(where ...interface{}) *models.ConversationEventLog { + return repositories.ConversationEventLogRepository.Take(sqls.DB(), where...) +} + +func (s *conversationEventLogService) Find(cnd *sqls.Cnd) []models.ConversationEventLog { + return repositories.ConversationEventLogRepository.Find(sqls.DB(), cnd) +} + +func (s *conversationEventLogService) FindOne(cnd *sqls.Cnd) *models.ConversationEventLog { + return repositories.ConversationEventLogRepository.FindOne(sqls.DB(), cnd) +} + +func (s *conversationEventLogService) FindPageByParams(params *params.QueryParams) (list []models.ConversationEventLog, paging *sqls.Paging) { + return repositories.ConversationEventLogRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *conversationEventLogService) FindPageByCnd(cnd *sqls.Cnd) (list []models.ConversationEventLog, paging *sqls.Paging) { + return repositories.ConversationEventLogRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *conversationEventLogService) Count(cnd *sqls.Cnd) int64 { + return repositories.ConversationEventLogRepository.Count(sqls.DB(), cnd) +} + +func (s *conversationEventLogService) Create(t *models.ConversationEventLog) error { + return repositories.ConversationEventLogRepository.Create(sqls.DB(), t) +} + +func (s *conversationEventLogService) Update(t *models.ConversationEventLog) error { + return repositories.ConversationEventLogRepository.Update(sqls.DB(), t) +} + +func (s *conversationEventLogService) Updates(id int64, columns map[string]interface{}) error { + return repositories.ConversationEventLogRepository.Updates(sqls.DB(), id, columns) +} + +func (s *conversationEventLogService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.ConversationEventLogRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *conversationEventLogService) Delete(id int64) { + repositories.ConversationEventLogRepository.Delete(sqls.DB(), id) +} + +func (s *conversationEventLogService) CreateEvent(ctx *sqls.TxContext, conversationID int64, eventType enums.IMEventType, operatorType enums.IMSenderType, operatorID int64, content, payload string) error { + return s.CreateEventWithRequestID(ctx, conversationID, "", eventType, operatorType, operatorID, content, payload) +} + +func (s *conversationEventLogService) CreateEventWithRequestID(ctx *sqls.TxContext, conversationID int64, requestID string, eventType enums.IMEventType, operatorType enums.IMSenderType, operatorID int64, content, payload string) error { + return repositories.ConversationEventLogRepository.Create(ctx.Tx, &models.ConversationEventLog{ + ConversationID: conversationID, + RequestID: tracex.NormalizeRequestID(requestID), + EventType: eventType, + OperatorType: operatorType, + OperatorID: operatorID, + Content: strings.TrimSpace(content), + Payload: strings.TrimSpace(payload), + CreatedAt: time.Now(), + }) +} diff --git a/internal/services/conversation_human_dispatch_realtime_test.go b/internal/services/conversation_human_dispatch_realtime_test.go new file mode 100644 index 0000000..ad01258 --- /dev/null +++ b/internal/services/conversation_human_dispatch_realtime_test.go @@ -0,0 +1,242 @@ +package services + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestAIHandoffPublishesFinalAssignedConversationEvent(t *testing.T) { + db := setupHumanDispatchRealtimeTestDB(t) + WsService = newWsService() + session := captureHumanDispatchRealtimeSession(t, "admin:101", "admin:all") + aiAgent := createHumanDispatchRealtimeAIAgent(t, db, "1") + createHumanDispatchRealtimeTeam(t, db, 1) + createHumanDispatchRealtimeActiveSchedule(t, db, 1) + createHumanDispatchRealtimeAgentProfile(t, db, 101, 1) + conversation := createHumanDispatchRealtimeConversation(t, db, aiAgent.ID) + + result, err := ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "用户要求转人工") + if err != nil { + t.Fatalf("HandoffByAI() error = %v", err) + } + if result == nil || result.Decision != HandoffDecisionAssigned { + t.Fatalf("expected assigned decision, got %+v", result) + } + + event := findHumanDispatchRealtimeEvent(t, session, enums.IMRealtimeEventConversationAssigned) + if event.Data["conversationId"] != float64(conversation.ID) { + t.Fatalf("unexpected conversation id in event: %+v", event.Data) + } + if event.Data["status"] != float64(enums.IMConversationStatusActive) { + t.Fatalf("expected active status in assigned event, got %+v", event.Data["status"]) + } + if event.Data["currentAssigneeId"] != float64(101) { + t.Fatalf("expected assignee 101 in assigned event, got %+v", event.Data["currentAssigneeId"]) + } +} + +func TestAIHandoffPublishesFinalTeamPoolConversationEvent(t *testing.T) { + db := setupHumanDispatchRealtimeTestDB(t) + WsService = newWsService() + session := captureHumanDispatchRealtimeSession(t, "admin:all") + aiAgent := createHumanDispatchRealtimeAIAgent(t, db, "1") + createHumanDispatchRealtimeTeam(t, db, 1) + createHumanDispatchRealtimeActiveSchedule(t, db, 1) + conversation := createHumanDispatchRealtimeConversation(t, db, aiAgent.ID) + + result, err := ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "用户要求转人工") + if err != nil { + t.Fatalf("HandoffByAI() error = %v", err) + } + if result == nil || result.Decision != HandoffDecisionTeamPool { + t.Fatalf("expected team_pool decision, got %+v", result) + } + + event := findHumanDispatchRealtimeEvent(t, session, enums.IMRealtimeEventConversationUpdated, func(event humanDispatchRealtimeEvent) bool { + return event.Data["currentTeamId"] == float64(1) + }) + if event.Data["conversationId"] != float64(conversation.ID) { + t.Fatalf("unexpected conversation id in event: %+v", event.Data) + } + if event.Data["status"] != float64(enums.IMConversationStatusPending) { + t.Fatalf("expected pending status in updated event, got %+v", event.Data["status"]) + } + if value, ok := event.Data["currentAssigneeId"]; ok && value != float64(0) { + t.Fatalf("expected no assignee in updated event, got %+v", event.Data["currentAssigneeId"]) + } +} + +type humanDispatchRealtimeEvent struct { + Type string `json:"type"` + Data map[string]any `json:"data"` +} + +func findHumanDispatchRealtimeEvent(t *testing.T, session *ClientSession, eventType string, matchers ...func(humanDispatchRealtimeEvent) bool) humanDispatchRealtimeEvent { + t.Helper() + timeout := time.After(500 * time.Millisecond) + for { + select { + case raw := <-session.Send: + var event humanDispatchRealtimeEvent + if err := json.Unmarshal(raw, &event); err != nil { + t.Fatalf("decode realtime event: %v", err) + } + if event.Type != eventType { + continue + } + matched := true + for _, matcher := range matchers { + if !matcher(event) { + matched = false + break + } + } + if matched { + return event + } + case <-timeout: + t.Fatalf("expected realtime event %q", eventType) + } + } +} + +func captureHumanDispatchRealtimeSession(t *testing.T, topics ...string) *ClientSession { + t.Helper() + session := &ClientSession{ + ID: "test-session", + Role: realtimeRoleAdmin, + Topics: map[string]struct{}{}, + Send: make(chan []byte, 32), + } + WsService.manager.Register(session, topics) + return session +} + +func setupHumanDispatchRealtimeTestDB(t *testing.T) *gorm.DB { + t.Helper() + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate( + &models.User{}, + &models.Notification{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &models.Channel{}, + &models.AIAgent{}, + &models.AgentTeam{}, + &models.AgentTeamSchedule{}, + &models.AgentProfile{}, + &models.Conversation{}, + &models.ConversationParticipant{}, + &models.ConversationAssignment{}, + &models.ConversationEventLog{}, + &models.ConversationReadState{}, + &models.Message{}, + &models.ChannelMessageOutbox{}, + ); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + return db +} + +func createHumanDispatchRealtimeAIAgent(t *testing.T, db *gorm.DB, teamIDs string) models.AIAgent { + t.Helper() + item := models.AIAgent{ + Name: "测试AI", + ServiceMode: enums.IMConversationServiceModeAIFirst, + TeamIDs: teamIDs, + Status: enums.StatusOk, + } + if err := db.Create(&item).Error; err != nil { + t.Fatalf("create ai agent error = %v", err) + } + return item +} + +func createHumanDispatchRealtimeTeam(t *testing.T, db *gorm.DB, id int64) { + t.Helper() + if err := db.Create(&models.AgentTeam{ID: id, Name: "售后支持组", Status: enums.StatusOk}).Error; err != nil { + t.Fatalf("create team error = %v", err) + } +} + +func createHumanDispatchRealtimeActiveSchedule(t *testing.T, db *gorm.DB, teamID int64) { + t.Helper() + now := time.Now() + if err := db.Create(&models.AgentTeamSchedule{ + TeamID: teamID, + StartAt: now.Add(-time.Hour), + EndAt: now.Add(time.Hour), + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create schedule error = %v", err) + } +} + +func createHumanDispatchRealtimeAgentProfile(t *testing.T, db *gorm.DB, userID, teamID int64) { + t.Helper() + if err := db.Create(&models.User{ + ID: userID, + Username: "agent", + Nickname: "客服", + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create user error = %v", err) + } + if err := db.Create(&models.AgentProfile{ + UserID: userID, + TeamID: teamID, + AgentCode: "A001", + DisplayName: "客服", + ServiceStatus: enums.ServiceStatusIdle, + MaxConcurrentCount: 3, + AutoAssignEnabled: true, + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create profile error = %v", err) + } +} + +func createHumanDispatchRealtimeConversation(t *testing.T, db *gorm.DB, aiAgentID int64) models.Conversation { + t.Helper() + now := time.Now() + item := models.Conversation{ + AIAgentID: aiAgentID, + ChannelID: 1, + CustomerID: 1, + CustomerName: "测试访客", + Status: enums.IMConversationStatusAIServing, + ServiceMode: enums.IMConversationServiceModeAIFirst, + LastMessageAt: now, + LastActiveAt: now, + } + if err := db.Create(&item).Error; err != nil { + t.Fatalf("create conversation error = %v", err) + } + return item +} diff --git a/internal/services/conversation_human_dispatch_service.go b/internal/services/conversation_human_dispatch_service.go new file mode 100644 index 0000000..91f1252 --- /dev/null +++ b/internal/services/conversation_human_dispatch_service.go @@ -0,0 +1,443 @@ +package services + +import ( + "context" + "fmt" + "strings" + "time" + + "agent-desk/internal/ai" + "agent-desk/internal/events" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/eventbus" + "agent-desk/internal/pkg/i18nx" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +var ConversationHumanDispatchService = newConversationHumanDispatchService() + +var ( + HandoffWaitingMessage = HandoffWaitingMessageForLocale(i18nx.DefaultLocale) + HandoffOffHoursMessage = HandoffOffHoursMessageForLocale(i18nx.DefaultLocale) +) + +func HandoffWaitingMessageForLocale(locale string) string { + return i18nx.Getf(locale, "conversation.handoff.waiting") +} + +func HandoffOffHoursMessageForLocale(locale string) string { + return i18nx.Getf(locale, "conversation.handoff.offHours") +} + +type HandoffDecisionType string + +const ( + HandoffDecisionAssigned HandoffDecisionType = "assigned" + HandoffDecisionTeamPool HandoffDecisionType = "team_pool" + HandoffDecisionGlobalPool HandoffDecisionType = "global_pool" + HandoffDecisionOffHours HandoffDecisionType = "off_hours" +) + +type HandoffDecisionResult struct { + Decision HandoffDecisionType + TeamID int64 + AssigneeID int64 + Message string +} + +type conversationHumanDispatchService struct{} + +func newConversationHumanDispatchService() *conversationHumanDispatchService { + return &conversationHumanDispatchService{} +} + +func (s *conversationHumanDispatchService) ResolveConversationLocale(conversationID int64) string { + if conversationID <= 0 { + return i18nx.DefaultLocale + } + recentMsgs := repositories.MessageRepository.Find(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conversationID). + Eq("sender_type", enums.IMSenderTypeCustomer). + Desc("id"). + Limit(5)) + for _, msg := range recentMsgs { + content := strings.TrimSpace(msg.Content) + if content == "" { + continue + } + lang := ai.DetectLanguage(content) + switch lang { + case "ru": + return "ru-RU" + case "zh": + return "zh-CN" + } + } + for _, msg := range recentMsgs { + if ai.DetectLanguage(msg.Content) == "en" { + return "en-US" + } + } + conv := repositories.ConversationRepository.Get(sqls.DB(), conversationID) + if conv != nil { + if ai.IsCyrillic(conv.CustomerName) || ai.IsCyrillic(conv.Subject) { + return "ru-RU" + } + if ai.IsHan(conv.CustomerName) || ai.IsHan(conv.Subject) { + return "zh-CN" + } + } + return i18nx.DefaultLocale +} + +func ResolveConversationLocale(conversationID int64) string { + return ConversationHumanDispatchService.ResolveConversationLocale(conversationID) +} + +func (s *conversationHumanDispatchService) TryOffHoursHandoffByAI(conversationID int64, aiAgent models.AIAgent, reason string) (bool, error) { + return s.TryOffHoursHandoffByAIWithRequestID(conversationID, aiAgent, reason, "") +} + +func (s *conversationHumanDispatchService) TryOffHoursHandoffByAIWithRequestID(conversationID int64, aiAgent models.AIAgent, reason string, requestID string) (bool, error) { + conversation := ConversationService.Get(conversationID) + if conversation == nil { + return false, errorsx.InvalidParamI18n("error.e0116") + } + teamIDs := resolveDispatchTeamIDs(aiAgent.TeamIDs) + activeTeamIDs := ConversationDispatchService.findActiveScheduleTeamIDs(teamIDs, time.Now()) + if len(activeTeamIDs) > 0 { + return false, nil + } + + if err := s.markHandoff(conversationID, aiAgent, reason, requestID); err != nil { + return true, err + } + + var queuedConversation *models.Conversation + var poolErr error + if len(teamIDs) > 0 { + queuedConversation, poolErr = s.moveToTeamPoolWithRequestID(conversationID, teamIDs[0], reason, requestID) + } else { + poolErr = s.moveToGlobalPool(conversationID, aiAgent.Name) + queuedConversation = ConversationService.Get(conversationID) + } + if poolErr != nil { + return true, poolErr + } + if queuedConversation != nil { + WsService.PublishConversationChanged(queuedConversation, enums.IMRealtimeEventConversationUpdated) + } + + offHoursMessage := strings.TrimSpace(aiAgent.OffHoursMessage) + if offHoursMessage == "" { + offHoursMessage = HandoffOffHoursMessageForLocale(s.ResolveConversationLocale(conversationID)) + } + if err := s.sendAITextWithRequestID(conversationID, aiAgent.ID, offHoursMessage, requestID); err != nil { + return true, err + } + return true, nil +} + +func (s *conversationHumanDispatchService) HandoffByAI(conversationID int64, aiAgent models.AIAgent, reason string) (*HandoffDecisionResult, error) { + return s.HandoffByAIWithRequestID(conversationID, aiAgent, reason, "") +} + +func (s *conversationHumanDispatchService) HandoffByAIWithRequestID(conversationID int64, aiAgent models.AIAgent, reason string, requestID string) (*HandoffDecisionResult, error) { + conversation := ConversationService.Get(conversationID) + if conversation == nil { + return nil, errorsx.InvalidParamI18n("error.e0116") + } + teamIDs := resolveDispatchTeamIDs(aiAgent.TeamIDs) + activeTeamIDs := ConversationDispatchService.findActiveScheduleTeamIDs(teamIDs, time.Now()) + if len(activeTeamIDs) == 0 { + if _, err := s.TryOffHoursHandoffByAIWithRequestID(conversationID, aiAgent, reason, requestID); err != nil { + return nil, err + } + offHoursMessage := strings.TrimSpace(aiAgent.OffHoursMessage) + if offHoursMessage == "" { + offHoursMessage = HandoffOffHoursMessageForLocale(s.ResolveConversationLocale(conversationID)) + } + var teamID int64 + if len(teamIDs) > 0 { + teamID = teamIDs[0] + } + return &HandoffDecisionResult{Decision: HandoffDecisionOffHours, TeamID: teamID, Message: offHoursMessage}, nil + } + + if err := s.markHandoff(conversationID, aiAgent, reason, requestID); err != nil { + return nil, err + } + return s.dispatchAfterHandoffWithRequestID(conversationID, aiAgent, activeTeamIDs, strings.TrimSpace(reason), true, requestID) +} + +func (s *conversationHumanDispatchService) ApplyHumanOnlyCreate(conversationID int64, aiAgent models.AIAgent) (*HandoffDecisionResult, error) { + teamIDs := resolveDispatchTeamIDs(aiAgent.TeamIDs) + activeTeamIDs := ConversationDispatchService.findActiveScheduleTeamIDs(teamIDs, time.Now()) + waitingMessage := strings.TrimSpace(aiAgent.HandoffMessage) + if waitingMessage == "" { + waitingMessage = HandoffWaitingMessageForLocale(s.ResolveConversationLocale(conversationID)) + } + if len(activeTeamIDs) == 0 { + if err := s.moveToGlobalPool(conversationID, aiAgent.Name); err != nil { + return nil, err + } + if err := s.sendAIText(conversationID, aiAgent.ID, waitingMessage); err != nil { + return nil, err + } + return &HandoffDecisionResult{Decision: HandoffDecisionGlobalPool, Message: waitingMessage}, nil + } + return s.dispatchAfterHandoff(conversationID, aiAgent, activeTeamIDs, "仅人工模式新会话", false) +} + +func (s *conversationHumanDispatchService) DispatchPendingConversation(conversationID int64, aiAgent models.AIAgent) (*HandoffDecisionResult, error) { + conversation := ConversationService.Get(conversationID) + if conversation == nil { + return nil, errorsx.InvalidParamI18n("error.e0116") + } + if conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 { + return nil, errorsx.InvalidParamI18n("error.e0137") + } + activeTeamIDs := ConversationDispatchService.findActiveScheduleTeamIDs(resolveDispatchTeamIDs(aiAgent.TeamIDs), time.Now()) + if len(activeTeamIDs) == 0 { + return &HandoffDecisionResult{Decision: HandoffDecisionOffHours}, nil + } + candidates, _, err := ConversationDispatchService.pickDispatchCandidates(activeTeamIDs, time.Now()) + if err != nil { + return nil, err + } + if len(candidates) > 0 { + dispatched, err := ConversationDispatchService.tryAssignConversation(conversationID, candidates[0].profile, "自动分配") + if err != nil { + return nil, err + } + if dispatched != nil { + WsService.PublishConversationChanged(dispatched, enums.IMRealtimeEventConversationAssigned) + return &HandoffDecisionResult{ + Decision: HandoffDecisionAssigned, + TeamID: dispatched.CurrentTeamID, + AssigneeID: dispatched.CurrentAssigneeID, + }, nil + } + } + teamID := activeTeamIDs[0] + teamPoolConversation, err := s.moveToTeamPool(conversationID, teamID, "手动触发自动分配") + if err != nil { + return nil, err + } + if teamPoolConversation != nil { + WsService.PublishConversationChanged(teamPoolConversation, enums.IMRealtimeEventConversationUpdated) + } + return &HandoffDecisionResult{Decision: HandoffDecisionTeamPool, TeamID: teamID}, nil +} + +func (s *conversationHumanDispatchService) dispatchAfterHandoff(conversationID int64, aiAgent models.AIAgent, activeTeamIDs []int64, reason string, publishAssignEvent bool) (*HandoffDecisionResult, error) { + return s.dispatchAfterHandoffWithRequestID(conversationID, aiAgent, activeTeamIDs, reason, publishAssignEvent, "") +} + +func (s *conversationHumanDispatchService) dispatchAfterHandoffWithRequestID(conversationID int64, aiAgent models.AIAgent, activeTeamIDs []int64, reason string, publishAssignEvent bool, requestID string) (*HandoffDecisionResult, error) { + waitingMessage := strings.TrimSpace(aiAgent.HandoffMessage) + if waitingMessage == "" { + waitingMessage = HandoffWaitingMessageForLocale(s.ResolveConversationLocale(conversationID)) + } + if err := s.sendAITextWithRequestID(conversationID, aiAgent.ID, waitingMessage, requestID); err != nil { + return nil, err + } + + candidates, _, err := ConversationDispatchService.pickDispatchCandidates(activeTeamIDs, time.Now()) + if err != nil { + return nil, err + } + if len(candidates) > 0 { + dispatched, err := ConversationDispatchService.tryAssignConversation(conversationID, candidates[0].profile, "自动分配") + if err != nil { + return nil, err + } + if dispatched != nil { + WsService.PublishConversationChanged(dispatched, enums.IMRealtimeEventConversationAssigned) + if publishAssignEvent { + eventbus.PublishAsync(context.Background(), events.ConversationAssignedEvent{ + ConversationID: dispatched.ID, + ToUserID: dispatched.CurrentAssigneeID, + OperatorID: systemDispatchPrincipal().UserID, + Reason: "自动分配", + AssignType: events.ConversationAssignTypeAutoAssign, + }) + } + return &HandoffDecisionResult{ + Decision: HandoffDecisionAssigned, + TeamID: dispatched.CurrentTeamID, + AssigneeID: dispatched.CurrentAssigneeID, + Message: waitingMessage, + }, nil + } + } + + teamID := activeTeamIDs[0] + teamPoolConversation, err := s.moveToTeamPoolWithRequestID(conversationID, teamID, reason, requestID) + if err != nil { + return nil, err + } + if teamPoolConversation != nil { + WsService.PublishConversationChanged(teamPoolConversation, enums.IMRealtimeEventConversationUpdated) + } + return &HandoffDecisionResult{Decision: HandoffDecisionTeamPool, TeamID: teamID, Message: waitingMessage}, nil +} + +func (s *conversationHumanDispatchService) markHandoff(conversationID int64, aiAgent models.AIAgent, reason string, requestID string) error { + now := time.Now() + trimmedReason := strings.TrimSpace(reason) + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{ + "handoff_at": now, + "handoff_reason": trimmedReason, + "status": enums.IMConversationStatusPending, + "current_team_id": 0, + "current_assignee_id": 0, + "update_user_id": 0, + "update_user_name": aiAgent.Name, + "updated_at": now, + }); err != nil { + return err + } + return ConversationEventLogService.CreateEventWithRequestID(ctx, conversationID, requestID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "AI转人工", trimmedReason) + }) +} + +func (s *conversationHumanDispatchService) moveToTeamPool(conversationID, teamID int64, reason string) (*models.Conversation, error) { + return s.moveToTeamPoolWithRequestID(conversationID, teamID, reason, "") +} + +func (s *conversationHumanDispatchService) moveToTeamPoolWithRequestID(conversationID, teamID int64, reason string, requestID string) (*models.Conversation, error) { + now := time.Now() + var conversation *models.Conversation + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + current := repositories.ConversationRepository.Get(ctx.Tx, conversationID) + if current == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil { + return err + } + if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{ + "status": enums.IMConversationStatusPending, + "current_team_id": teamID, + "current_assignee_id": 0, + "update_user_id": 0, + "update_user_name": "system", + "updated_at": now, + }); err != nil { + return err + } + if err := ConversationEventLogService.CreateEventWithRequestID(ctx, conversationID, requestID, enums.IMEventTypeTransfer, enums.IMSenderTypeSystem, 0, "会话进入客服组待接入", ConversationService.buildEventPayload(map[string]any{ + "fromStatus": current.Status, + "toStatus": enums.IMConversationStatusPending, + "fromAssigneeId": current.CurrentAssigneeID, + "toAssigneeId": int64(0), + "toTeamId": teamID, + "reason": strings.TrimSpace(reason), + "decision": string(HandoffDecisionTeamPool), + })); err != nil { + return err + } + current.Status = enums.IMConversationStatusPending + current.CurrentTeamID = teamID + current.CurrentAssigneeID = 0 + current.UpdateUserID = 0 + current.UpdateUserName = "system" + current.UpdatedAt = now + conversation = current + return nil + }) + if err != nil { + return nil, err + } + return conversation, nil +} + +func (s *conversationHumanDispatchService) moveToGlobalPool(conversationID int64, operatorName string) error { + now := time.Now() + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID) + if conversation == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{ + "status": enums.IMConversationStatusPending, + "current_team_id": 0, + "current_assignee_id": 0, + "update_user_id": 0, + "update_user_name": operatorName, + "updated_at": now, + }); err != nil { + return err + } + return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeSystem, 0, "会话进入全局待接入", ConversationService.buildEventPayload(map[string]any{ + "fromStatus": conversation.Status, + "toStatus": enums.IMConversationStatusPending, + "decision": string(HandoffDecisionGlobalPool), + })) + }) +} + +func (s *conversationHumanDispatchService) createEvent(conversationID int64, eventType enums.IMEventType, senderType enums.IMSenderType, senderID int64, content, payload string) error { + return s.createEventWithRequestID(conversationID, "", eventType, senderType, senderID, content, payload) +} + +func (s *conversationHumanDispatchService) createEventWithRequestID(conversationID int64, requestID string, eventType enums.IMEventType, senderType enums.IMSenderType, senderID int64, content, payload string) error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + return ConversationEventLogService.CreateEventWithRequestID(ctx, conversationID, requestID, eventType, senderType, senderID, content, payload) + }) +} + +func (s *conversationHumanDispatchService) sendAIText(conversationID, aiAgentID int64, content string) error { + return s.sendAITextWithRequestID(conversationID, aiAgentID, content, "") +} + +func (s *conversationHumanDispatchService) sendAITextWithRequestID(conversationID, aiAgentID int64, content string, requestID string) error { + _, err := MessageService.SendAIServiceNoticeWithRequestID(conversationID, aiAgentID, content, requestID) + return err +} + +func resolveDispatchTeamIDs(value string) []int64 { + ids := orderedPositiveIDs(value) + if len(ids) > 0 { + return ids + } + teams := AgentTeamService.Find(sqls.NewCnd().Eq("status", enums.StatusOk).Asc("id")) + if len(teams) == 0 { + return nil + } + fallback := make([]int64, 0, len(teams)) + for _, team := range teams { + if team.ID > 0 { + fallback = append(fallback, team.ID) + } + } + return fallback +} + +func orderedPositiveIDs(value string) []int64 { + return uniquePositiveInt64sFromStrings(strings.Split(value, ",")) +} + +func uniquePositiveInt64sFromStrings(values []string) []int64 { + seen := make(map[int64]struct{}, len(values)) + ret := make([]int64, 0, len(values)) + for _, value := range values { + var id int64 + _, _ = fmt.Sscan(strings.TrimSpace(value), &id) + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ret = append(ret, id) + } + return ret +} diff --git a/internal/services/conversation_human_dispatch_service_test.go b/internal/services/conversation_human_dispatch_service_test.go new file mode 100644 index 0000000..c2b29ef --- /dev/null +++ b/internal/services/conversation_human_dispatch_service_test.go @@ -0,0 +1,423 @@ +package services_test + +import ( + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/services" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestConversationHumanDispatchAIHandoffOffHoursQueuesAndSendsNotice(t *testing.T) { + db := setupConversationHumanDispatchTestDB(t) + aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1") + conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing) + + result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "用户要求转人工") + if err != nil { + t.Fatalf("HandoffByAI() error = %v", err) + } + if result == nil || result.Decision != services.HandoffDecisionOffHours { + t.Fatalf("expected off_hours decision, got %+v", result) + } + + current := services.ConversationService.Get(conversation.ID) + if current.Status != enums.IMConversationStatusPending { + t.Fatalf("expected conversation to be queued in pending status, got status=%d", current.Status) + } + if current.HandoffAt == nil { + t.Fatalf("expected handoffAt to be set") + } + if current.CurrentTeamID != 1 || current.CurrentAssigneeID != 0 { + t.Fatalf("expected conversation in team pool 1, got team=%d assignee=%d", current.CurrentTeamID, current.CurrentAssigneeID) + } + + message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id")) + if message == nil { + t.Fatalf("expected off-hours notice message") + } + expectedMsg := services.HandoffOffHoursMessageForLocale("zh-CN") + if message.SenderType != enums.IMSenderTypeAI || message.Content != expectedMsg { + t.Fatalf("unexpected off-hours message: %+v, want %q", message, expectedMsg) + } +} + +func TestConversationHumanDispatchCustomOffHoursMessage(t *testing.T) { + db := setupConversationHumanDispatchTestDB(t) + aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1") + aiAgent.OffHoursMessage = "Операторы сейчас оффлайн. Мы ответим вам в рабочее время!" + conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing) + + result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "нужен оператор") + if err != nil { + t.Fatalf("HandoffByAI() error = %v", err) + } + if result == nil || result.Decision != services.HandoffDecisionOffHours { + t.Fatalf("expected off_hours decision, got %+v", result) + } + if result.Message != aiAgent.OffHoursMessage { + t.Fatalf("expected custom off-hours message %q, got %q", aiAgent.OffHoursMessage, result.Message) + } + + message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id")) + if message == nil { + t.Fatalf("expected off-hours notice message") + } + if message.Content != aiAgent.OffHoursMessage { + t.Fatalf("expected custom notice %q, got %q", aiAgent.OffHoursMessage, message.Content) + } +} + +func TestConversationHumanDispatchAIHandoffAssignsAvailableAgent(t *testing.T) { + db := setupConversationHumanDispatchTestDB(t) + aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1") + createHumanDispatchTeam(t, db, 1, "售后支持组") + createHumanDispatchActiveSchedule(t, db, 1) + createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 3, true, enums.StatusOk) + conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing) + + result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "用户要求转人工") + if err != nil { + t.Fatalf("HandoffByAI() error = %v", err) + } + if result == nil || result.Decision != services.HandoffDecisionAssigned { + t.Fatalf("expected assigned decision, got %+v", result) + } + + current := services.ConversationService.Get(conversation.ID) + if current.Status != enums.IMConversationStatusActive { + t.Fatalf("expected active conversation, got status=%d", current.Status) + } + if current.CurrentAssigneeID != 101 || current.CurrentTeamID != 1 { + t.Fatalf("unexpected assignment: assignee=%d team=%d", current.CurrentAssigneeID, current.CurrentTeamID) + } + if current.HandoffAt == nil || current.HandoffReason != "用户要求转人工" { + t.Fatalf("expected handoff metadata, got at=%v reason=%q", current.HandoffAt, current.HandoffReason) + } +} + +func TestConversationHumanDispatchAIHandoffFallsBackToFirstScheduledTeam(t *testing.T) { + db := setupConversationHumanDispatchTestDB(t) + aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "3,1,2") + createHumanDispatchTeam(t, db, 1, "售后支持组") + createHumanDispatchTeam(t, db, 2, "VIP支持组") + createHumanDispatchTeam(t, db, 3, "非值班组") + createHumanDispatchActiveSchedule(t, db, 1) + createHumanDispatchActiveSchedule(t, db, 2) + conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing) + + result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "用户要求转人工") + if err != nil { + t.Fatalf("HandoffByAI() error = %v", err) + } + if result == nil || result.Decision != services.HandoffDecisionTeamPool { + t.Fatalf("expected team_pool decision, got %+v", result) + } + + current := services.ConversationService.Get(conversation.ID) + if current.Status != enums.IMConversationStatusPending { + t.Fatalf("expected pending conversation, got status=%d", current.Status) + } + if current.CurrentTeamID != 1 || current.CurrentAssigneeID != 0 { + t.Fatalf("expected fallback team 1 with no assignee, got team=%d assignee=%d", current.CurrentTeamID, current.CurrentAssigneeID) + } +} + +func TestConversationHumanDispatchEmptyAgentTeamIDsFallsBackToActiveTeam(t *testing.T) { + db := setupConversationHumanDispatchTestDB(t) + aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "") + createHumanDispatchTeam(t, db, 1, "Отдел поддержки") + createHumanDispatchActiveSchedule(t, db, 1) + createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 3, true, enums.StatusOk) + conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing) + + result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "AI execution failure") + if err != nil { + t.Fatalf("HandoffByAI() error = %v", err) + } + if result == nil || result.Decision != services.HandoffDecisionAssigned { + t.Fatalf("expected assigned decision when active team has schedule and idle agent even with empty aiAgent.TeamIDs, got %+v", result) + } + + current := services.ConversationService.Get(conversation.ID) + if current.Status != enums.IMConversationStatusActive || current.CurrentAssigneeID != 101 || current.CurrentTeamID != 1 { + t.Fatalf("expected active assigned conversation (team=1, assignee=101), got status=%d team=%d assignee=%d", current.Status, current.CurrentTeamID, current.CurrentAssigneeID) + } +} + +func TestConversationHumanDispatchHumanOnlyCreateOffHoursUsesGlobalPendingPool(t *testing.T) { + db := setupConversationHumanDispatchTestDB(t) + aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeHumanOnly, "1") + + conversation, err := services.ConversationService.Create(openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceGuest, + ExternalID: "guest-human-only-off-hours", + ExternalName: "非服务时间访客", + }, 1, aiAgent.ID) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if conversation.Status != enums.IMConversationStatusPending { + t.Fatalf("expected pending conversation, got status=%d", conversation.Status) + } + if conversation.CurrentTeamID != 0 || conversation.CurrentAssigneeID != 0 { + t.Fatalf("expected global pending pool, got team=%d assignee=%d", conversation.CurrentTeamID, conversation.CurrentAssigneeID) + } + + message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id")) + expectedWaiting := services.HandoffWaitingMessageForLocale("zh-CN") + if message == nil || message.Content != expectedWaiting { + t.Fatalf("expected waiting message %q, got %+v", expectedWaiting, message) + } +} + +func TestConversationHumanDispatchHumanOnlyCreateAssignsAvailableAgent(t *testing.T) { + db := setupConversationHumanDispatchTestDB(t) + aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeHumanOnly, "1") + createHumanDispatchTeam(t, db, 1, "售后支持组") + createHumanDispatchActiveSchedule(t, db, 1) + createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 3, true, enums.StatusOk) + + conversation, err := services.ConversationService.Create(openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceGuest, + ExternalID: "guest-human-only-assigned", + ExternalName: "服务时间访客", + }, 1, aiAgent.ID) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if conversation.Status != enums.IMConversationStatusActive { + t.Fatalf("expected active conversation, got status=%d", conversation.Status) + } + if conversation.CurrentAssigneeID != 101 || conversation.CurrentTeamID != 1 { + t.Fatalf("unexpected assignment: assignee=%d team=%d", conversation.CurrentAssigneeID, conversation.CurrentTeamID) + } +} + +func TestConversationAutoAssignManualDispatchOffHoursReturnsBusinessMessage(t *testing.T) { + db := setupConversationHumanDispatchTestDB(t) + aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1") + conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusPending) + + err := services.ConversationService.AutoAssignConversation(conversation.ID, testHumanDispatchOperator()) + if err == nil { + t.Fatalf("expected off-hours manual dispatch to fail") + } + if !strings.Contains(err.Error(), "Human support is currently outside service hours") && !strings.Contains(err.Error(), "当前暂不在人工客服服务时间内") { + t.Fatalf("expected off-hours error, got %v", err) + } +} + +func TestConversationAutoAssignManualDispatchFallsBackToTeamPool(t *testing.T) { + db := setupConversationHumanDispatchTestDB(t) + aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1") + createHumanDispatchTeam(t, db, 1, "售后支持组") + createHumanDispatchActiveSchedule(t, db, 1) + conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusPending) + + err := services.ConversationService.AutoAssignConversation(conversation.ID, testHumanDispatchOperator()) + if err != nil { + t.Fatalf("AutoAssignConversation() error = %v", err) + } + current := services.ConversationService.Get(conversation.ID) + if current.Status != enums.IMConversationStatusPending || current.CurrentTeamID != 1 || current.CurrentAssigneeID != 0 { + t.Fatalf("expected team-pool pending conversation, got %+v", current) + } +} + +func setupConversationHumanDispatchTestDB(t *testing.T) *gorm.DB { + t.Helper() + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate( + &models.User{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &models.AIAgent{}, + &models.AgentTeam{}, + &models.AgentTeamSchedule{}, + &models.AgentProfile{}, + &models.Conversation{}, + &models.ConversationParticipant{}, + &models.ConversationAssignment{}, + &models.ConversationEventLog{}, + &models.ConversationReadState{}, + &models.Message{}, + &models.ChannelMessageOutbox{}, + ); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + return db +} + +func createHumanDispatchAIAgent(t *testing.T, db *gorm.DB, mode enums.IMConversationServiceMode, teamIDs string) models.AIAgent { + t.Helper() + item := models.AIAgent{ + Name: "测试AI", + ServiceMode: mode, + TeamIDs: teamIDs, + Status: enums.StatusOk, + } + if err := db.Create(&item).Error; err != nil { + t.Fatalf("create ai agent error = %v", err) + } + return item +} + +func createHumanDispatchTeam(t *testing.T, db *gorm.DB, id int64, name string) { + t.Helper() + if err := db.Create(&models.AgentTeam{ID: id, Name: name, Status: enums.StatusOk}).Error; err != nil { + t.Fatalf("create team error = %v", err) + } +} + +func createHumanDispatchActiveSchedule(t *testing.T, db *gorm.DB, teamID int64) { + t.Helper() + now := time.Now() + if err := db.Create(&models.AgentTeamSchedule{ + TeamID: teamID, + StartAt: now.Add(-time.Hour), + EndAt: now.Add(time.Hour), + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create schedule error = %v", err) + } +} + +func createHumanDispatchAgentProfile(t *testing.T, db *gorm.DB, userID, teamID int64, serviceStatus enums.ServiceStatus, maxConcurrent int, autoAssign bool, status enums.Status) { + t.Helper() + if err := db.Create(&models.User{ + ID: userID, + Username: "agent", + Nickname: "客服", + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create user error = %v", err) + } + if err := db.Create(&models.AgentProfile{ + UserID: userID, + TeamID: teamID, + AgentCode: "A001", + DisplayName: "客服", + ServiceStatus: serviceStatus, + MaxConcurrentCount: maxConcurrent, + AutoAssignEnabled: autoAssign, + Status: status, + }).Error; err != nil { + t.Fatalf("create profile error = %v", err) + } +} + +func createHumanDispatchConversation(t *testing.T, db *gorm.DB, aiAgentID int64, status enums.IMConversationStatus) models.Conversation { + t.Helper() + now := time.Now() + item := models.Conversation{ + AIAgentID: aiAgentID, + ChannelID: 1, + CustomerID: 1, + CustomerName: "测试访客", + Status: status, + ServiceMode: enums.IMConversationServiceModeAIFirst, + LastMessageAt: now, + LastActiveAt: now, + } + if err := db.Create(&item).Error; err != nil { + t.Fatalf("create conversation error = %v", err) + } + return item +} + +func testHumanDispatchOperator() *dto.AuthPrincipal { + return &dto.AuthPrincipal{UserID: 9, Username: "dispatcher", Nickname: "调度员"} +} + +func TestConversationHumanDispatchCustomHandoffMessage(t *testing.T) { + db := setupConversationHumanDispatchTestDB(t) + aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1") + aiAgent.HandoffMessage = "客服专员马上为您服务,请稍候..." + db.Save(&aiAgent) + createHumanDispatchTeam(t, db, 1, "售后支持组") + createHumanDispatchActiveSchedule(t, db, 1) + createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 3, true, enums.StatusOk) + conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing) + + result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "测试自定义转接语") + if err != nil { + t.Fatalf("HandoffByAI() error = %v", err) + } + if result == nil || result.Message != "客服专员马上为您服务,请稍候..." { + t.Fatalf("expected custom handoff message in result, got %+v", result) + } + + message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id")) + if message == nil { + t.Fatalf("expected message to be sent") + } + if message.Content != "客服专员马上为您服务,请稍候..." { + t.Fatalf("expected custom message content, got %q", message.Content) + } +} + +func TestHandoffByAIUsesConversationLanguageForWaitingMessage(t *testing.T) { + db := setupConversationHumanDispatchTestDB(t) + aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1") + createHumanDispatchTeam(t, db, 1, "Отдел поддержки") + createHumanDispatchActiveSchedule(t, db, 1) + createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 3, true, enums.StatusOk) + + // 1. Russian customer message in conversation + convRu := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing) + services.MessageService.Create(&models.Message{ + ConversationID: convRu.ID, + SenderType: enums.IMSenderTypeCustomer, + Content: "Здравствуйте, соедините со специалистом, пожалуйста.", + }) + + resultRu, err := services.ConversationHumanDispatchService.HandoffByAI(convRu.ID, aiAgent, "клиент запросил оператора") + if err != nil { + t.Fatalf("HandoffByAI() error = %v", err) + } + if resultRu == nil || resultRu.Message != "Мы соединяем вас с оператором поддержки. Пожалуйста, подождите." { + t.Fatalf("expected Russian handoff waiting message, got %+v", resultRu) + } + + // 2. English customer message in conversation + convEn := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing) + services.MessageService.Create(&models.Message{ + ConversationID: convEn.ID, + SenderType: enums.IMSenderTypeCustomer, + Content: "Can I speak with a human support agent please?", + }) + + resultEn, err := services.ConversationHumanDispatchService.HandoffByAI(convEn.ID, aiAgent, "customer requested human support") + if err != nil { + t.Fatalf("HandoffByAI() error = %v", err) + } + if resultEn == nil || resultEn.Message != "We are connecting you to a human support agent. Please wait." { + t.Fatalf("expected English handoff waiting message, got %+v", resultEn) + } +} diff --git a/internal/services/conversation_interrupt_service.go b/internal/services/conversation_interrupt_service.go new file mode 100644 index 0000000..244f274 --- /dev/null +++ b/internal/services/conversation_interrupt_service.go @@ -0,0 +1,195 @@ +package services + +import ( + "encoding/base64" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +var ConversationInterruptService = newConversationInterruptService() + +func newConversationInterruptService() *conversationInterruptService { + return &conversationInterruptService{} +} + +type conversationInterruptService struct{} + +func (s *conversationInterruptService) Get(id int64) *models.ConversationInterrupt { + return repositories.ConversationInterruptRepository.Get(sqls.DB(), id) +} + +func (s *conversationInterruptService) GetByCheckPointID(checkPointID string) *models.ConversationInterrupt { + checkPointID = strings.TrimSpace(checkPointID) + if checkPointID == "" { + return nil + } + return repositories.ConversationInterruptRepository.GetByCheckPointID(sqls.DB(), checkPointID) +} + +func (s *conversationInterruptService) FindLatestPendingByConversationID(conversationID int64) *models.ConversationInterrupt { + if conversationID <= 0 { + return nil + } + return repositories.ConversationInterruptRepository.FindLatestPendingByConversationID(sqls.DB(), conversationID) +} + +func (s *conversationInterruptService) SaveCheckpoint(checkPointID string, data []byte) error { + checkPointID = strings.TrimSpace(checkPointID) + if checkPointID == "" { + return nil + } + now := time.Now() + item := &models.ConversationInterrupt{ + CheckPointID: checkPointID, + CheckPointData: base64.StdEncoding.EncodeToString(data), + Status: "checkpointed", + CreatedAt: now, + UpdatedAt: now, + } + item = s.mergeForCheckpointUpdate(s.GetByCheckPointID(checkPointID), item) + return repositories.ConversationInterruptRepository.UpsertByCheckPointID(sqls.DB(), item) +} + +func (s *conversationInterruptService) LoadCheckpoint(checkPointID string) ([]byte, bool, error) { + item := s.GetByCheckPointID(checkPointID) + if item == nil || strings.TrimSpace(item.CheckPointData) == "" { + return nil, false, nil + } + data, err := base64.StdEncoding.DecodeString(item.CheckPointData) + if err != nil { + return nil, false, err + } + return data, true, nil +} + +func (s *conversationInterruptService) CreateOrUpdatePending(item *models.ConversationInterrupt) error { + if item == nil { + return nil + } + now := time.Now() + if item.CreatedAt.IsZero() { + item.CreatedAt = now + } + item.UpdatedAt = now + item.Status = strings.TrimSpace(item.Status) + if item.Status == "" { + item.Status = "pending" + } + item = s.mergeForPendingUpdate(s.GetByCheckPointID(item.CheckPointID), item) + return repositories.ConversationInterruptRepository.UpsertByCheckPointID(sqls.DB(), item) +} + +func (s *conversationInterruptService) mergeForCheckpointUpdate(current, next *models.ConversationInterrupt) *models.ConversationInterrupt { + if next == nil { + return nil + } + if current == nil { + return next + } + merged := *current + merged.ConversationID = current.ConversationID + merged.AIAgentID = current.AIAgentID + merged.AgentRunID = current.AgentRunID + merged.AgentStepID = current.AgentStepID + merged.SourceMessageID = current.SourceMessageID + merged.LastResumeMessageID = current.LastResumeMessageID + merged.WorkflowRunID = current.WorkflowRunID + merged.WorkflowNodeID = current.WorkflowNodeID + merged.InterruptID = current.InterruptID + merged.InterruptType = current.InterruptType + merged.Status = current.Status + merged.PromptText = current.PromptText + merged.RequestData = current.RequestData + merged.ResumeCount = current.ResumeCount + merged.ExpiresAt = current.ExpiresAt + merged.CheckPointData = next.CheckPointData + merged.UpdatedAt = next.UpdatedAt + return &merged +} + +func (s *conversationInterruptService) mergeForPendingUpdate(current, next *models.ConversationInterrupt) *models.ConversationInterrupt { + if next == nil { + return nil + } + if current == nil { + return next + } + merged := *current + merged.ConversationID = next.ConversationID + merged.AIAgentID = next.AIAgentID + merged.AgentRunID = next.AgentRunID + merged.AgentStepID = next.AgentStepID + merged.SourceMessageID = next.SourceMessageID + merged.WorkflowRunID = next.WorkflowRunID + merged.WorkflowNodeID = next.WorkflowNodeID + merged.InterruptID = next.InterruptID + merged.InterruptType = next.InterruptType + merged.Status = next.Status + merged.PromptText = next.PromptText + merged.RequestData = next.RequestData + merged.UpdatedAt = next.UpdatedAt + return &merged +} + +func (s *conversationInterruptService) MarkResolved(id int64, lastResumeMessageID int64) error { + current := s.Get(id) + nextCount := 1 + if current != nil { + nextCount = current.ResumeCount + 1 + } + return repositories.ConversationInterruptRepository.Updates(sqls.DB(), id, map[string]any{ + "status": "resolved", + "last_resume_message_id": lastResumeMessageID, + "resume_count": nextCount, + "updated_at": time.Now(), + }) +} + +func (s *conversationInterruptService) MarkCancelled(id int64, lastResumeMessageID int64) error { + current := s.Get(id) + nextCount := 1 + if current != nil { + nextCount = current.ResumeCount + 1 + } + return repositories.ConversationInterruptRepository.Updates(sqls.DB(), id, map[string]any{ + "status": "cancelled", + "last_resume_message_id": lastResumeMessageID, + "resume_count": nextCount, + "updated_at": time.Now(), + }) +} + +func (s *conversationInterruptService) MarkExpired(id int64, lastResumeMessageID int64) error { + current := s.Get(id) + nextCount := 1 + if current != nil { + nextCount = current.ResumeCount + 1 + } + return repositories.ConversationInterruptRepository.Updates(sqls.DB(), id, map[string]any{ + "status": "expired", + "last_resume_message_id": lastResumeMessageID, + "resume_count": nextCount, + "updated_at": time.Now(), + }) +} + +func (s *conversationInterruptService) MarkPendingAgain(id int64, interruptID, promptText string, lastResumeMessageID int64) error { + current := s.Get(id) + nextCount := 1 + if current != nil { + nextCount = current.ResumeCount + 1 + } + return repositories.ConversationInterruptRepository.Updates(sqls.DB(), id, map[string]any{ + "status": "pending", + "interrupt_id": strings.TrimSpace(interruptID), + "prompt_text": strings.TrimSpace(promptText), + "last_resume_message_id": lastResumeMessageID, + "resume_count": nextCount, + "updated_at": time.Now(), + }) +} diff --git a/internal/services/conversation_participant_service.go b/internal/services/conversation_participant_service.go new file mode 100644 index 0000000..d7a5c3a --- /dev/null +++ b/internal/services/conversation_participant_service.go @@ -0,0 +1,83 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + "time" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var ConversationParticipantService = newConversationParticipantService() + +func newConversationParticipantService() *conversationParticipantService { + return &conversationParticipantService{} +} + +type conversationParticipantService struct { +} + +func (s *conversationParticipantService) Get(id int64) *models.ConversationParticipant { + return repositories.ConversationParticipantRepository.Get(sqls.DB(), id) +} + +func (s *conversationParticipantService) Take(where ...interface{}) *models.ConversationParticipant { + return repositories.ConversationParticipantRepository.Take(sqls.DB(), where...) +} + +func (s *conversationParticipantService) Find(cnd *sqls.Cnd) []models.ConversationParticipant { + return repositories.ConversationParticipantRepository.Find(sqls.DB(), cnd) +} + +func (s *conversationParticipantService) FindOne(cnd *sqls.Cnd) *models.ConversationParticipant { + return repositories.ConversationParticipantRepository.FindOne(sqls.DB(), cnd) +} + +func (s *conversationParticipantService) FindPageByParams(params *params.QueryParams) (list []models.ConversationParticipant, paging *sqls.Paging) { + return repositories.ConversationParticipantRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *conversationParticipantService) FindPageByCnd(cnd *sqls.Cnd) (list []models.ConversationParticipant, paging *sqls.Paging) { + return repositories.ConversationParticipantRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *conversationParticipantService) Count(cnd *sqls.Cnd) int64 { + return repositories.ConversationParticipantRepository.Count(sqls.DB(), cnd) +} + +func (s *conversationParticipantService) Create(t *models.ConversationParticipant) error { + return repositories.ConversationParticipantRepository.Create(sqls.DB(), t) +} + +func (s *conversationParticipantService) Update(t *models.ConversationParticipant) error { + return repositories.ConversationParticipantRepository.Update(sqls.DB(), t) +} + +func (s *conversationParticipantService) Updates(id int64, columns map[string]interface{}) error { + return repositories.ConversationParticipantRepository.Updates(sqls.DB(), id, columns) +} + +func (s *conversationParticipantService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.ConversationParticipantRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *conversationParticipantService) Delete(id int64) { + repositories.ConversationParticipantRepository.Delete(sqls.DB(), id) +} + +func (s *conversationParticipantService) CreateCustomerParticipant(ctx *sqls.TxContext, conversationID int64, externalUser openidentity.ExternalUser) error { + return repositories.ConversationParticipantRepository.Create(ctx.Tx, &models.ConversationParticipant{ + ConversationID: conversationID, + ParticipantType: string(enums.IMParticipantTypeCustomer), + ParticipantID: 0, + ExternalParticipantID: externalUser.ExternalID, + JoinedAt: new(time.Now()), + Status: enums.StatusOk, + AuditFields: utils.BuildAuditFields(nil), + }) +} diff --git a/internal/services/conversation_read_state_service.go b/internal/services/conversation_read_state_service.go new file mode 100644 index 0000000..cd7bd7f --- /dev/null +++ b/internal/services/conversation_read_state_service.go @@ -0,0 +1,275 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/repositories" + "strings" + "time" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/common/strs" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var ConversationReadStateService = newConversationReadStateService() + +func newConversationReadStateService() *conversationReadStateService { + return &conversationReadStateService{} +} + +type conversationReadStateService struct { +} + +// readerCursor 已读游标行的身份键(包内私有,供客服 / 客户两条路径共用)。 +type readerCursor struct { + readerType enums.IMSenderType + readerID int64 + externalReaderID string + auditUserID int64 + auditUserName string +} + +func agentReaderCursor(operator *dto.AuthPrincipal) (readerCursor, error) { + if operator == nil { + return readerCursor{}, errorsx.UnauthorizedI18n("error.auth.expired") + } + return readerCursor{ + readerType: enums.IMSenderTypeAgent, + readerID: operator.UserID, + externalReaderID: "", + auditUserID: operator.UserID, + auditUserName: operator.Username, + }, nil +} + +func customerReaderCursor(external *openidentity.ExternalUser) (readerCursor, error) { + if external == nil || strings.TrimSpace(external.ExternalID) == "" { + return readerCursor{}, errorsx.UnauthorizedI18n("error.e0149") + } + extID := strings.TrimSpace(external.ExternalID) + name := strings.TrimSpace(external.ExternalName) + if name == "" { + name = extID + } + return readerCursor{ + readerType: enums.IMSenderTypeCustomer, + readerID: 0, + externalReaderID: extID, + auditUserID: 0, + auditUserName: name, + }, nil +} + +func (s *conversationReadStateService) Get(id int64) *models.ConversationReadState { + return repositories.ConversationReadStateRepository.Get(sqls.DB(), id) +} + +func (s *conversationReadStateService) Take(where ...any) *models.ConversationReadState { + return repositories.ConversationReadStateRepository.Take(sqls.DB(), where...) +} + +func (s *conversationReadStateService) Find(cnd *sqls.Cnd) []models.ConversationReadState { + return repositories.ConversationReadStateRepository.Find(sqls.DB(), cnd) +} + +func (s *conversationReadStateService) FindOne(cnd *sqls.Cnd) *models.ConversationReadState { + return repositories.ConversationReadStateRepository.FindOne(sqls.DB(), cnd) +} + +func (s *conversationReadStateService) FindPageByParams(queryParams *params.QueryParams) (list []models.ConversationReadState, paging *sqls.Paging) { + return repositories.ConversationReadStateRepository.FindPageByParams(sqls.DB(), queryParams) +} + +func (s *conversationReadStateService) FindPageByCnd(cnd *sqls.Cnd) (list []models.ConversationReadState, paging *sqls.Paging) { + return repositories.ConversationReadStateRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *conversationReadStateService) Count(cnd *sqls.Cnd) int64 { + return repositories.ConversationReadStateRepository.Count(sqls.DB(), cnd) +} + +func (s *conversationReadStateService) Create(item *models.ConversationReadState) error { + return repositories.ConversationReadStateRepository.Create(sqls.DB(), item) +} + +func (s *conversationReadStateService) Update(item *models.ConversationReadState) error { + return repositories.ConversationReadStateRepository.Update(sqls.DB(), item) +} + +func (s *conversationReadStateService) Updates(id int64, columns map[string]any) error { + return repositories.ConversationReadStateRepository.Updates(sqls.DB(), id, columns) +} + +func (s *conversationReadStateService) UpdateColumn(id int64, name string, value any) error { + return repositories.ConversationReadStateRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *conversationReadStateService) Delete(id int64) { + repositories.ConversationReadStateRepository.Delete(sqls.DB(), id) +} + +// GetByAgentReader 查询客服侧已读游标。 +func (s *conversationReadStateService) GetByAgentReader(conversationID int64, operator *dto.AuthPrincipal) *models.ConversationReadState { + if operator == nil { + return nil + } + return s.getByCursor(conversationID, readerCursor{ + readerType: enums.IMSenderTypeAgent, + readerID: operator.UserID, + externalReaderID: "", + }) +} + +// GetByCustomerReader 查询 IM 客户侧已读游标(按 ExternalID)。 +func (s *conversationReadStateService) GetByCustomerReader(conversationID int64, external *openidentity.ExternalUser) *models.ConversationReadState { + if external == nil || strings.TrimSpace(external.ExternalID) == "" { + return nil + } + return s.getByCursor(conversationID, readerCursor{ + readerType: enums.IMSenderTypeCustomer, + readerID: 0, + externalReaderID: strings.TrimSpace(external.ExternalID), + }) +} + +func (s *conversationReadStateService) getByCursor(conversationID int64, c readerCursor) *models.ConversationReadState { + return s.FindOne(sqls.NewCnd(). + Eq("conversation_id", conversationID). + Eq("reader_type", c.readerType). + Eq("reader_id", c.readerID). + Eq("external_reader_id", c.externalReaderID)) +} + +func (s *conversationReadStateService) GetConversationReadStates(conversationID int64) (agentState, customerState *models.ConversationReadState) { + return s.getConversationReadStates(sqls.DB(), conversationID) +} + +func (s *conversationReadStateService) getConversationReadStates(db *gorm.DB, conversationID int64) (agentState, customerState *models.ConversationReadState) { + list := repositories.ConversationReadStateRepository.Find(db, sqls.NewCnd().Eq("conversation_id", conversationID)) + return s.pickConversationReadStates(list) +} + +func (s *conversationReadStateService) pickConversationReadStates(list []models.ConversationReadState) (agentState, customerState *models.ConversationReadState) { + for i := range list { + item := &list[i] + switch item.ReaderType { + case enums.IMSenderTypeAgent: + if agentState == nil || item.LastReadMessageID > agentState.LastReadMessageID { + agentState = item + } + case enums.IMSenderTypeCustomer: + if customerState == nil || item.LastReadMessageID > customerState.LastReadMessageID { + customerState = item + } + } + } + return agentState, customerState +} + +// MarkAgentRead 在事务内更新/创建客服已读游标。 +func (s *conversationReadStateService) MarkAgentRead(ctx *sqls.TxContext, conversation *models.Conversation, operator *dto.AuthPrincipal, message *models.Message) (*models.ConversationReadState, error) { + c, err := agentReaderCursor(operator) + if err != nil { + return nil, err + } + return s.markReadTxWithCursor(ctx, conversation, c, message) +} + +// MarkCustomerRead 在事务内更新/创建 IM 客户已读游标。 +func (s *conversationReadStateService) MarkCustomerRead(ctx *sqls.TxContext, conversation *models.Conversation, external *openidentity.ExternalUser, message *models.Message) (*models.ConversationReadState, error) { + c, err := customerReaderCursor(external) + if err != nil { + return nil, err + } + return s.markReadTxWithCursor(ctx, conversation, c, message) +} + +func (s *conversationReadStateService) markReadTxWithCursor(ctx *sqls.TxContext, conversation *models.Conversation, c readerCursor, message *models.Message) (*models.ConversationReadState, error) { + if ctx == nil || conversation == nil || message == nil { + return nil, nil + } + if c.readerType != enums.IMSenderTypeAgent && c.readerType != enums.IMSenderTypeCustomer { + return nil, errorsx.InvalidParamI18n("error.e0081") + } + + now := time.Now() + + item := &models.ConversationReadState{} + err := ctx.Tx.Where("conversation_id = ? AND reader_type = ? AND reader_id = ? AND external_reader_id = ?", + conversation.ID, c.readerType, c.readerID, c.externalReaderID, + ).First(item).Error + if err != nil { + if err != gorm.ErrRecordNotFound { + return nil, err + } + item = &models.ConversationReadState{ + ConversationID: conversation.ID, + ReaderType: c.readerType, + ReaderID: c.readerID, + ExternalReaderID: c.externalReaderID, + LastReadMessageID: message.ID, + LastReadAt: &now, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: c.auditUserID, + CreateUserName: c.auditUserName, + UpdatedAt: now, + UpdateUserID: c.auditUserID, + UpdateUserName: c.auditUserName, + }, + } + if err := ctx.Tx.Create(item).Error; err != nil { + return nil, err + } + return item, nil + } + + if item.LastReadMessageID >= message.ID { + return item, nil + } + + item.LastReadMessageID = message.ID + item.LastReadAt = &now + item.UpdatedAt = now + item.UpdateUserID = c.auditUserID + item.UpdateUserName = c.auditUserName + if err := repositories.ConversationReadStateRepository.Updates(ctx.Tx, item.ID, map[string]any{ + "last_read_message_id": item.LastReadMessageID, + "last_read_at": item.LastReadAt, + "updated_at": item.UpdatedAt, + "update_user_id": item.UpdateUserID, + "update_user_name": item.UpdateUserName, + }); err != nil { + return nil, err + } + return item, nil +} + +func (s *conversationReadStateService) CountUnreadMessages(ctx *sqls.TxContext, conversationID, lastReadMessageID int64, senderTypes ...enums.IMSenderType) (int64, error) { + normalizedSenderTypes := make([]enums.IMSenderType, 0, len(senderTypes)) + for _, senderType := range senderTypes { + if strs.IsBlank(string(senderType)) { + continue + } + normalizedSenderTypes = append(normalizedSenderTypes, senderType) + } + if len(normalizedSenderTypes) == 0 { + return 0, nil + } + var count int64 + query := ctx.Tx.Model(&models.Message{}). + Where("conversation_id = ? AND id > ? AND recalled_at IS NULL AND send_status <> ?", conversationID, lastReadMessageID, int(enums.IMMessageStatusRecalled)) + if len(normalizedSenderTypes) == 1 { + query = query.Where("sender_type = ?", normalizedSenderTypes[0]) + } else { + query = query.Where("sender_type IN ?", normalizedSenderTypes) + } + err := query.Count(&count).Error + return count, err +} diff --git a/internal/services/conversation_satisfaction_service.go b/internal/services/conversation_satisfaction_service.go new file mode 100644 index 0000000..6314941 --- /dev/null +++ b/internal/services/conversation_satisfaction_service.go @@ -0,0 +1,85 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/repositories" + "strings" + + "github.com/mlogclub/simple/sqls" +) + +var ConversationSatisfactionService = newConversationSatisfactionService() + +func newConversationSatisfactionService() *conversationSatisfactionService { + return &conversationSatisfactionService{} +} + +type conversationSatisfactionService struct{} + +func (s *conversationSatisfactionService) SubmitSatisfaction(conversationID int64, externalUser openidentity.ExternalUser, score int, feedback string) (*models.ConversationSatisfaction, error) { + if score < 1 || score > 5 { + return nil, errorsx.InvalidParamI18n("error.invalidParam") + } + + conversation := ConversationService.Get(conversationID) + if conversation == nil { + return nil, errorsx.InvalidParamI18n("error.e0116") + } + if !ConversationService.IsCustomerConversationOwner(conversation, externalUser) { + return nil, errorsx.ForbiddenI18n("error.e0222") + } + + trimmedFeedback := strings.TrimSpace(feedback) + operatorName := strings.TrimSpace(externalUser.ExternalName) + if operatorName == "" { + operatorName = strings.TrimSpace(externalUser.ExternalID) + } + + var result *models.ConversationSatisfaction + + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + existing := repositories.ConversationSatisfactionRepository.GetByConversationID(ctx.Tx, conversationID) + if existing != nil { + existing.Score = score + existing.Feedback = trimmedFeedback + existing.UpdateUserName = operatorName + if err := repositories.ConversationSatisfactionRepository.Update(ctx.Tx, existing); err != nil { + return err + } + result = existing + return nil + } + + item := &models.ConversationSatisfaction{ + ConversationID: conversation.ID, + Score: score, + Feedback: trimmedFeedback, + ChannelID: conversation.ChannelID, + CustomerID: conversation.CustomerID, + AssigneeID: conversation.CurrentAssigneeID, + AIAgentID: conversation.AIAgentID, + AuditFields: models.AuditFields{ + CreateUserName: operatorName, + UpdateUserName: operatorName, + }, + } + if err := repositories.ConversationSatisfactionRepository.Create(ctx.Tx, item); err != nil { + return err + } + result = item + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func (s *conversationSatisfactionService) GetByConversationID(conversationID int64) *models.ConversationSatisfaction { + if conversationID <= 0 { + return nil + } + return repositories.ConversationSatisfactionRepository.GetByConversationID(sqls.DB(), conversationID) +} diff --git a/internal/services/conversation_satisfaction_service_test.go b/internal/services/conversation_satisfaction_service_test.go new file mode 100644 index 0000000..e660f36 --- /dev/null +++ b/internal/services/conversation_satisfaction_service_test.go @@ -0,0 +1,150 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/repositories" + "testing" + + "github.com/mlogclub/simple/sqls" +) + +func setupSatisfactionTest(t *testing.T) (*conversationService, *conversationSatisfactionService, openidentity.ExternalUser, int64) { + t.Helper() + convSvc, external, aiAgentID := setupCustomerConversationTest(t, 5) + if err := sqls.DB().AutoMigrate(&models.ConversationSatisfaction{}); err != nil { + t.Fatalf("auto migrate conversation satisfaction error = %v", err) + } + return convSvc, ConversationSatisfactionService, external, aiAgentID +} + +func TestSubmitSatisfaction(t *testing.T) { + convSvc, csatSvc, external, aiAgentID := setupSatisfactionTest(t) + + // Create conversation and close it + conv, err := convSvc.CreateNew(external, 1, aiAgentID, "Need help with refund") + if err != nil { + t.Fatalf("CreateNew() error = %v", err) + } + if err := convSvc.CloseCustomerConversation(conv.ID, external); err != nil { + t.Fatalf("CloseCustomerConversation() error = %v", err) + } + + t.Run("rejects invalid score", func(t *testing.T) { + invalidScores := []int{0, -1, 6, 10} + for _, score := range invalidScores { + _, err := csatSvc.SubmitSatisfaction(conv.ID, external, score, "Bad score") + if err == nil { + t.Fatalf("SubmitSatisfaction() with score %d expected error, got nil", score) + } + } + }) + + t.Run("rejects if external user is not conversation owner", func(t *testing.T) { + unauthorizedUser := openidentity.ExternalUser{ + ExternalSource: "user", + ExternalID: "other_user", + ExternalName: "Attacker", + } + _, err := csatSvc.SubmitSatisfaction(conv.ID, unauthorizedUser, 5, "Nice") + if err == nil { + t.Fatal("SubmitSatisfaction() with unauthorized user expected forbidden error, got nil") + } + }) + + t.Run("creates record when valid and closed", func(t *testing.T) { + res, err := csatSvc.SubmitSatisfaction(conv.ID, external, 5, "Very helpful!") + if err != nil { + t.Fatalf("SubmitSatisfaction() error = %v", err) + } + if res == nil { + t.Fatal("expected satisfaction record, got nil") + } + if res.ConversationID != conv.ID { + t.Fatalf("ConversationID = %d, want %d", res.ConversationID, conv.ID) + } + if res.Score != 5 { + t.Fatalf("Score = %d, want 5", res.Score) + } + if res.Feedback != "Very helpful!" { + t.Fatalf("Feedback = %q, want %q", res.Feedback, "Very helpful!") + } + if res.CustomerID != conv.CustomerID { + t.Fatalf("CustomerID = %d, want %d", res.CustomerID, conv.CustomerID) + } + if res.ChannelID != conv.ChannelID { + t.Fatalf("ChannelID = %d, want %d", res.ChannelID, conv.ChannelID) + } + + // Verify retrieval + fetched := csatSvc.GetByConversationID(conv.ID) + if fetched == nil { + t.Fatal("GetByConversationID() returned nil") + } + if fetched.ID != res.ID || fetched.Score != 5 { + t.Fatalf("fetched record mismatch: %+v", fetched) + } + }) + + t.Run("returns existing record if already rated (idempotent)", func(t *testing.T) { + initial := csatSvc.GetByConversationID(conv.ID) + if initial == nil { + t.Fatal("initial satisfaction record not found") + } + + updated, err := csatSvc.SubmitSatisfaction(conv.ID, external, 4, "Updated: Good overall") + if err != nil { + t.Fatalf("SubmitSatisfaction() update error = %v", err) + } + if updated.ID != initial.ID { + t.Fatalf("expected updated record ID to be %d, got %d", initial.ID, updated.ID) + } + if updated.Score != 4 { + t.Fatalf("Score = %d, want 4", updated.Score) + } + if updated.Feedback != "Updated: Good overall" { + t.Fatalf("Feedback = %q, want %q", updated.Feedback, "Updated: Good overall") + } + + // Count should still be 1 for this conversation + count := repositories.ConversationSatisfactionRepository.Count(sqls.DB(), "conversation_id = ?", conv.ID) + if count != 1 { + t.Fatalf("expected count = 1, got %d", count) + } + + // Average score should be 4.0 + avg := repositories.ConversationSatisfactionRepository.AverageScore(sqls.DB(), "conversation_id = ?", conv.ID) + if avg != 4.0 { + t.Fatalf("expected avg = 4.0, got %f", avg) + } + }) + + t.Run("returns error when conversation does not exist", func(t *testing.T) { + _, err := csatSvc.SubmitSatisfaction(999999, external, 5, "Non-existent") + if err == nil { + t.Fatal("expected error for non-existent conversation, got nil") + } + }) + + t.Run("returns nil for non-positive conversation ID in GetByConversationID", func(t *testing.T) { + if got := csatSvc.GetByConversationID(0); got != nil { + t.Fatalf("expected nil for conversationID 0, got %+v", got) + } + if got := csatSvc.GetByConversationID(-1); got != nil { + t.Fatalf("expected nil for conversationID -1, got %+v", got) + } + if got := repositories.ConversationSatisfactionRepository.Get(sqls.DB(), 0); got != nil { + t.Fatalf("expected nil for ID 0, got %+v", got) + } + if got := repositories.ConversationSatisfactionRepository.GetByConversationID(sqls.DB(), 0); got != nil { + t.Fatalf("expected nil for conversationID 0, got %+v", got) + } + }) + + t.Run("returns zero average when no records match", func(t *testing.T) { + avg := repositories.ConversationSatisfactionRepository.AverageScore(sqls.DB(), "conversation_id = ?", 888888) + if avg != 0 { + t.Fatalf("expected 0 average for unmatched condition, got %f", avg) + } + }) +} diff --git a/internal/services/conversation_service.go b/internal/services/conversation_service.go new file mode 100644 index 0000000..c0a28b8 --- /dev/null +++ b/internal/services/conversation_service.go @@ -0,0 +1,1006 @@ +package services + +import ( + "context" + "encoding/json" + "log/slog" + + "agent-desk/internal/events" + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/eventbus" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + "slices" + "strings" + "time" + + "github.com/mlogclub/simple/common/strs" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var ConversationService = newConversationService() + +// conversationSubjectMaxLength matches the Subject column width, so a +// visitor-typed title is truncated before the write rather than being rejected +// by the database. +const conversationSubjectMaxLength = 255 + +func newConversationService() *conversationService { + return &conversationService{} +} + +type conversationService struct { +} + +func (s *conversationService) Get(id int64) *models.Conversation { + if id <= 0 { + return nil + } + return repositories.ConversationRepository.Get(sqls.DB(), id) +} + +func (s *conversationService) Find(cnd *sqls.Cnd) []models.Conversation { + return repositories.ConversationRepository.Find(sqls.DB(), cnd) +} + +func (s *conversationService) FindOne(cnd *sqls.Cnd) *models.Conversation { + return repositories.ConversationRepository.FindOne(sqls.DB(), cnd) +} + +func (s *conversationService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Conversation, paging *sqls.Paging) { + return repositories.ConversationRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *conversationService) ListConversations(userID int64, filter request.AgentConversationFilter, keyword string, paging *sqls.Paging) ([]models.Conversation, *sqls.Paging, error) { + cnd := sqls.NewCnd().Page(paging.Page, paging.Limit) + + if strs.IsNotBlank(keyword) { + keyword = strings.TrimSpace(keyword) + keywordLike := "%" + keyword + "%" + cnd.Where("customer_name LIKE ? OR last_message_summary LIKE ?", keywordLike, keywordLike) + } + + switch filter { + case request.AgentConversationFilterAIServing: + cnd.Eq("current_assignee_id", 0).Eq("status", enums.IMConversationStatusAIServing).Desc("last_active_at").Desc("id") + case request.AgentConversationFilterMine: + cnd.Eq("current_assignee_id", userID).Desc("last_active_at").Desc("id") + case request.AgentConversationFilterActive: + cnd.Eq("current_assignee_id", userID).Eq("status", enums.IMConversationStatusActive).Desc("last_active_at").Desc("id") + case request.AgentConversationFilterPending: + cnd.Eq("current_assignee_id", 0).Eq("status", enums.IMConversationStatusPending).Asc("last_active_at").Desc("id") + case request.AgentConversationFilterClosed: + cnd.Eq("current_assignee_id", userID).Eq("status", enums.IMConversationStatusClosed).Desc("last_active_at").Desc("id") + default: + return nil, nil, errorsx.InvalidParamI18n("error.e0121") + } + + list, paging := repositories.ConversationRepository.FindPageByCnd(sqls.DB(), cnd) + return list, paging, nil +} + +func (s *conversationService) Updates(id int64, columns map[string]interface{}) error { + return repositories.ConversationRepository.Updates(sqls.DB(), id, columns) +} + +func (s *conversationService) resolveAIAgent(aiAgentID int64) (*models.AIAgent, error) { + aiAgent := AIAgentService.Get(aiAgentID) + if aiAgent == nil || aiAgent.Status != enums.StatusOk { + return nil, errorsx.InvalidParamI18n("error.e0002") + } + return aiAgent, nil +} + +// insertConversation writes the conversation row, its participant and the +// creation event, and hands back the AI welcome message so the caller can +// publish it only after the transaction has committed. +func (s *conversationService) insertConversation( + ctx *sqls.TxContext, + externalUser openidentity.ExternalUser, + aiAgent *models.AIAgent, + channelID, customerID int64, + customerName, subject string, +) (*models.Conversation, *models.Message, error) { + now := time.Now() + conversation := &models.Conversation{ + AIAgentID: aiAgent.ID, + ChannelID: channelID, + CustomerID: customerID, + CustomerName: customerName, + Subject: subject, + Status: s.resolveInitialStatus(aiAgent.ServiceMode), + ServiceMode: aiAgent.ServiceMode, + Priority: 0, + CurrentAssigneeID: 0, + CurrentTeamID: 0, + LastMessageAt: now, + LastActiveAt: now, + AuditFields: utils.BuildAuditFields(nil), + } + if err := ctx.Tx.Create(conversation).Error; err != nil { + return nil, nil, err + } + if err := ConversationParticipantService.CreateCustomerParticipant(ctx, conversation.ID, externalUser); err != nil { + return nil, nil, err + } + if err := ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeCreate, enums.IMSenderTypeCustomer, 0, "用户创建会话", ""); err != nil { + return nil, nil, err + } + welcomeMessage, err := MessageService.createAIWelcomeMessage(ctx, conversation, aiAgent, now) + if err != nil { + return nil, nil, err + } + return conversation, welcomeMessage, nil +} + +// afterConversationCreated runs only once the creating transaction has +// committed: realtime fan-out and human dispatch must not observe - or act on - +// rows that a rollback would discard. +func (s *conversationService) afterConversationCreated( + conversation *models.Conversation, + welcomeMessage *models.Message, + aiAgent *models.AIAgent, +) (*models.Conversation, error) { + WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationCreated) + if welcomeMessage != nil { + if updatedConversation := s.Get(conversation.ID); updatedConversation != nil { + WsService.PublishMessageCreated(updatedConversation, welcomeMessage) + WsService.PublishConversationChanged(updatedConversation, enums.IMRealtimeEventConversationUpdated) + } + } + + if aiAgent.ServiceMode == enums.IMConversationServiceModeHumanOnly { + if _, err := ConversationHumanDispatchService.ApplyHumanOnlyCreate(conversation.ID, *aiAgent); err != nil { + return nil, err + } + } + return s.Get(conversation.ID), nil +} + +func (s *conversationService) countNotFinishedByCustomerID(db *gorm.DB, customerID int64) int64 { + if customerID <= 0 { + return 0 + } + return repositories.ConversationRepository.Count(db, s.notFinishedCnd(customerID, 0)) +} + +// notFinishedCnd is the single definition of "still open" so the resume path, +// the cap and any future query cannot drift apart. +// +// channelID scopes the resume to one channel, and it has to. Resuming purely by +// customer meant a visitor with an open web chat who then emailed support landed +// in their web thread, so their mail was answered in a medium they had left, and +// the widget could rejoin a thread the customer can no longer see the replies +// to. Listing stays customer-scoped on purpose, so the request switcher still +// shows the whole history; only resume is per channel. A zero channelID means +// "any channel", which is what the cap wants to count across. +func (s *conversationService) notFinishedCnd(customerID, channelID int64) *sqls.Cnd { + cnd := sqls.NewCnd().Eq("customer_id", customerID) + if channelID > 0 { + cnd.Eq("channel_id", channelID) + } + cnd.In("status", []enums.IMConversationStatus{ + enums.IMConversationStatusAIServing, + enums.IMConversationStatusPending, + enums.IMConversationStatusActive, + }) + return cnd +} + +func (s *conversationService) getLatestNotFinishedByCustomerID(db *gorm.DB, customerID, channelID int64) *models.Conversation { + if customerID <= 0 { + return nil + } + cnd := s.notFinishedCnd(customerID, channelID) + cnd.Desc("id") + return repositories.ConversationRepository.FindOne(db, cnd) +} + +// checkCustomerOpenLimit guards the queue rather than the visitor. Each open +// conversation is real agent work, and in human-only mode creating one dispatches +// an agent immediately, so without a cap one caller could flood the queue. A +// visitor cannot undo that by closing a conversation, since they may legitimately +// need one to stay open. +func (s *conversationService) checkCustomerOpenLimit(db *gorm.DB, customerID int64) error { + maxOpen := config.Current().Conversation.MaxOpen() + if maxOpen <= 0 || customerID <= 0 { + return nil + } + open := s.countNotFinishedByCustomerID(db, customerID) + if open < int64(maxOpen) { + return nil + } + return errorsx.BusinessErrorI18n(0, "error.conversation.tooManyOpen", maxOpen) +} + +// normalizeConversationSubject keeps a visitor-typed line usable as a list title. +// It is capped to the column width on rune boundaries so a long or multi-byte +// value cannot overflow the varchar on write. +func normalizeConversationSubject(subject string) string { + subject = strings.Join(strings.Fields(subject), " ") + subject = strings.TrimSpace(subject) + if subject == "" { + return "" + } + runes := []rune(subject) + if len(runes) > conversationSubjectMaxLength { + return strings.TrimSpace(string(runes[:conversationSubjectMaxLength])) + } + return subject +} + +// Create resumes the visitor's most recent unfinished conversation, starting one +// only when there is none. The widget depends on this so that reloading the page +// rejoins the live thread instead of orphaning it. +func (s *conversationService) Create(externalUser openidentity.ExternalUser, channelID, aiAgentID int64) (*models.Conversation, error) { + aiAgent, err := s.resolveAIAgent(aiAgentID) + if err != nil { + return nil, err + } + + var conversation *models.Conversation + var welcomeMessage *models.Message + created := false + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + customerID, err := CustomerService.EnsureExternalCustomer(ctx, externalUser) + if err != nil { + return err + } + customerName := s.getCustomerName(ctx.Tx, customerID) + if existing := s.getLatestNotFinishedByCustomerID(ctx.Tx, customerID, channelID); existing != nil { + conversation = existing + if customerName != "" && existing.CustomerName != customerName { + if err := repositories.ConversationRepository.Updates(ctx.Tx, existing.ID, map[string]any{ + "customer_name": customerName, + "updated_at": time.Now(), + }); err != nil { + return err + } + conversation.CustomerName = customerName + } + return nil + } + created = true + conversation, welcomeMessage, err = s.insertConversation(ctx, externalUser, aiAgent, channelID, customerID, customerName, "") + return err + }); err != nil { + return nil, err + } + if conversation == nil { + return nil, errorsx.BusinessErrorI18n(1, "error.conversation.createFailed") + } + if !created { + return conversation, nil + } + return s.afterConversationCreated(conversation, welcomeMessage, aiAgent) +} + +// CreateNew always starts a fresh conversation. It backs the visitor's "new +// request" action, so unlike Create it must never resume an unfinished one. +func (s *conversationService) CreateNew(externalUser openidentity.ExternalUser, channelID, aiAgentID int64, subject string) (*models.Conversation, error) { + aiAgent, err := s.resolveAIAgent(aiAgentID) + if err != nil { + return nil, err + } + + var conversation *models.Conversation + var welcomeMessage *models.Message + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + customerID, err := CustomerService.EnsureExternalCustomer(ctx, externalUser) + if err != nil { + return err + } + if err := s.checkCustomerOpenLimit(ctx.Tx, customerID); err != nil { + return err + } + customerName := s.getCustomerName(ctx.Tx, customerID) + conversation, welcomeMessage, err = s.insertConversation(ctx, externalUser, aiAgent, channelID, customerID, customerName, normalizeConversationSubject(subject)) + return err + }); err != nil { + return nil, err + } + if conversation == nil { + return nil, errorsx.BusinessErrorI18n(1, "error.conversation.createFailed") + } + return s.afterConversationCreated(conversation, welcomeMessage, aiAgent) +} + +// ListCustomerConversations returns the visitor's own conversations, newest +// activity first, so the widget can offer a request list. A visitor who has not +// talked to us yet has no identity row and therefore an empty list, not an error. +// +// Scoping is by customer, not by channel: a CustomerIdentity is keyed by +// (external_source, external_id) globally, so one signed visitor reaching us +// through two channels is one person with one request history. For a guest the +// external id is issued per channel, so their identities cannot collide across +// channels anyway. Narrowing this to the current channel would silently hide the +// visitor's earlier requests. +func (s *conversationService) ListCustomerConversations(externalUser openidentity.ExternalUser, cnd *sqls.Cnd) ([]models.Conversation, *sqls.Paging, error) { + if cnd == nil { + cnd = sqls.NewCnd() + } + identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), externalUser.ExternalSource, strings.TrimSpace(externalUser.ExternalID)) + if identity == nil { + paging := &sqls.Paging{} + if cnd.Paging != nil { + *paging = *cnd.Paging + } + return []models.Conversation{}, paging, nil + } + cnd.Eq("customer_id", identity.CustomerID).Desc("last_active_at").Desc("id") + list, paging := repositories.ConversationRepository.FindPageByCnd(sqls.DB(), cnd) + if list == nil { + list = []models.Conversation{} + } + return list, paging, nil +} + +func (s *conversationService) AssignConversation(req request.AssignConversationRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + targetProfile := AgentProfileService.GetByUserID(req.AssigneeID) + if targetProfile == nil || targetProfile.Status != enums.StatusOk { + return errorsx.InvalidParamI18n("error.e0276") + } + var assignedEvent events.ConversationAssignedEvent + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + conversation := repositories.ConversationRepository.Get(ctx.Tx, req.ConversationID) + if conversation == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + if conversation.Status != enums.IMConversationStatusPending { + return errorsx.InvalidParamI18n("error.e0135") + } + now := time.Now() + if err := ConversationAssignmentService.FinishActiveAssignments(ctx, req.ConversationID, now); err != nil { + return err + } + if err := ConversationAssignmentService.CreateAssignment(ctx, req.ConversationID, conversation.CurrentAssigneeID, req.AssigneeID, enums.IMAssignmentTypeAssign, req.Reason, operator, now); err != nil { + return err + } + if err := repositories.ConversationRepository.Updates(ctx.Tx, req.ConversationID, map[string]any{ + "current_assignee_id": req.AssigneeID, + "status": enums.IMConversationStatusActive, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + if err := ConversationEventLogService.CreateEvent(ctx, req.ConversationID, enums.IMEventTypeAssign, enums.IMSenderTypeAgent, operator.UserID, "会话已分配", s.buildEventPayload(map[string]any{ + "fromStatus": conversation.Status, + "toStatus": enums.IMConversationStatusActive, + "fromAssigneeId": conversation.CurrentAssigneeID, + "toAssigneeId": req.AssigneeID, + "reason": strings.TrimSpace(req.Reason), + })); err != nil { + return err + } + assignedEvent = events.ConversationAssignedEvent{ + ConversationID: req.ConversationID, + FromUserID: conversation.CurrentAssigneeID, + ToUserID: req.AssigneeID, + OperatorID: operator.UserID, + Reason: strings.TrimSpace(req.Reason), + AssignType: events.ConversationAssignTypeAssign, + } + return nil + }); err != nil { + return err + } + if conversation := s.Get(req.ConversationID); conversation != nil { + WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationAssigned) + } + eventbus.PublishAsync(context.Background(), assignedEvent) + return nil +} + +func (s *conversationService) AutoAssignConversation(conversationID int64, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + + conversation := s.Get(conversationID) + if conversation == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + if conversation.Status != enums.IMConversationStatusPending { + return errorsx.InvalidParamI18n("error.e0136") + } + if conversation.CurrentAssigneeID > 0 { + return errorsx.InvalidParamI18n("error.e0190") + } + + aiAgent := AIAgentService.Get(conversation.AIAgentID) + if aiAgent == nil || aiAgent.Status != enums.StatusOk { + return errorsx.InvalidParamI18n("error.e0003") + } + result, err := ConversationHumanDispatchService.DispatchPendingConversation(conversationID, *aiAgent) + if err != nil { + return err + } + if result == nil || result.Decision == HandoffDecisionOffHours { + return errorsx.InvalidParamI18n("error.e0194") + } + return nil +} + +func (s *conversationService) TransferConversation(conversationID, toUserID int64, reason string, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + if toUserID <= 0 { + return errorsx.InvalidParamI18n("error.e0278") + } + targetProfile := AgentProfileService.GetByUserID(toUserID) + if targetProfile == nil || targetProfile.Status != enums.StatusOk { + return errorsx.InvalidParamI18n("error.e0276") + } + var assignedEvent events.ConversationAssignedEvent + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID) + if conversation == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + if !s.canTransferConversation(conversation, operator) { + return errorsx.ForbiddenI18n("error.e0223") + } + if conversation.Status != enums.IMConversationStatusActive { + return errorsx.InvalidParamI18n("error.e0134") + } + if conversation.CurrentAssigneeID <= 0 { + return errorsx.InvalidParamI18n("error.e0193") + } + if conversation.CurrentAssigneeID == toUserID { + return errorsx.InvalidParamI18n("error.e0277") + } + now := time.Now() + if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil { + return err + } + if err := ConversationAssignmentService.CreateAssignment(ctx, conversationID, conversation.CurrentAssigneeID, toUserID, enums.IMAssignmentTypeTransfer, reason, operator, now); err != nil { + return err + } + if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{ + "current_assignee_id": toUserID, + "status": enums.IMConversationStatusActive, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + if err := ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAgent, operator.UserID, "会话已转接", s.buildEventPayload(map[string]any{ + "fromStatus": conversation.Status, + "toStatus": enums.IMConversationStatusActive, + "fromAssigneeId": conversation.CurrentAssigneeID, + "toAssigneeId": toUserID, + "reason": strings.TrimSpace(reason), + })); err != nil { + return err + } + assignedEvent = events.ConversationAssignedEvent{ + ConversationID: conversationID, + FromUserID: conversation.CurrentAssigneeID, + ToUserID: toUserID, + OperatorID: operator.UserID, + Reason: strings.TrimSpace(reason), + AssignType: events.ConversationAssignTypeTransfer, + } + return nil + }); err != nil { + return err + } + if conversation := s.Get(conversationID); conversation != nil { + WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationTransferred) + } + eventbus.PublishAsync(context.Background(), assignedEvent) + return nil +} + +func (s *conversationService) HandoffByAI(conversationID int64, aiAgent models.AIAgent, reason string) error { + return s.HandoffByAIWithRequestID(conversationID, aiAgent, reason, "") +} + +func (s *conversationService) HandoffByAIWithRequestID(conversationID int64, aiAgent models.AIAgent, reason string, requestID string) error { + if conversationID <= 0 { + return errorsx.InvalidParamI18n("error.e0116") + } + _, err := ConversationHumanDispatchService.HandoffByAIWithRequestID(conversationID, aiAgent, reason, requestID) + if err != nil { + slog.Warn("schedule-aware ai handoff failed", + "requestId", requestID, + "conversation_id", conversationID, + "ai_agent_id", aiAgent.ID, + "error", err) + } + return err +} + +func (s *conversationService) TryOffHoursHandoffByAI(conversationID int64, aiAgent models.AIAgent, reason string) (bool, error) { + return s.TryOffHoursHandoffByAIWithRequestID(conversationID, aiAgent, reason, "") +} + +func (s *conversationService) TryOffHoursHandoffByAIWithRequestID(conversationID int64, aiAgent models.AIAgent, reason string, requestID string) (bool, error) { + if conversationID <= 0 { + return false, errorsx.InvalidParamI18n("error.e0116") + } + handled, err := ConversationHumanDispatchService.TryOffHoursHandoffByAIWithRequestID(conversationID, aiAgent, reason, requestID) + if err != nil { + slog.Warn("off-hours ai handoff failed", + "requestId", requestID, + "conversation_id", conversationID, + "ai_agent_id", aiAgent.ID, + "error", err) + } + return handled, err +} + +func (s *conversationService) CloseConversation(conversationID int64, closeReason string, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + return s.closeConversation(conversationID, enums.IMSenderTypeAgent, closeReason, operator) +} + +func (s *conversationService) CloseCustomerConversation(conversationID int64, externalUser openidentity.ExternalUser) error { + conversation := s.Get(conversationID) + if conversation == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + if !s.IsCustomerConversationOwner(conversation, externalUser) { + return errorsx.ForbiddenI18n("error.e0222") + } + return s.closeConversation(conversationID, enums.IMSenderTypeCustomer, "", nil) +} + +func (s *conversationService) closeConversation(conversationID int64, senderType enums.IMSenderType, closeReason string, operator *dto.AuthPrincipal) error { + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID) + if conversation == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + if conversation.Status == enums.IMConversationStatusClosed { + return nil + } + if conversation.Status != enums.IMConversationStatusAIServing && + conversation.Status != enums.IMConversationStatusPending && + conversation.Status != enums.IMConversationStatusActive { + return errorsx.InvalidParamI18n("error.e0197") + } + var ( + now = time.Now() + eventDesc = "会话已关闭" + operatorID int64 + operatorName string + ) + closeReason = strings.TrimSpace(closeReason) + if senderType == enums.IMSenderTypeCustomer { + eventDesc = "客户关闭会话" + } else { + if operator == nil { + return errorsx.InvalidParamI18n("error.e0226") + } + if closeReason == "" { + return errorsx.InvalidParamI18n("error.e0128") + } + if !s.canCloseConversation(conversation, operator) { + return errorsx.ForbiddenI18n("error.e0221") + } + operatorID = operator.UserID + operatorName = operator.Nickname + } + if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil { + return err + } + if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{ + "status": enums.IMConversationStatusClosed, + "closed_at": now, + "closed_by": operatorID, + "close_reason": closeReason, + "update_user_id": operatorID, + "update_user_name": operatorName, + "updated_at": now, + }); err != nil { + return err + } + return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeClose, senderType, operatorID, eventDesc, s.buildEventPayload(map[string]any{ + "fromStatus": conversation.Status, + "toStatus": enums.IMConversationStatusClosed, + "fromAssigneeId": conversation.CurrentAssigneeID, + "toAssigneeId": conversation.CurrentAssigneeID, + "closeReason": closeReason, + })) + }); err != nil { + return err + } + if conversation := s.Get(conversationID); conversation != nil { + WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationClosed) + } + return nil +} + +// MarkAgentConversationReadToMessage 控制台客服将会话已读推进到指定消息。 +func (s *conversationService) MarkAgentConversationReadToMessage(conversationID, messageID int64, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + conversation := s.Get(conversationID) + if conversation == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + changed, err := s.markConversationReadWithActor(conversation, messageID, agentConversationReadActor{operator: operator}) + if err != nil { + return err + } + if changed { + if updated := s.Get(conversationID); updated != nil { + WsService.PublishConversationChanged(updated, enums.IMRealtimeEventConversationRead) + } + } + return nil +} + +// MarkCustomerConversationReadToMessage IM 客户将会话已读推进到指定消息(需为会话归属外部身份)。 +func (s *conversationService) MarkCustomerConversationReadToMessage(conversationID, messageID int64, external *openidentity.ExternalUser) error { + if external == nil || strings.TrimSpace(external.ExternalID) == "" { + return errorsx.UnauthorizedI18n("error.e0149") + } + conversation := s.Get(conversationID) + if conversation == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + if !s.IsCustomerConversationOwner(conversation, *external) { + return errorsx.ForbiddenI18n("error.e0222") + } + changed, err := s.markConversationReadWithActor(conversation, messageID, customerConversationReadActor{external: external}) + if err != nil { + return err + } + if changed { + if updated := s.Get(conversationID); updated != nil { + WsService.PublishConversationChanged(updated, enums.IMRealtimeEventConversationRead) + } + } + return nil +} + +func displayExternalName(ext *openidentity.ExternalUser) string { + if ext == nil { + return "" + } + if n := strings.TrimSpace(ext.ExternalName); n != "" { + return n + } + return strings.TrimSpace(ext.ExternalID) +} + +// conversationReadActor 抽象「读者身份」,供 markConversationReadWithActor 共用(包内私有)。 +type conversationReadActor interface { + isAgentSide() bool + getReadState(conversationID int64) *models.ConversationReadState + markRead(ctx *sqls.TxContext, conversation *models.Conversation, targetMessage *models.Message) error + conversationUpdateAudit() (userID int64, userName string) +} + +type agentConversationReadActor struct { + operator *dto.AuthPrincipal +} + +func (a agentConversationReadActor) isAgentSide() bool { return true } + +func (a agentConversationReadActor) getReadState(conversationID int64) *models.ConversationReadState { + return ConversationReadStateService.GetByAgentReader(conversationID, a.operator) +} + +func (a agentConversationReadActor) markRead(ctx *sqls.TxContext, conversation *models.Conversation, targetMessage *models.Message) error { + _, err := ConversationReadStateService.MarkAgentRead(ctx, conversation, a.operator, targetMessage) + return err +} + +func (a agentConversationReadActor) conversationUpdateAudit() (int64, string) { + if a.operator == nil { + return 0, "" + } + return a.operator.UserID, a.operator.Username +} + +type customerConversationReadActor struct { + external *openidentity.ExternalUser +} + +func (a customerConversationReadActor) isAgentSide() bool { return false } + +func (a customerConversationReadActor) getReadState(conversationID int64) *models.ConversationReadState { + return ConversationReadStateService.GetByCustomerReader(conversationID, a.external) +} + +func (a customerConversationReadActor) markRead(ctx *sqls.TxContext, conversation *models.Conversation, targetMessage *models.Message) error { + _, err := ConversationReadStateService.MarkCustomerRead(ctx, conversation, a.external, targetMessage) + return err +} + +func (a customerConversationReadActor) conversationUpdateAudit() (int64, string) { + return 0, displayExternalName(a.external) +} + +func (s *conversationService) markConversationReadWithActor(conversation *models.Conversation, messageID int64, actor conversationReadActor) (bool, error) { + if conversation == nil { + return false, errorsx.InvalidParamI18n("error.e0116") + } + targetMessage, err := MessageService.GetConversationReadTarget(conversation.ID, messageID) + if err != nil { + return false, err + } + if targetMessage == nil { + if actor.isAgentSide() && conversation.AgentUnreadCount == 0 { + return false, nil + } + if !actor.isAgentSide() && conversation.CustomerUnreadCount == 0 { + return false, nil + } + now := time.Now() + updateUserID, updateUserName := actor.conversationUpdateAudit() + updates := map[string]any{ + "update_user_id": updateUserID, + "update_user_name": updateUserName, + "updated_at": now, + } + if actor.isAgentSide() { + updates["agent_unread_count"] = 0 + } else { + updates["customer_unread_count"] = 0 + } + return true, s.Updates(conversation.ID, updates) + } + + currentReadState := actor.getReadState(conversation.ID) + if currentReadState != nil && currentReadState.LastReadMessageID >= targetMessage.ID { + if actor.isAgentSide() && conversation.AgentUnreadCount == 0 { + return false, nil + } + if !actor.isAgentSide() && conversation.CustomerUnreadCount == 0 { + return false, nil + } + } + + err = sqls.WithTransaction(func(ctx *sqls.TxContext) error { + currentConversation := repositories.ConversationRepository.Get(ctx.Tx, conversation.ID) + if currentConversation == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + if err := actor.markRead(ctx, currentConversation, targetMessage); err != nil { + return err + } + agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, currentConversation.ID) + agentUnreadCount, err := s.countUnreadByState(ctx, currentConversation.ID, agentReadState, enums.IMSenderTypeCustomer) + if err != nil { + return err + } + customerUnreadCount, err := s.countUnreadByState(ctx, currentConversation.ID, customerReadState, enums.IMSenderTypeAgent, enums.IMSenderTypeAI) + if err != nil { + return err + } + if actor.isAgentSide() && currentConversation.AgentUnreadCount == agentUnreadCount && currentReadState != nil && currentReadState.LastReadMessageID >= targetMessage.ID { + return nil + } + if !actor.isAgentSide() && currentConversation.CustomerUnreadCount == customerUnreadCount && currentReadState != nil && currentReadState.LastReadMessageID >= targetMessage.ID { + return nil + } + updateUserID, updateUserName := actor.conversationUpdateAudit() + return repositories.ConversationRepository.Updates(ctx.Tx, currentConversation.ID, map[string]any{ + "agent_unread_count": agentUnreadCount, + "customer_unread_count": customerUnreadCount, + "update_user_id": updateUserID, + "update_user_name": updateUserName, + "updated_at": time.Now(), + }) + }) + if err != nil { + return false, err + } + return true, nil +} + +func (s *conversationService) countUnreadByState(ctx *sqls.TxContext, conversationID int64, state *models.ConversationReadState, senderTypes ...enums.IMSenderType) (int, error) { + lastReadMessageID := int64(0) + if state != nil { + lastReadMessageID = state.LastReadMessageID + } + normalizedSenderTypes := make([]enums.IMSenderType, 0, len(senderTypes)) + for _, senderType := range senderTypes { + normalizedSenderTypes = append(normalizedSenderTypes, senderType) + } + count, err := ConversationReadStateService.CountUnreadMessages(ctx, conversationID, lastReadMessageID, normalizedSenderTypes...) + return int(count), err +} + +func (s *conversationService) IsCustomerConversationOwner(conversation *models.Conversation, externalUser openidentity.ExternalUser) bool { + if conversation == nil { + return false + } + extID := strings.TrimSpace(externalUser.ExternalID) + if extID == "" || strings.TrimSpace(string(externalUser.ExternalSource)) == "" || conversation.CustomerID <= 0 { + return false + } + identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), externalUser.ExternalSource, extID) + if identity == nil { + return false + } + return identity.CustomerID == conversation.CustomerID +} + +func (s *conversationService) BuildConversationSummary(conversation *models.Conversation) string { + if conversation == nil { + return "" + } + if strings.TrimSpace(conversation.LastMessageSummary) != "" { + return conversation.LastMessageSummary + } + return strings.TrimSpace(conversation.CustomerName) +} + +func (s *conversationService) getCustomerName(db *gorm.DB, customerID int64) string { + if customerID <= 0 { + return "" + } + if customer := repositories.CustomerRepository.Get(db, customerID); customer != nil { + return strings.TrimSpace(customer.Name) + } + return "" +} + +func (s *conversationService) canCloseConversation(conversation *models.Conversation, operator *dto.AuthPrincipal) bool { + if conversation == nil || operator == nil { + return false + } + if s.isAdmin(operator) { + return true + } + return conversation.Status == enums.IMConversationStatusActive && conversation.CurrentAssigneeID > 0 && conversation.CurrentAssigneeID == operator.UserID +} + +func (s *conversationService) canTransferConversation(conversation *models.Conversation, operator *dto.AuthPrincipal) bool { + if conversation == nil || operator == nil { + return false + } + if s.isAdmin(operator) { + return true + } + return conversation.Status == enums.IMConversationStatusActive && + conversation.CurrentAssigneeID > 0 && + conversation.CurrentAssigneeID == operator.UserID +} + +func (s *conversationService) isAdmin(operator *dto.AuthPrincipal) bool { + if operator == nil { + return false + } + return slices.Contains(operator.Roles, constants.RoleCodeSuperAdmin) || slices.Contains(operator.Roles, constants.RoleCodeAdmin) +} + +func (s *conversationService) buildEventPayload(payload map[string]any) string { + if len(payload) == 0 { + return "" + } + data, err := json.Marshal(payload) + if err != nil { + return "" + } + return string(data) +} + +// LinkConversationCustomer 将会话绑定到指定客户。 +func (s *conversationService) LinkConversationCustomer(conversationID, customerID int64, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + if conversationID <= 0 || customerID <= 0 { + return errorsx.InvalidParamI18n("error.e0133") + } + cust := CustomerService.Get(customerID) + if cust == nil || cust.Status == enums.StatusDeleted { + return errorsx.InvalidParamI18n("error.e0155") + } + conv := s.Get(conversationID) + if conv == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + if conv.Status == enums.IMConversationStatusClosed { + return errorsx.InvalidParamI18n("error.e0183") + } + if !s.canLinkConversationCustomer(conv, operator) { + return errorsx.ForbiddenI18n("error.e0224") + } + + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + current := repositories.ConversationRepository.Get(ctx.Tx, conversationID) + if current == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + now := time.Now() + return repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{ + "customer_id": customerID, + "customer_name": strings.TrimSpace(cust.Name), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }) + }) + if err != nil { + return err + } + if updated := s.Get(conversationID); updated != nil { + WsService.PublishConversationChanged(updated, enums.IMRealtimeEventConversationUpdated) + } + return nil +} + +func (s *conversationService) GetConversationExternalIdentity(conversation *models.Conversation) *models.CustomerIdentity { + if conversation == nil || conversation.CustomerID <= 0 { + return nil + } + identities := repositories.CustomerIdentityRepository.FindByCustomerID(sqls.DB(), conversation.CustomerID) + if len(identities) == 0 { + return nil + } + if channel := ChannelService.Get(conversation.ChannelID); channel != nil { + expected := externalSourceForChannelType(channel.ChannelType) + if strings.TrimSpace(string(expected)) != "" { + for i := range identities { + if identities[i].ExternalSource == expected { + return &identities[i] + } + } + } + } + return &identities[0] +} + +func externalSourceForChannelType(channelType string) enums.ExternalSource { + switch strings.TrimSpace(channelType) { + case enums.ChannelTypeWeb: + return enums.ExternalSourceGuest + default: + return "" + } +} + +func (s *conversationService) canLinkConversationCustomer(conv *models.Conversation, operator *dto.AuthPrincipal) bool { + if conv == nil || operator == nil { + return false + } + if s.isAdmin(operator) { + return true + } + switch conv.Status { + case enums.IMConversationStatusAIServing: + return true + case enums.IMConversationStatusPending: + return true + case enums.IMConversationStatusActive: + return conv.CurrentAssigneeID == 0 || conv.CurrentAssigneeID == operator.UserID + default: + return false + } +} + +func (s *conversationService) resolveInitialStatus(serviceMode enums.IMConversationServiceMode) enums.IMConversationStatus { + switch serviceMode { + case enums.IMConversationServiceModeHumanOnly: + return enums.IMConversationStatusPending + case enums.IMConversationServiceModeAIOnly, enums.IMConversationServiceModeAIFirst: + return enums.IMConversationStatusAIServing + default: + return enums.IMConversationStatusAIServing + } +} diff --git a/internal/services/conversation_tag_service.go b/internal/services/conversation_tag_service.go new file mode 100644 index 0000000..f77da19 --- /dev/null +++ b/internal/services/conversation_tag_service.go @@ -0,0 +1,95 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var ConversationTagService = newConversationTagService() + +func newConversationTagService() *conversationTagService { + return &conversationTagService{} +} + +type conversationTagService struct { +} + +func (s *conversationTagService) Get(id int64) *models.ConversationTag { + return repositories.ConversationTagRepository.Get(sqls.DB(), id) +} + +func (s *conversationTagService) Take(where ...interface{}) *models.ConversationTag { + return repositories.ConversationTagRepository.Take(sqls.DB(), where...) +} + +func (s *conversationTagService) Find(cnd *sqls.Cnd) []models.ConversationTag { + return repositories.ConversationTagRepository.Find(sqls.DB(), cnd) +} + +func (s *conversationTagService) FindOne(cnd *sqls.Cnd) *models.ConversationTag { + return repositories.ConversationTagRepository.FindOne(sqls.DB(), cnd) +} + +func (s *conversationTagService) FindPageByParams(params *params.QueryParams) (list []models.ConversationTag, paging *sqls.Paging) { + return repositories.ConversationTagRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *conversationTagService) FindPageByCnd(cnd *sqls.Cnd) (list []models.ConversationTag, paging *sqls.Paging) { + return repositories.ConversationTagRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *conversationTagService) Count(cnd *sqls.Cnd) int64 { + return repositories.ConversationTagRepository.Count(sqls.DB(), cnd) +} + +func (s *conversationTagService) Create(t *models.ConversationTag) error { + return repositories.ConversationTagRepository.Create(sqls.DB(), t) +} + +func (s *conversationTagService) Update(t *models.ConversationTag) error { + return repositories.ConversationTagRepository.Update(sqls.DB(), t) +} + +func (s *conversationTagService) Updates(id int64, columns map[string]interface{}) error { + return repositories.ConversationTagRepository.Updates(sqls.DB(), id, columns) +} + +func (s *conversationTagService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.ConversationTagRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *conversationTagService) Delete(id int64) { + repositories.ConversationTagRepository.Delete(sqls.DB(), id) +} + +func (s *conversationTagService) IsExists(conversationID int64, tagID int64) bool { + return repositories.ConversationTagRepository.FindOne(sqls.DB(), sqls.NewCnd().Where("conversation_id = ? AND tag_id = ?", conversationID, tagID)) != nil +} + +func (s *conversationTagService) AddTag(req request.AddConversationTagRequest, operator *dto.AuthPrincipal) error { + tag := TagService.Get(req.TagID) + if tag == nil || tag.Status != enums.StatusOk { + return errorsx.InvalidParamI18n("error.conversation.tagNotFound") + } + if s.IsExists(req.ConversationID, req.TagID) { + return nil + } + return repositories.ConversationTagRepository.Create(sqls.DB(), &models.ConversationTag{ + ConversationID: req.ConversationID, + TagID: req.TagID, + AuditFields: utils.BuildAuditFields(operator), + }) +} + +func (s *conversationTagService) RemoveTag(req request.RemoveConversationTagRequest) error { + return sqls.DB().Where("conversation_id = ? AND tag_id = ?", req.ConversationID, req.TagID).Delete(&models.ConversationTag{}).Error +} diff --git a/internal/services/cronx/cron.go b/internal/services/cronx/cron.go new file mode 100644 index 0000000..ba96f26 --- /dev/null +++ b/internal/services/cronx/cron.go @@ -0,0 +1,54 @@ +package cronx + +import ( + "agent-desk/internal/services" + "fmt" + "log/slog" + + "github.com/robfig/cron/v3" +) + +func Init() { + c := cron.New() + + addFunc(c, "0 4 ? * *", func() { + fmt.Println("cron test") + }) + + addFunc(c, "@every 30s", func() { + if _, err := services.ConversationDispatchService.DispatchPendingConversations(0); err != nil { + slog.Warn("dispatch pending conversations loop failed", "error", err) + } + }) + + addFunc(c, "@every 5s", func() { + tgCount := services.TelegramOutboundService.DispatchPendingOutbox() + if tgCount > 0 { + slog.Info("telegram outbox dispatched", "count", tgCount) + } + zaloCount := services.ZaloOAOutboundService.DispatchPendingOutbox() + if zaloCount > 0 { + slog.Info("zalo oa outbox dispatched", "count", zaloCount) + } + slackCount := services.SlackOutboundService.DispatchPendingOutbox() + if slackCount > 0 { + slog.Info("slack outbox dispatched", "count", slackCount) + } + discordCount := services.DiscordOutboundService.DispatchPendingOutbox() + if discordCount > 0 { + slog.Info("discord outbox dispatched", "count", discordCount) + } + emailCount := services.EmailOutboundService.DispatchPendingOutbox() + if emailCount > 0 { + slog.Info("email outbox dispatched", "count", emailCount) + } + }) + + c.Start() +} + +func addFunc(c *cron.Cron, sepc string, cmd func()) { + if _, err := c.AddFunc(sepc, cmd); err != nil { + slog.Error("add cron func error", slog.Any("err", err)) + } +} diff --git a/internal/services/customer_contact_service.go b/internal/services/customer_contact_service.go new file mode 100644 index 0000000..eb075b7 --- /dev/null +++ b/internal/services/customer_contact_service.go @@ -0,0 +1,524 @@ +package services + +import ( + "sort" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var CustomerContactService = newCustomerContactService() + +func newCustomerContactService() *customerContactService { + return &customerContactService{} +} + +type customerContactService struct { +} + +func (s *customerContactService) Get(id int64) *models.CustomerContact { + return repositories.CustomerContactRepository.Get(sqls.DB(), id) +} + +func (s *customerContactService) Take(where ...interface{}) *models.CustomerContact { + return repositories.CustomerContactRepository.Take(sqls.DB(), where...) +} + +func (s *customerContactService) Find(cnd *sqls.Cnd) []models.CustomerContact { + return repositories.CustomerContactRepository.Find(sqls.DB(), cnd) +} + +func (s *customerContactService) FindOne(cnd *sqls.Cnd) *models.CustomerContact { + return repositories.CustomerContactRepository.FindOne(sqls.DB(), cnd) +} + +func (s *customerContactService) FindPageByParams(params *params.QueryParams) (list []models.CustomerContact, paging *sqls.Paging) { + return repositories.CustomerContactRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *customerContactService) FindPageByCnd(cnd *sqls.Cnd) (list []models.CustomerContact, paging *sqls.Paging) { + return repositories.CustomerContactRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *customerContactService) Count(cnd *sqls.Cnd) int64 { + return repositories.CustomerContactRepository.Count(sqls.DB(), cnd) +} + +func (s *customerContactService) Create(t *models.CustomerContact) error { + return repositories.CustomerContactRepository.Create(sqls.DB(), t) +} + +func (s *customerContactService) Update(t *models.CustomerContact) error { + return repositories.CustomerContactRepository.Update(sqls.DB(), t) +} + +func (s *customerContactService) Updates(id int64, columns map[string]interface{}) error { + return repositories.CustomerContactRepository.Updates(sqls.DB(), id, columns) +} + +func (s *customerContactService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.CustomerContactRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *customerContactService) Delete(id int64) { + repositories.CustomerContactRepository.Delete(sqls.DB(), id) +} + +// FindActiveByCustomerID 返回某客户下未删除的联系方式列表。 +func (s *customerContactService) FindActiveByCustomerID(customerID int64) []models.CustomerContact { + if customerID <= 0 { + return nil + } + cnd := sqls.NewCnd(). + Where("customer_id = ?", customerID). + Where("status <> ?", enums.StatusDeleted). + Asc("id") + return repositories.CustomerContactRepository.Find(sqls.DB(), cnd) +} + +func normalizeContactSource(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "manual" + } + return v +} + +func (s *customerContactService) hasDuplicateContact( + db *gorm.DB, + customerID int64, + contactType enums.ContactType, + contactValue string, + excludeID int64, +) bool { + cnd := sqls.NewCnd(). + Where("customer_id = ?", customerID). + Where("contact_type = ?", contactType). + Where("contact_value = ?", contactValue). + Where("status <> ?", enums.StatusDeleted) + if excludeID > 0 { + cnd = cnd.Where("id <> ?", excludeID) + } + return repositories.CustomerContactRepository.FindOne(db, cnd) != nil +} + +// findSoftDeletedContactByNaturalKey 按 uk_customer_contact 业务键查找已软删行;复活时用 UPDATE 代替 INSERT,避免唯一索引冲突。 +func (s *customerContactService) findSoftDeletedContactByNaturalKey( + db *gorm.DB, + customerID int64, + contactType enums.ContactType, + contactValue string, +) *models.CustomerContact { + cnd := sqls.NewCnd(). + Where("customer_id = ?", customerID). + Where("contact_type = ?", contactType). + Where("contact_value = ?", contactValue). + Where("status = ?", enums.StatusDeleted) + return repositories.CustomerContactRepository.FindOne(db, cnd) +} + +// syncCustomerPrimaryFromContacts 根据当前主联系方式更新客户表冗余字段(列表检索用)。 +func (s *customerContactService) syncCustomerPrimaryFromContacts(db *gorm.DB, customerID int64) error { + if customerID <= 0 { + return nil + } + if repositories.CustomerRepository.Get(db, customerID) == nil { + return nil + } + cnd := sqls.NewCnd(). + Where("customer_id = ?", customerID). + Where("is_primary = ?", true). + Where("status <> ?", enums.StatusDeleted) + primary := repositories.CustomerContactRepository.FindOne(db, cnd) + pm, pe := "", "" + if primary != nil { + val := strings.TrimSpace(primary.ContactValue) + switch primary.ContactType { + case enums.ContactTypeEmail: + pe = val + default: + pm = val + } + } + return repositories.CustomerRepository.Updates(db, customerID, map[string]any{ + "primary_mobile": pm, + "primary_email": pe, + "updated_at": time.Now(), + }) +} + +// ReplaceAllForCustomerInTx 在事务内全量替换客户联系方式(软删未出现在 payload 中的记录),并同步客户主联系方式冗余字段。 +func (s *customerContactService) ReplaceAllForCustomerInTx( + ctx *sqls.TxContext, + customerID int64, + raw []request.CustomerProfileContactItem, + operator *dto.AuthPrincipal, +) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + type line struct { + id *int64 + ct enums.ContactType + val string + remark string + primary bool + } + var items []line + for _, r := range raw { + ct := strings.TrimSpace(r.ContactType) + val := strings.TrimSpace(r.ContactValue) + if val == "" { + continue + } + if !enums.IsValidContactType(ct) { + return errorsx.InvalidParamI18n("error.e0301") + } + items = append(items, line{ + id: r.ID, + ct: enums.ContactType(ct), + val: val, + remark: strings.TrimSpace(r.Remark), + primary: r.IsPrimary, + }) + } + if len(items) > 0 { + primaryCount := 0 + for i := range items { + if items[i].primary { + primaryCount++ + } + } + if primaryCount == 0 { + items[0].primary = true + } else if primaryCount > 1 { + return errorsx.InvalidParamI18n("error.e0092") + } + } + + existing := repositories.CustomerContactRepository.Find(ctx.Tx, sqls.NewCnd(). + Where("customer_id = ?", customerID). + Where("status <> ?", enums.StatusDeleted). + Asc("id")) + + wantIDs := map[int64]struct{}{} + for i := range items { + if items[i].id != nil && *items[i].id > 0 { + wantIDs[*items[i].id] = struct{}{} + } + } + now := time.Now() + for _, ex := range existing { + if _, ok := wantIDs[ex.ID]; !ok { + if err := repositories.CustomerContactRepository.Updates(ctx.Tx, ex.ID, map[string]any{ + "status": enums.StatusDeleted, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + } + } + + sort.SliceStable(items, func(i, j int) bool { + return !items[i].primary && items[j].primary + }) + + for _, it := range items { + if it.id != nil && *it.id > 0 { + row := repositories.CustomerContactRepository.Get(ctx.Tx, *it.id) + if row == nil || row.CustomerID != customerID || row.Status == enums.StatusDeleted { + return errorsx.InvalidParamI18n("error.e0299") + } + if s.hasDuplicateContact(ctx.Tx, customerID, it.ct, it.val, *it.id) { + return errorsx.InvalidParamI18n("error.e0318") + } + if it.primary { + if err := s.clearPrimaryExcept(ctx.Tx, customerID, *it.id); err != nil { + return err + } + } + if err := repositories.CustomerContactRepository.Updates(ctx.Tx, *it.id, map[string]any{ + "contact_type": it.ct, + "contact_value": it.val, + "is_primary": it.primary, + "remark": it.remark, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + continue + } + if s.hasDuplicateContact(ctx.Tx, customerID, it.ct, it.val, 0) { + return errorsx.InvalidParamI18n("error.e0318") + } + if deleted := s.findSoftDeletedContactByNaturalKey(ctx.Tx, customerID, it.ct, it.val); deleted != nil { + if it.primary { + if err := s.clearPrimaryExcept(ctx.Tx, customerID, deleted.ID); err != nil { + return err + } + } + if err := repositories.CustomerContactRepository.Updates(ctx.Tx, deleted.ID, map[string]any{ + "status": enums.StatusOk, + "contact_type": it.ct, + "contact_value": it.val, + "is_primary": it.primary, + "is_verified": false, + "verified_at": nil, + "remark": it.remark, + "source": normalizeContactSource("manual"), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + continue + } + if it.primary { + if err := s.clearPrimaryExcept(ctx.Tx, customerID, 0); err != nil { + return err + } + } + item := &models.CustomerContact{ + CustomerID: customerID, + ContactType: it.ct, + ContactValue: it.val, + IsPrimary: it.primary, + IsVerified: false, + Source: normalizeContactSource("manual"), + Status: enums.StatusOk, + Remark: it.remark, + AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.CustomerContactRepository.Create(ctx.Tx, item); err != nil { + return err + } + } + return s.syncCustomerPrimaryFromContacts(ctx.Tx, customerID) +} + +func (s *customerContactService) clearPrimaryExcept(db *gorm.DB, customerID int64, exceptID int64) error { + cnd := sqls.NewCnd(). + Where("customer_id = ?", customerID). + Where("is_primary = ?", true) + if exceptID > 0 { + cnd = cnd.Where("id <> ?", exceptID) + } + list := repositories.CustomerContactRepository.Find(db, cnd) + for i := range list { + if err := repositories.CustomerContactRepository.UpdateColumn(db, list[i].ID, "is_primary", false); err != nil { + return err + } + } + return nil +} + +func (s *customerContactService) validateContactStatus(status int) error { + if !enums.IsValidStatus(status) { + return errorsx.InvalidParamI18n("error.e0254") + } + if status == int(enums.StatusDeleted) { + return errorsx.InvalidParamI18n("error.e0254") + } + return nil +} + +// CreateCustomerContact 创建联系方式;主联系方式在同一客户下唯一。 +func (s *customerContactService) CreateCustomerContact(req request.CreateCustomerContactRequest, operator *dto.AuthPrincipal) (*models.CustomerContact, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + if req.CustomerID <= 0 { + return nil, errorsx.InvalidParamI18n("error.e0155") + } + if CustomerService.Get(req.CustomerID) == nil { + return nil, errorsx.InvalidParamI18n("error.e0155") + } + ct := strings.TrimSpace(req.ContactType) + if !enums.IsValidContactType(ct) { + return nil, errorsx.InvalidParamI18n("error.e0301") + } + val := strings.TrimSpace(req.ContactValue) + if val == "" { + return nil, errorsx.InvalidParamI18n("error.e0300") + } + if err := s.validateContactStatus(req.Status); err != nil { + return nil, err + } + status := enums.Status(req.Status) + if status == 0 { + status = enums.StatusOk + } + + var created *models.CustomerContact + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if s.hasDuplicateContact(ctx.Tx, req.CustomerID, enums.ContactType(ct), val, 0) { + return errorsx.InvalidParamI18n("error.e0318") + } + now := time.Now() + if deleted := s.findSoftDeletedContactByNaturalKey(ctx.Tx, req.CustomerID, enums.ContactType(ct), val); deleted != nil { + if req.IsPrimary { + if err := s.clearPrimaryExcept(ctx.Tx, req.CustomerID, deleted.ID); err != nil { + return err + } + } + var verifiedAt *time.Time + if req.IsVerified { + verifiedAt = &now + } + if err := repositories.CustomerContactRepository.Updates(ctx.Tx, deleted.ID, map[string]any{ + "status": status, + "contact_type": enums.ContactType(ct), + "contact_value": val, + "is_primary": req.IsPrimary, + "is_verified": req.IsVerified, + "verified_at": verifiedAt, + "source": normalizeContactSource(req.Source), + "remark": strings.TrimSpace(req.Remark), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + created = repositories.CustomerContactRepository.Get(ctx.Tx, deleted.ID) + return s.syncCustomerPrimaryFromContacts(ctx.Tx, req.CustomerID) + } + if req.IsPrimary { + if err := s.clearPrimaryExcept(ctx.Tx, req.CustomerID, 0); err != nil { + return err + } + } + var verifiedAt *time.Time + if req.IsVerified { + verifiedAt = &now + } + item := &models.CustomerContact{ + CustomerID: req.CustomerID, + ContactType: enums.ContactType(ct), + ContactValue: val, + IsPrimary: req.IsPrimary, + IsVerified: req.IsVerified, + VerifiedAt: verifiedAt, + Source: normalizeContactSource(req.Source), + Status: status, + Remark: strings.TrimSpace(req.Remark), + AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.CustomerContactRepository.Create(ctx.Tx, item); err != nil { + return err + } + created = item + if err := s.syncCustomerPrimaryFromContacts(ctx.Tx, req.CustomerID); err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + return created, nil +} + +// UpdateCustomerContact 更新联系方式。 +func (s *customerContactService) UpdateCustomerContact(req request.UpdateCustomerContactRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + if req.ID <= 0 { + return errorsx.InvalidParamI18n("error.e0299") + } + current := s.Get(req.ID) + if current == nil { + return errorsx.InvalidParamI18n("error.e0299") + } + ct := strings.TrimSpace(req.ContactType) + if !enums.IsValidContactType(ct) { + return errorsx.InvalidParamI18n("error.e0301") + } + val := strings.TrimSpace(req.ContactValue) + if val == "" { + return errorsx.InvalidParamI18n("error.e0300") + } + if err := s.validateContactStatus(req.Status); err != nil { + return err + } + + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if s.hasDuplicateContact(ctx.Tx, current.CustomerID, enums.ContactType(ct), val, req.ID) { + return errorsx.InvalidParamI18n("error.e0318") + } + if req.IsPrimary { + if err := s.clearPrimaryExcept(ctx.Tx, current.CustomerID, req.ID); err != nil { + return err + } + } + now := time.Now() + verifiedAt := current.VerifiedAt + if req.IsVerified { + if verifiedAt == nil { + verifiedAt = &now + } + } else { + verifiedAt = nil + } + if err := repositories.CustomerContactRepository.Updates(ctx.Tx, req.ID, map[string]any{ + "contact_type": enums.ContactType(ct), + "contact_value": val, + "is_primary": req.IsPrimary, + "is_verified": req.IsVerified, + "verified_at": verifiedAt, + "source": normalizeContactSource(req.Source), + "status": req.Status, + "remark": strings.TrimSpace(req.Remark), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + return s.syncCustomerPrimaryFromContacts(ctx.Tx, current.CustomerID) + }) +} + +// DeleteCustomerContact 软删除联系方式并同步客户主联系方式冗余字段。 +func (s *customerContactService) DeleteCustomerContact(id int64, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + if id <= 0 { + return errorsx.InvalidParamI18n("error.e0299") + } + current := s.Get(id) + if current == nil { + return errorsx.InvalidParamI18n("error.e0299") + } + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + now := time.Now() + if err := repositories.CustomerContactRepository.Updates(ctx.Tx, id, map[string]any{ + "status": enums.StatusDeleted, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + return s.syncCustomerPrimaryFromContacts(ctx.Tx, current.CustomerID) + }) +} diff --git a/internal/services/customer_identity_service.go b/internal/services/customer_identity_service.go new file mode 100644 index 0000000..2cac659 --- /dev/null +++ b/internal/services/customer_identity_service.go @@ -0,0 +1,67 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var CustomerIdentityService = newCustomerIdentityService() + +func newCustomerIdentityService() *customerIdentityService { + return &customerIdentityService{} +} + +type customerIdentityService struct { +} + +func (s *customerIdentityService) Get(id int64) *models.CustomerIdentity { + return repositories.CustomerIdentityRepository.Get(sqls.DB(), id) +} + +func (s *customerIdentityService) Take(where ...interface{}) *models.CustomerIdentity { + return repositories.CustomerIdentityRepository.Take(sqls.DB(), where...) +} + +func (s *customerIdentityService) Find(cnd *sqls.Cnd) []models.CustomerIdentity { + return repositories.CustomerIdentityRepository.Find(sqls.DB(), cnd) +} + +func (s *customerIdentityService) FindOne(cnd *sqls.Cnd) *models.CustomerIdentity { + return repositories.CustomerIdentityRepository.FindOne(sqls.DB(), cnd) +} + +func (s *customerIdentityService) FindPageByParams(params *params.QueryParams) (list []models.CustomerIdentity, paging *sqls.Paging) { + return repositories.CustomerIdentityRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *customerIdentityService) FindPageByCnd(cnd *sqls.Cnd) (list []models.CustomerIdentity, paging *sqls.Paging) { + return repositories.CustomerIdentityRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *customerIdentityService) Count(cnd *sqls.Cnd) int64 { + return repositories.CustomerIdentityRepository.Count(sqls.DB(), cnd) +} + +func (s *customerIdentityService) Create(t *models.CustomerIdentity) error { + return repositories.CustomerIdentityRepository.Create(sqls.DB(), t) +} + +func (s *customerIdentityService) Update(t *models.CustomerIdentity) error { + return repositories.CustomerIdentityRepository.Update(sqls.DB(), t) +} + +func (s *customerIdentityService) Updates(id int64, columns map[string]interface{}) error { + return repositories.CustomerIdentityRepository.Updates(sqls.DB(), id, columns) +} + +func (s *customerIdentityService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.CustomerIdentityRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *customerIdentityService) Delete(id int64) { + repositories.CustomerIdentityRepository.Delete(sqls.DB(), id) +} diff --git a/internal/services/customer_service.go b/internal/services/customer_service.go new file mode 100644 index 0000000..2514180 --- /dev/null +++ b/internal/services/customer_service.go @@ -0,0 +1,373 @@ +package services + +import ( + "crypto/md5" + "encoding/hex" + "log/slog" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + "strings" + "time" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/common/strs" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var CustomerService = newCustomerService() + +func newCustomerService() *customerService { + return &customerService{} +} + +type customerService struct { +} + +func (s *customerService) Get(id int64) *models.Customer { + return repositories.CustomerRepository.Get(sqls.DB(), id) +} + +func (s *customerService) Take(where ...interface{}) *models.Customer { + return repositories.CustomerRepository.Take(sqls.DB(), where...) +} + +func (s *customerService) Find(cnd *sqls.Cnd) []models.Customer { + return repositories.CustomerRepository.Find(sqls.DB(), cnd) +} + +func (s *customerService) FindOne(cnd *sqls.Cnd) *models.Customer { + return repositories.CustomerRepository.FindOne(sqls.DB(), cnd) +} + +func (s *customerService) FindPageByParams(params *params.QueryParams) (list []models.Customer, paging *sqls.Paging) { + return repositories.CustomerRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *customerService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Customer, paging *sqls.Paging) { + return repositories.CustomerRepository.FindPageByCnd(sqls.DB(), cnd) +} + +// ListCustomers 客户分页列表(连联系方式表,支持按非主联系方式检索)。 +func (s *customerService) ListCustomers(req request.CustomerListRequest) (list []models.Customer, paging *sqls.Paging) { + if err := s.newCustomerListQuery(req).Distinct("c.*").Offset(req.Offset()).Order("c.id DESC").Limit(req.GetLimit()).Scan(&list).Error; err != nil { + slog.Error("customer list scan failed", slog.Any("error", err)) + } + + var total int64 + if err := s.newCustomerListQuery(req).Distinct("c.id").Count(&total).Error; err != nil { + slog.Error("customer list count failed", slog.Any("error", err)) + } + + paging = &sqls.Paging{ + Page: req.GetPage(), + Limit: req.GetLimit(), + Total: total, + } + return +} + +func (s *customerService) newCustomerListQuery(req request.CustomerListRequest) *gorm.DB { + deleted := int(enums.StatusDeleted) + tx := sqls.DB(). + Table("t_customer AS c"). + Joins("LEFT JOIN t_customer_contact AS cc ON cc.customer_id = c.id AND cc.status <> ?", deleted). + Joins("LEFT JOIN t_company AS co ON co.id = c.company_id") + + tx.Where("c.status <> ?", enums.StatusDeleted) + + if req.Status != nil { + tx.Where("c.status = ?", *req.Status) + } + if req.Gender != nil { + tx.Where("c.gender = ?", *req.Gender) + } + if req.CompanyID != nil && *req.CompanyID > 0 { + tx.Where("c.company_id = ?", *req.CompanyID) + } + if kw := strings.TrimSpace(req.Keyword); strs.IsNotBlank(kw) { + pat := "%" + kw + "%" + tx.Where(`( +c.name LIKE ? OR +c.primary_mobile LIKE ? OR +c.primary_email LIKE ? OR +cc.contact_value LIKE ? OR +co.name LIKE ? +)`, pat, pat, pat, pat, pat) + } + return tx +} + +func (s *customerService) Count(cnd *sqls.Cnd) int64 { + return repositories.CustomerRepository.Count(sqls.DB(), cnd) +} + +func (s *customerService) CountByCompanyIDs(companyIDs []int64) map[int64]int64 { + return repositories.CustomerRepository.CountByCompanyIDs(sqls.DB(), companyIDs, int(enums.StatusDeleted)) +} + +func (s *customerService) EnsureExternalCustomer(ctx *sqls.TxContext, externalUser openidentity.ExternalUser) (int64, error) { + if ctx == nil || ctx.Tx == nil { + return 0, errorsx.InvalidParamI18n("error.e0086") + } + externalSource := externalUser.ExternalSource + externalID := strings.TrimSpace(externalUser.ExternalID) + if strings.TrimSpace(string(externalSource)) == "" || externalID == "" { + return 0, errorsx.UnauthorizedI18n("error.e0149") + } + now := time.Now() + if identity := repositories.CustomerIdentityRepository.GetBy(ctx.Tx, externalSource, externalID); identity != nil { + updates := map[string]any{ + "last_active_at": now, + "updated_at": now, + } + if strs.IsNotBlank(externalUser.ExternalName) { + updates["name"] = externalUser.ExternalName + } + if err := repositories.CustomerRepository.Updates(ctx.Tx, identity.CustomerID, updates); err != nil { + return 0, err + } + + ctx.RegisterCallback(func() { + if strs.IsNotBlank(externalUser.ExternalName) { + if err := s.syncConversationCustomerName(sqls.DB(), identity.CustomerID, externalUser.ExternalName, nil, now); err != nil { + slog.Error("sync conversation customer name failed", + "customerId", identity.CustomerID, + "customerName", externalUser.ExternalName, + "error", err, + ) + } + } + }) + return identity.CustomerID, nil + } + + customer := &models.Customer{ + Name: buildExternalCustomerName(externalUser), + LastActiveAt: &now, + Status: enums.StatusOk, + AuditFields: utils.BuildAuditFields(nil), + } + if err := repositories.CustomerRepository.Create(ctx.Tx, customer); err != nil { + return 0, err + } + if err := repositories.CustomerIdentityRepository.Create(ctx.Tx, &models.CustomerIdentity{ + CustomerID: customer.ID, + ExternalSource: externalSource, + ExternalID: externalID, + Status: enums.StatusOk, + AuditFields: utils.BuildAuditFields(nil), + }); err != nil { + return 0, err + } + return customer.ID, nil +} + +func buildExternalCustomerName(externalUser openidentity.ExternalUser) string { + if strs.IsNotBlank(externalUser.ExternalName) { + return externalUser.ExternalName + } + return "Visitor " + hashUUID(externalUser.ExternalID) +} + +func hashUUID(uuid string) string { + if uuid == "" { + return "unknown" + } + + h := md5.Sum([]byte(uuid)) + return hex.EncodeToString(h[:])[:8] +} + +func (s *customerService) CreateCustomer(req request.CreateCustomerRequest, operator *dto.AuthPrincipal) (*models.Customer, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errorsx.InvalidParamI18n("error.e0156") + } + + if req.CompanyID > 0 { + company := CompanyService.Get(req.CompanyID) + if company == nil { + return nil, errorsx.InvalidParamI18n("error.e0204") + } + } + + item := &models.Customer{ + Name: name, + Gender: enums.Gender(req.Gender), + CompanyID: req.CompanyID, + PrimaryMobile: strings.TrimSpace(req.PrimaryMobile), + PrimaryEmail: strings.TrimSpace(req.PrimaryEmail), + Status: enums.StatusOk, + Remark: strings.TrimSpace(req.Remark), + AuditFields: utils.BuildAuditFields(operator), + } + + if err := repositories.CustomerRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return item, nil +} + +func (s *customerService) UpdateCustomer(req request.UpdateCustomerRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + item := s.Get(req.ID) + if item == nil { + return errorsx.InvalidParamI18n("error.e0155") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return errorsx.InvalidParamI18n("error.e0156") + } + + if req.CompanyID > 0 { + company := CompanyService.Get(req.CompanyID) + if company == nil { + return errorsx.InvalidParamI18n("error.e0204") + } + } + + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + now := time.Now() + if err := repositories.CustomerRepository.Updates(ctx.Tx, req.ID, map[string]any{ + "name": name, + "gender": req.Gender, + "company_id": req.CompanyID, + "primary_mobile": strings.TrimSpace(req.PrimaryMobile), + "primary_email": strings.TrimSpace(req.PrimaryEmail), + "remark": strings.TrimSpace(req.Remark), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + return s.syncConversationCustomerName(ctx.Tx, req.ID, name, operator, now) + }) +} + +func (s *customerService) DeleteCustomer(id int64, operator dto.AuthPrincipal) error { + item := s.Get(id) + if item == nil { + return errorsx.InvalidParamI18n("error.e0155") + } + return repositories.CustomerRepository.Updates(sqls.DB(), id, map[string]any{ + "status": enums.StatusDeleted, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) +} + +func (s *customerService) syncConversationCustomerName(db *gorm.DB, customerID int64, name string, operator *dto.AuthPrincipal, now time.Time) error { + if customerID <= 0 { + return nil + } + updates := map[string]any{ + "customer_name": name, + "updated_at": now, + } + if operator != nil { + updates["update_user_id"] = operator.UserID + updates["update_user_name"] = operator.Username + } + return repositories.ConversationRepository.UpdatesByCustomerID(db, customerID, updates) +} + +func (s *customerService) UpdateStatus(id int64, status int, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + item := s.Get(id) + if item == nil { + return errorsx.InvalidParamI18n("error.e0155") + } + if status != int(enums.StatusOk) && status != int(enums.StatusDisabled) { + return errorsx.InvalidParamI18n("error.e0254") + } + return repositories.CustomerRepository.Updates(sqls.DB(), id, map[string]any{ + "status": status, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) +} + +// SaveCustomerProfile 单事务保存客户主信息与联系方式全量(新建或更新)。 +func (s *customerService) SaveCustomerProfile(req request.SaveCustomerProfileRequest, operator *dto.AuthPrincipal) (*models.Customer, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errorsx.InvalidParamI18n("error.e0156") + } + if req.CompanyID > 0 { + if CompanyService.Get(req.CompanyID) == nil { + return nil, errorsx.InvalidParamI18n("error.e0204") + } + } + createMode := req.ID == nil || *req.ID <= 0 + + var out *models.Customer + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + var customerID int64 + if createMode { + c := &models.Customer{ + Name: name, + Gender: enums.Gender(req.Gender), + CompanyID: req.CompanyID, + PrimaryMobile: "", + PrimaryEmail: "", + Status: enums.StatusOk, + Remark: strings.TrimSpace(req.Remark), + AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.CustomerRepository.Create(ctx.Tx, c); err != nil { + return err + } + customerID = c.ID + out = c + } else { + customerID = *req.ID + cur := repositories.CustomerRepository.Get(ctx.Tx, customerID) + if cur == nil { + return errorsx.InvalidParamI18n("error.e0155") + } + now := time.Now() + if err := repositories.CustomerRepository.Updates(ctx.Tx, customerID, map[string]any{ + "name": name, + "gender": req.Gender, + "company_id": req.CompanyID, + "remark": strings.TrimSpace(req.Remark), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + if err := s.syncConversationCustomerName(ctx.Tx, customerID, name, operator, now); err != nil { + return err + } + out = repositories.CustomerRepository.Get(ctx.Tx, customerID) + } + return CustomerContactService.ReplaceAllForCustomerInTx(ctx, customerID, req.Contacts, operator) + }) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/services/customer_service_test.go b/internal/services/customer_service_test.go new file mode 100644 index 0000000..3040db0 --- /dev/null +++ b/internal/services/customer_service_test.go @@ -0,0 +1,125 @@ +package services_test + +import ( + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/services" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestEnsureExternalCustomerUpdatesNameFromExternalIdentity(t *testing.T) { + db := setupCustomerServiceTestDB(t) + + var firstID int64 + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + id, err := services.CustomerService.EnsureExternalCustomer(ctx, openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceUser, + ExternalID: "user-1", + ExternalName: "张三", + }) + firstID = id + return err + }); err != nil { + t.Fatalf("EnsureExternalCustomer() first error = %v", err) + } + + conversation := &models.Conversation{ + CustomerID: firstID, + CustomerName: "张三", + Status: enums.IMConversationStatusActive, + AuditFields: models.AuditFields{CreatedAt: time.Now(), UpdatedAt: time.Now()}, + } + if err := db.Create(conversation).Error; err != nil { + t.Fatalf("create conversation error = %v", err) + } + + var secondID int64 + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + id, err := services.CustomerService.EnsureExternalCustomer(ctx, openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceUser, + ExternalID: "user-1", + ExternalName: "李四", + }) + secondID = id + return err + }); err != nil { + t.Fatalf("EnsureExternalCustomer() second error = %v", err) + } + if secondID != firstID { + t.Fatalf("expected same customer id, got %d and %d", firstID, secondID) + } + + customer := services.CustomerService.Get(firstID) + if customer == nil { + t.Fatalf("expected customer to exist") + } + if customer.Name != "李四" { + t.Fatalf("expected customer name updated, got %q", customer.Name) + } + + var updatedConversation models.Conversation + if err := db.First(&updatedConversation, conversation.ID).Error; err != nil { + t.Fatalf("get conversation error = %v", err) + } + if updatedConversation.CustomerName != "李四" { + t.Fatalf("expected conversation customer name updated, got %q", updatedConversation.CustomerName) + } +} + +func TestEnsureExternalCustomerGeneratesVisitorName(t *testing.T) { + _ = setupCustomerServiceTestDB(t) + + var custID int64 + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + id, err := services.CustomerService.EnsureExternalCustomer(ctx, openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceGuest, + ExternalID: "guest-uuid-12345", + }) + custID = id + return err + }); err != nil { + t.Fatalf("EnsureExternalCustomer() error = %v", err) + } + + customer := services.CustomerService.Get(custID) + if customer == nil { + t.Fatalf("expected customer to exist") + } + if !strings.HasPrefix(customer.Name, "Visitor ") { + t.Fatalf("expected customer name to start with 'Visitor ', got %q", customer.Name) + } +} + +func setupCustomerServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.Customer{}, &models.CustomerIdentity{}, &models.Conversation{}); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + return db +} diff --git a/internal/services/customer_session_service.go b/internal/services/customer_session_service.go new file mode 100644 index 0000000..7421b49 --- /dev/null +++ b/internal/services/customer_session_service.go @@ -0,0 +1,252 @@ +package services + +import ( + "errors" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/mlogclub/simple/sqls" +) + +const ( + customerSessionTokenType = "customer_session" + customerSessionHeader = "X-Customer-Session-Token" + customerSessionExpHeader = "X-Customer-Session-Expires-At" +) + +var CustomerSessionService = newCustomerSessionService() + +func newCustomerSessionService() *customerSessionService { + return &customerSessionService{} +} + +type customerSessionService struct { +} + +type customerSessionClaims struct { + TokenType string `json:"typ"` + ChannelID int64 `json:"channelId"` + ChannelCode string `json:"channelCode"` + CustomerID int64 `json:"customerId"` + CustomerName string `json:"customerName"` + IdentityKey string `json:"identityKey"` + jwt.RegisteredClaims +} + +type CustomerSessionVerifyResult struct { + ExternalUser *openidentity.ExternalUser + Token string + ExpiresAt time.Time + Refreshed bool +} + +func (s *customerSessionService) Exchange(channel *models.Channel, externalUser openidentity.ExternalUser) (*response.CustomerSessionExchangeResponse, error) { + if channel == nil || channel.Status != enums.StatusOk { + return nil, errorsx.InvalidParamI18n("error.e0209") + } + var customerID int64 + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + id, err := CustomerService.EnsureExternalCustomer(ctx, externalUser) + if err != nil { + return err + } + customerID = id + return nil + }); err != nil { + return nil, err + } + customer := CustomerService.Get(customerID) + if customer == nil || customer.Status == enums.StatusDeleted { + return nil, errorsx.InvalidParamI18n("error.e0155") + } + token, expiresAt, err := s.Sign(channel, customer, externalUser) + if err != nil { + return nil, err + } + return &response.CustomerSessionExchangeResponse{ + CustomerSessionToken: token, + ExpiresAt: expiresAt.UTC().Format(time.RFC3339), + IdentityKey: s.identityKey(externalUser), + Customer: response.CustomerSessionCustomerResponse{ + ID: customer.ID, + Name: strings.TrimSpace(customer.Name), + }, + }, nil +} + +func (s *customerSessionService) Sign(channel *models.Channel, customer *models.Customer, externalUser openidentity.ExternalUser) (string, time.Time, error) { + cfg := config.Current().CustomerSession + secret := strings.TrimSpace(cfg.Secret) + if secret == "" { + return "", time.Time{}, errorsx.BusinessErrorI18n(1, "error.customerSession.secretMissing") + } + if channel == nil || customer == nil { + return "", time.Time{}, errorsx.InvalidParamI18n("error.e0158") + } + now := time.Now() + expiresAt := now.Add(time.Duration(cfg.TTL()) * time.Minute) + claims := customerSessionClaims{ + TokenType: customerSessionTokenType, + ChannelID: channel.ID, + ChannelCode: strings.TrimSpace(channel.ChannelID), + CustomerID: customer.ID, + CustomerName: strings.TrimSpace(customer.Name), + IdentityKey: s.identityKey(externalUser), + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expiresAt), + }, + } + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) + if err != nil { + return "", time.Time{}, err + } + return token, expiresAt, nil +} + +func (s *customerSessionService) VerifyRequest(ctx *gin.Context, channel *models.Channel) (*CustomerSessionVerifyResult, error) { + token := s.getCustomerSessionToken(ctx) + if token == "" { + return nil, errorsx.UnauthorizedI18n("error.e0157") + } + claims, err := s.verifyToken(token) + if err != nil { + return nil, err + } + if channel == nil || channel.Status != enums.StatusOk { + return nil, errorsx.InvalidParamI18n("error.e0209") + } + if claims.ChannelID != channel.ID || strings.TrimSpace(claims.ChannelCode) != strings.TrimSpace(channel.ChannelID) { + return nil, errorsx.UnauthorizedI18n("error.e0161") + } + customer := CustomerService.Get(claims.CustomerID) + if customer == nil || customer.Status == enums.StatusDeleted { + return nil, errorsx.UnauthorizedI18n("error.e0161") + } + external, err := s.externalUserFromClaims(claims, customer) + if err != nil { + return nil, err + } + result := &CustomerSessionVerifyResult{ + ExternalUser: external, + Token: token, + ExpiresAt: claims.ExpiresAt.Time, + } + if s.shouldRefresh(claims.ExpiresAt.Time) { + newToken, expiresAt, err := s.Sign(channel, customer, *external) + if err != nil { + return nil, err + } + result.Token = newToken + result.ExpiresAt = expiresAt + result.Refreshed = true + } + return result, nil +} + +func (s *customerSessionService) SetRefreshHeaders(ctx *gin.Context, result *CustomerSessionVerifyResult) { + if ctx == nil || result == nil || !result.Refreshed { + return + } + ctx.Header(customerSessionHeader, result.Token) + ctx.Header(customerSessionExpHeader, result.ExpiresAt.UTC().Format(time.RFC3339)) +} + +func (s *customerSessionService) verifyToken(rawToken string) (*customerSessionClaims, error) { + cfg := config.Current().CustomerSession + secret := strings.TrimSpace(cfg.Secret) + if secret == "" { + return nil, errorsx.BusinessErrorI18n(1, "error.customerSession.secretMissing") + } + claims := &customerSessionClaims{} + token, err := jwt.ParseWithClaims(rawToken, claims, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unsupported signing method") + } + return []byte(secret), nil + }, jwt.WithExpirationRequired(), jwt.WithValidMethods([]string{ + jwt.SigningMethodHS256.Alg(), + jwt.SigningMethodHS384.Alg(), + jwt.SigningMethodHS512.Alg(), + })) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, errorsx.UnauthorizedI18n("error.e0160") + } + return nil, errorsx.UnauthorizedI18n("error.e0161") + } + if token == nil || !token.Valid || claims.TokenType != customerSessionTokenType || claims.ExpiresAt == nil { + return nil, errorsx.UnauthorizedI18n("error.e0161") + } + if claims.ChannelID <= 0 || strings.TrimSpace(claims.ChannelCode) == "" || claims.CustomerID <= 0 || strings.TrimSpace(claims.IdentityKey) == "" { + return nil, errorsx.UnauthorizedI18n("error.e0161") + } + return claims, nil +} + +func (s *customerSessionService) externalUserFromClaims(claims *customerSessionClaims, customer *models.Customer) (*openidentity.ExternalUser, error) { + identityKey := strings.TrimSpace(claims.IdentityKey) + parts := strings.SplitN(identityKey, ":", 2) + if len(parts) != 2 || strings.TrimSpace(parts[1]) == "" { + return nil, errorsx.UnauthorizedI18n("error.e0161") + } + var source enums.ExternalSource + switch parts[0] { + case "user": + source = enums.ExternalSourceUser + case "guest": + source = enums.ExternalSourceGuest + default: + return nil, errorsx.UnauthorizedI18n("error.e0161") + } + identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), source, parts[1]) + if identity == nil || identity.CustomerID != claims.CustomerID { + return nil, errorsx.UnauthorizedI18n("error.e0161") + } + name := strings.TrimSpace(claims.CustomerName) + if customer != nil && strings.TrimSpace(customer.Name) != "" { + name = strings.TrimSpace(customer.Name) + } + return &openidentity.ExternalUser{ + ExternalSource: source, + ExternalID: parts[1], + ExternalName: name, + }, nil +} + +func (s *customerSessionService) shouldRefresh(expiresAt time.Time) bool { + threshold := config.Current().CustomerSession.RefreshThreshold() + return time.Until(expiresAt) <= time.Duration(threshold)*time.Minute +} + +func (s *customerSessionService) identityKey(externalUser openidentity.ExternalUser) string { + switch externalUser.ExternalSource { + case enums.ExternalSourceUser: + return "user:" + strings.TrimSpace(externalUser.ExternalID) + default: + return "guest:" + strings.TrimSpace(externalUser.ExternalID) + } +} + +func (s *customerSessionService) getCustomerSessionToken(ctx *gin.Context) string { + auth := strings.TrimSpace(ctx.GetHeader("Authorization")) + if len(auth) > 7 && strings.EqualFold(auth[:7], "Bearer ") { + if token := strings.TrimSpace(auth[7:]); token != "" { + return token + } + } + token, _ := params.Get(ctx, "customerSessionToken") + return strings.TrimSpace(token) +} diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go new file mode 100644 index 0000000..b4c8b89 --- /dev/null +++ b/internal/services/dashboard_service.go @@ -0,0 +1,519 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/i18nx" + "agent-desk/internal/repositories" + "fmt" + "sort" + "strings" + "time" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var DashboardService = newDashboardService() + +func newDashboardService() *dashboardService { + return &dashboardService{} +} + +type dashboardService struct { +} + +func (s *dashboardService) GetOverview(rangeValue string, locale string) response.DashboardOverviewResponse { + locale = i18nx.NormalizeLocale(locale) + now := time.Now() + normalizedRange, trendDays := normalizeDashboardRange(rangeValue) + todayStart := startOfDay(now) + trendStart := todayStart.AddDate(0, 0, -(trendDays - 1)) + db := sqls.DB() + + conversationTodayCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("created_at >= ?", todayStart) + }) + processingConversationCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("status IN ?", []enums.IMConversationStatus{ + enums.IMConversationStatusAIServing, + enums.IMConversationStatusActive, + }) + }) + pendingConversationCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("status = ?", enums.IMConversationStatusPending) + }) + + agentProfiles := repositories.DashboardRepository.ListEnabledAgentProfiles(db) + agentTeams := repositories.DashboardRepository.ListEnabledAgentTeams(db) + activeSchedules := repositories.DashboardRepository.ListActiveTeamSchedules(db, now, now) + activeConversations := repositories.DashboardRepository.ListConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("status IN ?", []enums.IMConversationStatus{ + enums.IMConversationStatusAIServing, + enums.IMConversationStatusPending, + enums.IMConversationStatusActive, + }) + }) + + onlineAgents, busyAgents, offlineAgents, teamLoads := s.buildAgentStats(now, agentTeams, agentProfiles, activeSchedules, activeConversations) + + enabledAIAgentCount := repositories.DashboardRepository.CountAIAgents(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("status = ?", enums.StatusOk) + }) + enabledChannelCount := repositories.DashboardRepository.CountChannels(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("status = ?", enums.StatusOk) + }) + knowledgeRetrieveCount := repositories.DashboardRepository.CountKnowledgeRetrieveLogs(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("created_at >= ?", todayStart) + }) + knowledgeRetrieveFailCount := repositories.DashboardRepository.CountKnowledgeRetrieveLogs(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("created_at >= ? AND answer_status IN ?", todayStart, []int{2, 3, 4}) + }) + agentRunFailCount := repositories.DashboardRepository.CountAgentRuns(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("created_at >= ? AND status = ?", todayStart, "failed") + }) + aiHandoffCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("handoff_at >= ?", todayStart) + }) + + enabledAIAgents := repositories.DashboardRepository.ListAIAgents(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("status = ?", enums.StatusOk) + }) + alerts := s.buildAlerts(now, db, enabledAIAgents, agentTeams, activeSchedules, locale) + + return response.DashboardOverviewResponse{ + Range: normalizedRange, + GeneratedAt: now.Format("2006-01-02 15:04:05"), + Summary: response.DashboardSummaryResponse{ + TodayNewConversations: conversationTodayCount, + ProcessingConversations: processingConversationCount, + PendingDispatchConversations: pendingConversationCount, + OnlineAgents: onlineAgents, + AIServiceRate: calcAIServiceRate(activeConversations), + }, + ConversationStats: response.DashboardSectionStatsResponse{ + StatusDistribution: buildConversationStatusDistribution(db, locale), + Trend: buildConversationTrend(db, trendStart), + }, + AgentStats: response.DashboardAgentStatsResponse{ + OnlineAgents: onlineAgents, + BusyAgents: busyAgents, + OfflineAgents: offlineAgents, + TeamLoads: teamLoads, + }, + AIStats: response.DashboardAIStatsResponse{ + EnabledAIAgents: enabledAIAgentCount, + EnabledChannels: enabledChannelCount, + TodayKnowledgeRetrieves: knowledgeRetrieveCount, + TodayKnowledgeRetrieveFailCount: knowledgeRetrieveFailCount, + TodayKnowledgeRetrieveFailRate: calcRate(knowledgeRetrieveFailCount, knowledgeRetrieveCount), + TodayAgentRunFailCount: agentRunFailCount, + TodayAIHandoffCount: aiHandoffCount, + }, + Alerts: alerts, + QuickLinks: buildDashboardQuickLinks(locale), + } +} + +func (s *dashboardService) buildAgentStats(now time.Time, teams []models.AgentTeam, profiles []models.AgentProfile, schedules []models.AgentTeamSchedule, conversations []models.Conversation) (int64, int64, int64, []response.DashboardTeamLoadResponse) { + const onlineWindow = 15 * time.Minute + + scheduledTeamIDs := make(map[int64]bool, len(schedules)) + for _, item := range schedules { + scheduledTeamIDs[item.TeamID] = true + } + + type teamCounter struct { + totalAgents int64 + onlineAgents int64 + busyAgents int64 + offlineAgents int64 + waitingConversations int64 + processingConversations int64 + maxConcurrentCapacity int64 + } + + teamCounters := make(map[int64]*teamCounter, len(teams)) + for _, team := range teams { + teamCounters[team.ID] = &teamCounter{} + } + + var onlineAgents int64 + var busyAgents int64 + var offlineAgents int64 + + for _, profile := range profiles { + counter := teamCounters[profile.TeamID] + if counter == nil { + counter = &teamCounter{} + teamCounters[profile.TeamID] = counter + } + counter.totalAgents++ + counter.maxConcurrentCapacity += int64(profile.MaxConcurrentCount) + if profile.LastOnlineAt != nil && now.Sub(*profile.LastOnlineAt) <= onlineWindow { + counter.onlineAgents++ + onlineAgents++ + if profile.ServiceStatus == enums.ServiceStatusBusy { + counter.busyAgents++ + busyAgents++ + } + continue + } + counter.offlineAgents++ + offlineAgents++ + } + + for _, item := range conversations { + if item.CurrentTeamID <= 0 { + continue + } + counter := teamCounters[item.CurrentTeamID] + if counter == nil { + counter = &teamCounter{} + teamCounters[item.CurrentTeamID] = counter + } + switch item.Status { + case enums.IMConversationStatusAIServing: + counter.processingConversations++ + case enums.IMConversationStatusPending: + counter.waitingConversations++ + case enums.IMConversationStatusActive: + counter.processingConversations++ + } + } + + teamLoads := make([]response.DashboardTeamLoadResponse, 0, len(teams)) + for _, team := range teams { + counter := teamCounters[team.ID] + if counter == nil { + counter = &teamCounter{} + } + teamLoads = append(teamLoads, response.DashboardTeamLoadResponse{ + TeamID: team.ID, + TeamName: team.Name, + TotalAgents: counter.totalAgents, + OnlineAgents: counter.onlineAgents, + BusyAgents: counter.busyAgents, + OfflineAgents: counter.offlineAgents, + WaitingConversations: counter.waitingConversations, + ProcessingConversations: counter.processingConversations, + MaxConcurrentCapacity: counter.maxConcurrentCapacity, + LoadRate: calcRate(counter.processingConversations, counter.maxConcurrentCapacity), + HasScheduleNow: scheduledTeamIDs[team.ID], + }) + } + + sort.Slice(teamLoads, func(i, j int) bool { + if teamLoads[i].WaitingConversations == teamLoads[j].WaitingConversations { + if teamLoads[i].LoadRate == teamLoads[j].LoadRate { + return teamLoads[i].TeamID < teamLoads[j].TeamID + } + return teamLoads[i].LoadRate > teamLoads[j].LoadRate + } + return teamLoads[i].WaitingConversations > teamLoads[j].WaitingConversations + }) + + return onlineAgents, busyAgents, offlineAgents, teamLoads +} + +func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []models.AIAgent, teams []models.AgentTeam, schedules []models.AgentTeamSchedule, locale string) []response.DashboardAlertResponse { + alerts := make([]response.DashboardAlertResponse, 0, 4) + pendingTimeout := now.Add(-10 * time.Minute) + activeTimeout := now.Add(-30 * time.Minute) + + pendingLongWaitCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("status = ? AND created_at < ?", enums.IMConversationStatusPending, pendingTimeout) + }) + if pendingLongWaitCount > 0 { + alerts = append(alerts, response.DashboardAlertResponse{ + ID: "pending-long-wait", + Level: "warning", + Title: dashboardText(locale, "alert.pendingLongWait.title"), + Description: dashboardText(locale, "alert.pendingLongWait.description"), + Count: pendingLongWaitCount, + Link: "/dashboard/conversations", + }) + } + + staleProcessingCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("status IN ? AND (last_message_at IS NULL OR last_message_at < ?)", []enums.IMConversationStatus{ + enums.IMConversationStatusAIServing, + enums.IMConversationStatusActive, + }, activeTimeout) + }) + if staleProcessingCount > 0 { + alerts = append(alerts, response.DashboardAlertResponse{ + ID: "stale-processing", + Level: "warning", + Title: dashboardText(locale, "alert.staleProcessing.title"), + Description: dashboardText(locale, "alert.staleProcessing.description"), + Count: staleProcessingCount, + Link: "/dashboard/conversations", + }) + } + + scheduledTeamIDs := make(map[int64]bool, len(schedules)) + for _, item := range schedules { + scheduledTeamIDs[item.TeamID] = true + } + var scheduleMissingCount int64 + for _, team := range teams { + if !scheduledTeamIDs[team.ID] { + scheduleMissingCount++ + } + } + if scheduleMissingCount > 0 { + alerts = append(alerts, response.DashboardAlertResponse{ + ID: "team-no-schedule", + Level: "info", + Title: dashboardText(locale, "alert.teamNoSchedule.title"), + Description: dashboardText(locale, "alert.teamNoSchedule.description"), + Count: scheduleMissingCount, + Link: "/dashboard/agent-team-schedules", + }) + } + + sort.Slice(alerts, func(i, j int) bool { + if alerts[i].Count == alerts[j].Count { + return alerts[i].ID < alerts[j].ID + } + return alerts[i].Count > alerts[j].Count + }) + + return alerts +} + +func buildConversationStatusDistribution(db *gorm.DB, locale string) []response.DashboardStatusDistributionItem { + ret := make([]response.DashboardStatusDistributionItem, 0, len(enums.IMConversationStatusValues)) + for _, status := range enums.IMConversationStatusValues { + ret = append(ret, response.DashboardStatusDistributionItem{ + Status: int(status), + Label: conversationStatusLabel(status, locale), + Count: repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("status = ?", status) + }), + }) + } + return ret +} + +func buildConversationTrend(db *gorm.DB, start time.Time) []response.DashboardTrendItem { + created := repositories.DashboardRepository.ListConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Select("created_at").Where("created_at >= ?", start) + }) + closed := repositories.DashboardRepository.ListConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Select("closed_at").Where("closed_at IS NOT NULL AND closed_at >= ?", start) + }) + return buildTrendItems(start, created, closed, func(item models.Conversation) *time.Time { + return &item.CreatedAt + }, func(item models.Conversation) *time.Time { + return item.ClosedAt + }) +} + +func buildTrendItems(start time.Time, created []models.Conversation, closed []models.Conversation, createdAt func(models.Conversation) *time.Time, closedAt func(models.Conversation) *time.Time) []response.DashboardTrendItem { + series := initTrendMap(start, time.Now()) + for _, item := range created { + if ts := createdAt(item); ts != nil { + series[ts.Format("2006-01-02")].NewCount++ + } + } + for _, item := range closed { + if ts := closedAt(item); ts != nil { + series[ts.Format("2006-01-02")].ClosedCount++ + } + } + return flattenTrendMap(series) +} + +func initTrendMap(start, end time.Time) map[string]*response.DashboardTrendItem { + series := make(map[string]*response.DashboardTrendItem) + for current := startOfDay(start); !current.After(end); current = current.AddDate(0, 0, 1) { + key := current.Format("2006-01-02") + series[key] = &response.DashboardTrendItem{Date: key} + } + return series +} + +func flattenTrendMap(series map[string]*response.DashboardTrendItem) []response.DashboardTrendItem { + keys := make([]string, 0, len(series)) + for key := range series { + keys = append(keys, key) + } + sort.Strings(keys) + ret := make([]response.DashboardTrendItem, 0, len(keys)) + for _, key := range keys { + ret = append(ret, *series[key]) + } + return ret +} + +func buildDashboardQuickLinks(locale string) []response.DashboardQuickLinkResponse { + return []response.DashboardQuickLinkResponse{ + {Title: dashboardText(locale, "quick.conversations.title"), Description: dashboardText(locale, "quick.conversations.description"), Link: "/dashboard/conversations"}, + {Title: dashboardText(locale, "quick.agents.title"), Description: dashboardText(locale, "quick.agents.description"), Link: "/dashboard/agents"}, + {Title: dashboardText(locale, "quick.knowledge.title"), Description: dashboardText(locale, "quick.knowledge.description"), Link: "/dashboard/knowledge"}, + {Title: dashboardText(locale, "quick.aiAgents.title"), Description: dashboardText(locale, "quick.aiAgents.description"), Link: "/dashboard/ai-agents"}, + {Title: dashboardText(locale, "quick.channels.title"), Description: dashboardText(locale, "quick.channels.description"), Link: "/dashboard/channels"}, + } +} + +func normalizeDashboardRange(value string) (string, int) { + switch value { + case "30d": + return "30d", 30 + case "today": + return "today", 1 + default: + return "7d", 7 + } +} + +func startOfDay(value time.Time) time.Time { + return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, value.Location()) +} + +func calcRate(numerator, denominator int64) float64 { + if denominator <= 0 { + return 0 + } + ratio := float64(numerator) / float64(denominator) * 100 + return float64(int(ratio*10+0.5)) / 10 +} + +func calcAIServiceRate(conversations []models.Conversation) float64 { + var aiCount int64 + var total int64 + for _, item := range conversations { + total++ + if item.ServiceMode == enums.IMConversationServiceModeAIOnly || item.ServiceMode == enums.IMConversationServiceModeAIFirst { + aiCount++ + } + } + return calcRate(aiCount, total) +} + +func labelOrDefault(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return value + } + return fallback +} + +func conversationStatusLabel(status enums.IMConversationStatus, locale string) string { + normalized := i18nx.NormalizeLocale(locale) + if normalized == i18nx.LocaleRuRU { + switch status { + case enums.IMConversationStatusAIServing: + return "Обрабатывает ИИ" + case enums.IMConversationStatusPending: + return "В очереди" + case enums.IMConversationStatusActive: + return "В работе" + case enums.IMConversationStatusClosed: + return "Закрыт" + default: + return fmt.Sprintf("Статус %d", status) + } + } + if normalized == i18nx.LocaleZhCN { + return labelOrDefault(enums.GetIMConversationStatusLabel(status), fmt.Sprintf("状态 %d", status)) + } + switch status { + case enums.IMConversationStatusAIServing: + return "AI active" + case enums.IMConversationStatusPending: + return "Queued" + case enums.IMConversationStatusActive: + return "In progress" + case enums.IMConversationStatusClosed: + return "Closed" + default: + return fmt.Sprintf("Status %d", status) + } +} + +func dashboardText(locale string, key string) string { + normalized := i18nx.NormalizeLocale(locale) + if normalized == i18nx.LocaleRuRU { + if value, ok := dashboardRuRU[key]; ok { + return value + } + } + if normalized == i18nx.LocaleZhCN { + if value, ok := dashboardZhCN[key]; ok { + return value + } + } + if value, ok := dashboardEnUS[key]; ok { + return value + } + if value, ok := dashboardZhCN[key]; ok { + return value + } + return key +} + +var dashboardRuRU = map[string]string{ + "alert.pendingLongWait.title": "Очередь диалогов переполнена", + "alert.pendingLongWait.description": "Некоторые диалоги ожидают назначения более 10 минут. Рекомендуется распределить обращения.", + "alert.staleProcessing.title": "Диалоги в работе требуют внимания", + "alert.staleProcessing.description": "В некоторых активных диалогах нет новых сообщений более 30 минут. Проверьте их статус.", + "alert.teamNoSchedule.title": "У команд операторов нет активного расписания", + "alert.teamNoSchedule.description": "У части активных команд операторов сейчас нет действующего расписания, что может повлиять на автоназначение.", + "alert.aiNoKnowledge.title": "ИИ-агенты не привязаны к базе знаний", + "alert.aiNoKnowledge.description": "Некоторые активные ИИ-агенты не привязаны к базе знаний, что может снизить качество ответов.", + "quick.conversations.title": "Диалоги", + "quick.conversations.description": "Просмотр очереди и активных диалогов", + "quick.agents.title": "Операторы", + "quick.agents.description": "Статус операторов и настройка команд", + "quick.knowledge.title": "База знаний", + "quick.knowledge.description": "Управление документами и журнал поиска", + "quick.aiAgents.title": "ИИ-агенты", + "quick.aiAgents.description": "Настройка стратегий приёма и базы знаний", + "quick.channels.title": "Каналы связи", + "quick.channels.description": "Управление каналами и агентами по умолчанию", +} + +var dashboardZhCN = map[string]string{ + "alert.pendingLongWait.title": "待接入会话堆积", + "alert.pendingLongWait.description": "存在超过 10 分钟仍未接入的会话,建议优先处理分配。", + "alert.staleProcessing.title": "处理中会话长时间无响应", + "alert.staleProcessing.description": "部分处理中会话已超过 30 分钟没有最新消息,需要确认跟进状态。", + "alert.teamNoSchedule.title": "客服组当前无生效排班", + "alert.teamNoSchedule.description": "部分启用中的客服组当前没有生效排班,可能影响自动分配。", + "alert.aiNoKnowledge.title": "AI Agent 未绑定知识库", + "alert.aiNoKnowledge.description": "部分启用中的 AI Agent 尚未绑定知识库,回答质量可能不稳定。", + "quick.conversations.title": "会话管理", + "quick.conversations.description": "查看待接入与处理中会话", + "quick.agents.title": "客服档案", + "quick.agents.description": "查看客服状态与分组配置", + "quick.knowledge.title": "知识库", + "quick.knowledge.description": "维护文档与查看检索日志", + "quick.aiAgents.title": "AI Agent", + "quick.aiAgents.description": "配置 AI 接待策略与知识绑定", + "quick.channels.title": "接入渠道", + "quick.channels.description": "管理接入渠道与默认 Agent", +} + +var dashboardEnUS = map[string]string{ + "alert.pendingLongWait.title": "Queued conversations are piling up", + "alert.pendingLongWait.description": "Some conversations have been waiting for more than 10 minutes. Prioritize assignment.", + "alert.staleProcessing.title": "Active conversations need attention", + "alert.staleProcessing.description": "Some active conversations have had no new messages for over 30 minutes. Check their follow-up status.", + "alert.teamNoSchedule.title": "Agent teams have no active schedule", + "alert.teamNoSchedule.description": "Some enabled agent teams do not have an active schedule right now, which may affect automatic assignment.", + "alert.aiNoKnowledge.title": "AI Agents are missing knowledge bases", + "alert.aiNoKnowledge.description": "Some enabled AI Agents are not linked to a knowledge base yet, which may reduce answer quality.", + "quick.conversations.title": "Conversations", + "quick.conversations.description": "Review queued and active conversations", + "quick.agents.title": "Agents", + "quick.agents.description": "Check agent status and team setup", + "quick.knowledge.title": "Knowledge base", + "quick.knowledge.description": "Manage documents and review retrieval logs", + "quick.aiAgents.title": "AI Agents", + "quick.aiAgents.description": "Configure AI service policies and knowledge bindings", + "quick.channels.title": "Channels", + "quick.channels.description": "Manage channels and default agents", +} diff --git a/internal/services/dashboard_service_test.go b/internal/services/dashboard_service_test.go new file mode 100644 index 0000000..22a0339 --- /dev/null +++ b/internal/services/dashboard_service_test.go @@ -0,0 +1,41 @@ +package services + +import ( + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/i18nx" + "testing" +) + +func TestDashboardTextUsesEnglishLocale(t *testing.T) { + t.Parallel() + + if got := dashboardText(i18nx.LocaleEnUS, "alert.pendingLongWait.title"); got != "Queued conversations are piling up" { + t.Fatalf("dashboardText(en-US) = %q", got) + } + if got := dashboardText(i18nx.LocaleRuRU, "alert.pendingLongWait.title"); got != "Очередь диалогов переполнена" { + t.Fatalf("dashboardText(ru-RU) = %q", got) + } + if got := dashboardText(i18nx.LocaleZhCN, "alert.pendingLongWait.title"); got != "待接入会话堆积" { + t.Fatalf("dashboardText(zh-CN) = %q", got) + } + if got := dashboardText("fr-FR", "alert.pendingLongWait.title"); got != "Queued conversations are piling up" { + t.Fatalf("dashboardText(fallback) = %q, want English fallback", got) + } +} + +func TestConversationStatusLabelUsesEnglishLocale(t *testing.T) { + t.Parallel() + + if got := conversationStatusLabel(enums.IMConversationStatusPending, i18nx.LocaleEnUS); got != "Queued" { + t.Fatalf("conversationStatusLabel(en-US) = %q", got) + } + if got := conversationStatusLabel(enums.IMConversationStatusPending, i18nx.LocaleRuRU); got != "В очереди" { + t.Fatalf("conversationStatusLabel(ru-RU) = %q", got) + } + if got := conversationStatusLabel(enums.IMConversationStatusPending, i18nx.LocaleZhCN); got != "待接入" { + t.Fatalf("conversationStatusLabel(zh-CN) = %q", got) + } + if got := conversationStatusLabel(enums.IMConversationStatusPending, "fr-FR"); got != "Queued" { + t.Fatalf("conversationStatusLabel(fallback) = %q, want English fallback", got) + } +} diff --git a/internal/services/discord_inbound_service.go b/internal/services/discord_inbound_service.go new file mode 100644 index 0000000..93f20eb --- /dev/null +++ b/internal/services/discord_inbound_service.go @@ -0,0 +1,176 @@ +package services + +import ( + "context" + "crypto/subtle" + "encoding/json" + "fmt" + "log/slog" + "strings" + + "agent-desk/internal/discord" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" +) + +var DiscordInboundService = newDiscordInboundService() + +func newDiscordInboundService() *discordInboundService { + return &discordInboundService{} +} + +type discordInboundService struct{} + +// HandleWebhook processes an incoming webhook or gateway payload from Discord. +func (s *discordInboundService) HandleWebhook(ctx context.Context, channelID string, secretHeader string, rawPayload []byte) error { + channelID = strings.TrimSpace(channelID) + var channel *models.Channel + if channelID != "" { + channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeDiscord, enums.StatusOk) + } + if channel == nil { + channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeDiscord, enums.StatusOk) + } + if channel == nil { + return errorsx.InvalidParam("discord channel not found or disabled") + } + + cfg, err := ChannelService.ParseDiscordChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil { + return errorsx.InvalidParam("discord channel config invalid") + } + + // Compared in constant time: a byte-wise != leaks how much of the prefix + // matched through response timing. + if cfg.WebhookSecret != "" && + subtle.ConstantTimeCompare([]byte(strings.TrimSpace(secretHeader)), []byte(cfg.WebhookSecret)) != 1 { + return errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + + var payload discord.WebhookPayload + if err := json.Unmarshal(rawPayload, &payload); err != nil { + return fmt.Errorf("unmarshal discord payload failed: %w", err) + } + + author := payload.Author + text := strings.TrimSpace(payload.Content) + msgID := payload.ID + targetChannelID := payload.ChannelID + guildID := payload.GuildID + attachments := payload.Attachments + embeds := payload.Embeds + + if payload.Message != nil { + if author == nil { + author = &payload.Message.Author + } + if text == "" { + text = strings.TrimSpace(payload.Message.Content) + } + if msgID == "" { + msgID = payload.Message.ID + } + if targetChannelID == "" { + targetChannelID = payload.Message.ChannelID + } + if guildID == "" { + guildID = payload.Message.GuildID + } + if len(attachments) == 0 && len(payload.Message.Attachments) > 0 { + attachments = payload.Message.Attachments + } + if len(embeds) == 0 && len(payload.Message.Embeds) > 0 { + embeds = payload.Message.Embeds + } + } + + if author == nil || author.Bot || strings.TrimSpace(author.ID) == "" { + return nil // Ignore bot messages or invalid authors + } + + // Honour the channel's guild scope. A bot can be invited to several servers, + // and without these checks GuildID and ChannelScope would be stored + // configuration that silently does nothing. + if cfg.GuildID != "" && guildID != cfg.GuildID { + slog.Debug("ignoring discord message from an out-of-scope guild", + "guild_id", guildID, + "channel", channel.ID, + ) + return nil + } + if cfg.ChannelScope == "dm_only" && guildID != "" { + slog.Debug("ignoring discord guild message, channel is dm_only", + "guild_id", guildID, + "channel", channel.ID, + ) + return nil + } + + if text == "" && len(attachments) > 0 { + firstAtt := attachments[0] + if firstAtt.Filename != "" { + text = fmt.Sprintf("[%s] %s", firstAtt.Filename, firstAtt.URL) + } else { + text = firstAtt.URL + } + } + + if text == "" && len(attachments) == 0 && len(embeds) == 0 { + return nil // Ignore empty messages + } + if text == "" && len(embeds) > 0 { + text = embeds[0].Description + if text == "" { + text = embeds[0].Title + } + } + + // 1. Resolve customer identity + externalID := author.ID + name := strings.TrimSpace(author.GlobalName) + if name == "" { + name = strings.TrimSpace(author.Username) + } + if name == "" { + name = fmt.Sprintf("Discord User %s", author.ID) + } + + externalUser := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceDiscord, + ExternalID: externalID, + ExternalName: name, + } + + // 2. Create or match Conversation + conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID) + if err != nil { + return fmt.Errorf("create discord conversation failed: %w", err) + } + + // 3. Send message through MessageService + clientMsgID := fmt.Sprintf("discord_%s_%s", targetChannelID, msgID) + payloadMap := map[string]any{ + "discord_message_id": msgID, + "discord_channel_id": targetChannelID, + "discord_guild_id": guildID, + "discord_user_id": author.ID, + "discord_attachments": attachments, + } + payloadBytes, _ := json.Marshal(payloadMap) + + _, err = MessageService.SendCustomerMessage( + conversation.ID, + clientMsgID, + enums.IMMessageTypeText, + text, + string(payloadBytes), + externalUser, + ) + if err != nil { + return fmt.Errorf("send customer message failed: %w", err) + } + + return nil +} diff --git a/internal/services/discord_inbound_service_test.go b/internal/services/discord_inbound_service_test.go new file mode 100644 index 0000000..b6c31b2 --- /dev/null +++ b/internal/services/discord_inbound_service_test.go @@ -0,0 +1,294 @@ +package services + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupDiscordTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate( + &models.Channel{}, + &models.ChannelMessageOutbox{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &models.CustomerContact{}, + &models.Conversation{}, + &models.ConversationParticipant{}, + &models.ConversationReadState{}, + &models.ConversationInterrupt{}, + &models.ConversationEventLog{}, + &models.Message{}, + &models.AIAgent{}, + &models.User{}, + &models.Role{}, + &models.UserRole{}, + &models.Permission{}, + &models.RolePermission{}, + &models.UserPermission{}, + ); err != nil { + t.Fatalf("migrate discord test tables: %v", err) + } + sqls.SetDB(db) + return db +} + +func TestDiscordInboundAndOutbound(t *testing.T) { + db := setupDiscordTestDB(t) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"out_msg_100","channel_id":"text_chan_1","content":"Agent reply"}`)) + })) + defer mockServer.Close() + + now := time.Now() + aiAgent := &models.AIAgent{ + Name: "Support AI", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(aiAgent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + + discordConfig := dto.DiscordChannelConfig{ + GuildID: "guild_12345", + GuildName: "Test Guild", + BotToken: "discord_bot_token", + WebhookSecret: "test_secret", + } + cfgBytes, _ := json.Marshal(discordConfig) + + channel := &models.Channel{ + ChannelType: enums.ChannelTypeDiscord, + ChannelID: "discord_ch_1", + AIAgentID: aiAgent.ID, + AIAgentRolloutPercent: 100, + Name: "Community Support", + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create discord channel: %v", err) + } + + payload := `{ + "id": "msg_999", + "channel_id": "text_chan_1", + "guild_id": "guild_12345", + "content": "", + "author": { + "id": "user_888", + "username": "gamer_joy", + "global_name": "Joy Le", + "bot": false + }, + "attachments": [ + { + "id": "att_1", + "filename": "screenshot.png", + "url": "https://cdn.discordapp.com/attachments/1/screenshot.png", + "content_type": "image/png", + "size": 10240 + } + ] + }` + + ctx := context.Background() + err := DiscordInboundService.HandleWebhook(ctx, channel.ChannelID, "test_secret", []byte(payload)) + if err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + + // Verify customer identity + identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceDiscord). + Eq("external_id", "user_888")) + if identity == nil { + t.Fatalf("expected customer identity to be created") + } + + // Verify conversation + conv := repositories.ConversationRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", identity.CustomerID). + Eq("channel_id", channel.ID)) + if conv == nil { + t.Fatalf("expected conversation to be created") + } + + // Verify image message created from attachment + custMsg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conv.ID). + Eq("sender_type", enums.IMSenderTypeCustomer)) + if custMsg == nil { + t.Fatalf("expected customer message to be created") + } + + operator := &dto.AuthPrincipal{UserID: 1, Nickname: "Agent Joy"} + + // Test Outbound enqueue with Message + replyMsg, err := MessageService.SendAIMessage(conv.ID, aiAgent.ID, "ai_msg_1", enums.IMMessageTypeText, "Here is your response image: https://example.com/response_img.png", "", operator) + if err != nil { + t.Fatalf("MessageService.SendAIMessage failed: %v", err) + } + + outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeDiscord, replyMsg.ID) + if outbox == nil { + t.Fatalf("expected outbox entry for discord message") + } + if outbox.SendStatus != string(enums.ChannelMessageOutboxStatusPending) && outbox.SendStatus != string(enums.ChannelMessageOutboxStatusSending) && outbox.SendStatus != string(enums.ChannelMessageOutboxStatusFailed) { + t.Fatalf("unexpected outbox status: %s", outbox.SendStatus) + } +} + +func seedDiscordScopedChannel(t *testing.T, db *gorm.DB, channelID string, cfg dto.DiscordChannelConfig) *models.Channel { + t.Helper() + now := time.Now() + agent := &models.AIAgent{ + Name: "Support AI", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(agent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + cfg.BotToken = "discord_bot_token" + cfg.WebhookSecret = "scope_secret" + cfgBytes, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal discord config: %v", err) + } + channel := &models.Channel{ + ChannelType: enums.ChannelTypeDiscord, + ChannelID: channelID, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + Name: "Discord " + channelID, + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create discord channel: %v", err) + } + return channel +} + +func discordScopedPayload(t *testing.T, guildID, messageID string) []byte { + t.Helper() + body := map[string]any{ + "id": messageID, + "channel_id": "discord_text_chan", + "content": "hello from discord", + "author": map[string]any{"id": "user_scope", "username": "scoped_user", "bot": false}, + } + if guildID != "" { + body["guild_id"] = guildID + } + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + return raw +} + +// A bot can be invited to several servers, and GuildID and ChannelScope are +// stored on the channel. Messages outside that scope must not create a +// conversation, otherwise the stored scope is configuration that does nothing. +func TestDiscordInboundHonoursGuildScope(t *testing.T) { + db := setupDiscordTestDB(t) + + guildScoped := seedDiscordScopedChannel(t, db, "discord_guild_scoped", dto.DiscordChannelConfig{ + GuildID: "guild_in_scope", + GuildName: "In Scope", + }) + dmOnly := seedDiscordScopedChannel(t, db, "discord_dm_only", dto.DiscordChannelConfig{ + ChannelScope: "dm_only", + }) + unscoped := seedDiscordScopedChannel(t, db, "discord_unscoped", dto.DiscordChannelConfig{}) + + cases := []struct { + name string + channel *models.Channel + guildID string + wantStored bool + }{ + {"matching guild is accepted", guildScoped, "guild_in_scope", true}, + {"another guild is ignored", guildScoped, "guild_elsewhere", false}, + {"a dm is ignored by a guild scoped channel", guildScoped, "", false}, + {"a dm is accepted by a dm_only channel", dmOnly, "", true}, + {"a guild message is ignored by a dm_only channel", dmOnly, "guild_anywhere", false}, + {"an unscoped channel accepts any guild", unscoped, "guild_anywhere", true}, + {"an unscoped channel accepts a dm", unscoped, "", true}, + } + + for _, tc := range cases { + messageID := "scope_" + strings.ReplaceAll(tc.name, " ", "_") + payload := discordScopedPayload(t, tc.guildID, messageID) + + if err := DiscordInboundService.HandleWebhook(context.Background(), tc.channel.ChannelID, "scope_secret", payload); err != nil { + t.Fatalf("%s: HandleWebhook failed: %v", tc.name, err) + } + + var count int64 + if err := db.Table("t_message").Where("client_msg_id LIKE ?", "%"+messageID).Count(&count).Error; err != nil { + t.Fatalf("%s: count messages: %v", tc.name, err) + } + switch { + case tc.wantStored && count == 0: + t.Errorf("%s: expected the message to be stored", tc.name) + case !tc.wantStored && count != 0: + t.Errorf("%s: expected the message to be dropped, found %d", tc.name, count) + } + } +} + +// The webhook secret is compared in constant time, so a wrong secret of the same +// length must be rejected rather than accepted by a prefix match. +func TestDiscordInboundRejectsWrongWebhookSecret(t *testing.T) { + db := setupDiscordTestDB(t) + channel := seedDiscordScopedChannel(t, db, "discord_secret", dto.DiscordChannelConfig{}) + + payload := discordScopedPayload(t, "", "secret_msg_1") + err := DiscordInboundService.HandleWebhook(context.Background(), channel.ChannelID, "wrong_secret_value", payload) + if err == nil { + t.Fatalf("expected a wrong webhook secret to be rejected") + } + + var count int64 + if err := db.Table("t_message").Where("client_msg_id LIKE ?", "%secret_msg_1").Count(&count).Error; err != nil { + t.Fatalf("count messages: %v", err) + } + if count != 0 { + t.Fatalf("a rejected delivery stored %d messages", count) + } +} diff --git a/internal/services/discord_integration_test.go b/internal/services/discord_integration_test.go new file mode 100644 index 0000000..e470bd2 --- /dev/null +++ b/internal/services/discord_integration_test.go @@ -0,0 +1,169 @@ +package services + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupDiscordIntegrationTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate( + &models.Channel{}, + &models.ChannelMessageOutbox{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &models.CustomerContact{}, + &models.Conversation{}, + &models.ConversationParticipant{}, + &models.ConversationReadState{}, + &models.ConversationInterrupt{}, + &models.ConversationEventLog{}, + &models.Message{}, + &models.AIAgent{}, + &models.AgentProfile{}, + &models.AgentTeam{}, + &models.User{}, + &models.Role{}, + &models.UserRole{}, + &models.Permission{}, + &models.RolePermission{}, + &models.UserPermission{}, + ); err != nil { + t.Fatalf("migrate discord integration test tables: %v", err) + } + sqls.SetDB(db) + return db +} + +func TestDiscordIntegrationFullFlow(t *testing.T) { + db := setupDiscordIntegrationTestDB(t) + + mockDiscordServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"discord_msg_reply_999","channel_id":"ch_discord_general","content":"Cảm ơn bạn! Đội ngũ hỗ trợ sẽ kiểm tra ngay."}`)) + })) + defer mockDiscordServer.Close() + + now := time.Now() + // 1. Create AI Agent + agent := &models.AIAgent{ + Name: "Discord Support AI", + ServiceMode: enums.IMConversationServiceModeAIFirst, + PublishedRevisionID: 1, + WelcomeMessage: "Chào mừng đến với máy chủ Discord Crove Desk!", + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + } + _ = db.Create(agent) + + // 2. Create Discord Channel + discordConfig, _ := json.Marshal(dto.DiscordChannelConfig{ + GuildID: "guild_987654321", + GuildName: "Crove Community Discord", + BotToken: "test-discord-bot-token-xyz", + WebhookSecret: "discord-secret-token-123", + WelcomeMessage: "Welcome to Discord Support!", + }) + + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + Name: "Crove Discord Support", + ChannelType: enums.ChannelTypeDiscord, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: string(discordConfig), + Status: int(enums.StatusOk), + }, operator) + if err != nil { + t.Fatalf("CreateChannel failed: %v", err) + } + + // 3. Simulate Inbound Discord Webhook / Gateway message from user + inboundPayload := []byte(`{ + "id": "msg_discord_user_001", + "channel_id": "ch_discord_general", + "guild_id": "guild_987654321", + "content": "Tôi muốn hỏi về cách cấu hình Custom Domain cho Email Channel trên Crove Desk", + "author": { + "id": "discord_uid_555", + "username": "gamer_joy", + "global_name": "Anh Le", + "bot": false + } + }`) + + ctx := context.Background() + err = DiscordInboundService.HandleWebhook(ctx, channel.ChannelID, "discord-secret-token-123", inboundPayload) + if err != nil { + t.Fatalf("DiscordInboundService.HandleWebhook failed: %v", err) + } + + // Verify Customer Identity + identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceDiscord). + Eq("external_id", "discord_uid_555")) + if identity == nil { + t.Fatalf("expected customer identity for discord_uid_555") + } + + customer := repositories.CustomerRepository.Get(db, identity.CustomerID) + if customer == nil || customer.Name != "Anh Le" { + t.Fatalf("unexpected customer profile: %+v", customer) + } + + // Verify Conversation created + conv := repositories.ConversationRepository.FindOne(db, sqls.NewCnd().Eq("customer_id", customer.ID)) + if conv == nil || conv.ChannelID != channel.ID { + t.Fatalf("unexpected conversation: %+v", conv) + } + + // Verify Customer Message stored + msg := repositories.MessageRepository.FindOne(db, sqls.NewCnd(). + Eq("conversation_id", conv.ID). + Eq("sender_type", enums.IMSenderTypeCustomer)) + if msg == nil || msg.Content != "Tôi muốn hỏi về cách cấu hình Custom Domain cho Email Channel trên Crove Desk" { + t.Fatalf("unexpected stored customer message: %+v", msg) + } + + // 4. Simulate Agent / AI Reply and test Outbox Enqueue & Outbound Dispatch + replyMsg, err := MessageService.SendAIMessage(conv.ID, agent.ID, "ai_reply_001", enums.IMMessageTypeText, "Cảm ơn bạn! Đội ngũ hỗ trợ sẽ kiểm tra ngay.", "", operator) + if err != nil { + t.Fatalf("MessageService.SendAIMessage failed: %v", err) + } + + outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeDiscord, replyMsg.ID) + if outbox == nil { + t.Fatalf("expected discord outbox entry for AI message") + } + if outbox.ChannelType != enums.ChannelTypeDiscord { + t.Fatalf("expected outbox channel type 'discord', got '%s'", outbox.ChannelType) + } +} diff --git a/internal/services/discord_outbound_service.go b/internal/services/discord_outbound_service.go new file mode 100644 index 0000000..2024f8a --- /dev/null +++ b/internal/services/discord_outbound_service.go @@ -0,0 +1,231 @@ +package services + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "time" + + "agent-desk/internal/discord" + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/services/storage" + "os" + + "github.com/mlogclub/simple/sqls" +) + +const ( + discordOutboxBatchSize = 20 + discordOutboxMaxRetry = 5 +) + +var DiscordOutboundService = newDiscordOutboundService() + +func newDiscordOutboundService() *discordOutboundService { + return &discordOutboundService{} +} + +type discordOutboundService struct{} + +func (s *discordOutboundService) DispatchPendingOutbox() int { + return s.doDispatchPendingOutbox(discordOutboxBatchSize) +} + +func (s *discordOutboundService) doDispatchPendingOutbox(limit int) int { + if limit <= 0 { + limit = discordOutboxBatchSize + } + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeDiscord, limit) + if len(items) == 0 { + return 0 + } + + successCount := 0 + for i := range items { + if err := s.processOutbox(items[i].ID); err != nil { + slog.Warn("process discord outbox failed", + "outbox_id", items[i].ID, + "error", err, + ) + continue + } + successCount++ + } + return successCount +} + +func (s *discordOutboundService) processOutbox(outboxID int64) error { + outbox := ChannelMessageOutboxService.Get(outboxID) + if outbox == nil { + return nil + } + if outbox.ChannelType != enums.ChannelTypeDiscord { + return nil + } + if outbox.SendStatus == string(enums.ChannelMessageOutboxStatusSent) { + return nil + } + if outbox.NextRetryAt != nil && outbox.NextRetryAt.After(time.Now()) { + return nil + } + + if err := ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSending), + "updated_at": time.Now(), + }); err != nil { + return err + } + + message := MessageService.Get(outbox.MessageID) + if message == nil { + return s.markOutboxFailed(outbox, "message not found") + } + conversation := ConversationService.Get(outbox.ConversationID) + if conversation == nil { + return s.markOutboxFailed(outbox, "conversation not found") + } + + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.Status != enums.StatusOk { + return s.markOutboxFailed(outbox, "discord channel not found or disabled") + } + cfg, err := ChannelService.ParseDiscordChannelConfig(channel.ConfigJSON) + if err != nil { + return s.markOutboxFailed(outbox, "invalid discord channel config") + } + botToken := "" + if cfg != nil { + botToken = strings.TrimSpace(cfg.BotToken) + } + if botToken == "" { + botToken = strings.TrimSpace(config.Current().Discord.BotToken) + } + if botToken == "" { + botToken = strings.TrimSpace(os.Getenv("DISCORD_BOT_TOKEN")) + } + if botToken == "" { + return s.markOutboxFailed(outbox, "discord bot token not configured") + } + + // Resolve target Discord User ID and/or Channel ID + var discordUserID string + customerIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", conversation.CustomerID). + Eq("external_source", enums.ExternalSourceDiscord)) + if customerIdentity != nil { + discordUserID = strings.TrimSpace(customerIdentity.ExternalID) + } + + // Check if there is a discord_channel_id in last message payload + var targetChannelID string + lastCustomerMsg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conversation.ID). + Eq("sender_type", enums.IMSenderTypeCustomer). + Desc("id")) + if lastCustomerMsg != nil && lastCustomerMsg.Payload != "" { + var payloadMap map[string]any + if err := json.Unmarshal([]byte(lastCustomerMsg.Payload), &payloadMap); err == nil { + if chID, ok := payloadMap["discord_channel_id"].(string); ok && chID != "" { + targetChannelID = chID + } + } + } + + client := discord.NewClient(botToken) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if targetChannelID == "" { + if discordUserID == "" { + return s.markOutboxFailed(outbox, "unable to resolve discord target user or channel") + } + dmChannel, err := client.CreateDMChannel(ctx, discordUserID) + if err != nil { + return s.markOutboxFailed(outbox, "create discord dm channel failed: "+err.Error()) + } + targetChannelID = dmChannel.ID + } + + var sendErr error + if message.MessageType == enums.IMMessageTypeImage { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + var imageURL string + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + imageURL = provider.GetSignedURL(assetPayload.StorageKey) + } + } + } + if imageURL == "" && strings.HasPrefix(strings.TrimSpace(message.Content), "http") { + imageURL = strings.TrimSpace(message.Content) + } + + if imageURL != "" { + embed := discord.Embed{ + Title: "Image Attachment", + Image: &discord.EmbedMedia{URL: imageURL}, + } + _, sendErr = client.SendEmbedMessage(ctx, targetChannelID, message.Content, []discord.Embed{embed}) + } else { + _, sendErr = client.SendMessage(ctx, targetChannelID, message.Content) + } + } else if message.MessageType == enums.IMMessageTypeAttachment { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + var fileURL string + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + fileURL = provider.GetSignedURL(assetPayload.StorageKey) + } + } + } + textToSend := message.Content + if fileURL != "" { + if textToSend != "" { + textToSend += "\n" + fileURL + } else { + textToSend = fileURL + } + } + _, sendErr = client.SendMessage(ctx, targetChannelID, textToSend) + } else { + _, sendErr = client.SendMessage(ctx, targetChannelID, message.Content) + } + + if sendErr != nil { + return s.markOutboxFailed(outbox, sendErr.Error()) + } + + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSent), + "sent_at": time.Now(), + "updated_at": time.Now(), + }) +} + +func (s *discordOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error { + if outbox == nil { + return nil + } + retryCount := outbox.RetryCount + 1 + status := string(enums.ChannelMessageOutboxStatusFailed) + if retryCount >= discordOutboxMaxRetry { + status = string(enums.ChannelMessageOutboxStatusIgnored) + } + nextRetryAt := time.Now().Add(time.Duration(retryCount*30) * time.Second) + + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": status, + "retry_count": retryCount, + "next_retry_at": &nextRetryAt, + "last_error": errMsg, + "updated_at": time.Now(), + }) +} diff --git a/internal/services/email_delivery_service.go b/internal/services/email_delivery_service.go new file mode 100644 index 0000000..771323e --- /dev/null +++ b/internal/services/email_delivery_service.go @@ -0,0 +1,71 @@ +package services + +import ( + "context" + "strings" + + "agent-desk/internal/email" + "agent-desk/internal/pkg/errorsx" +) + +var EmailDeliveryService = newEmailDeliveryService() + +func newEmailDeliveryService() *emailDeliveryService { + return &emailDeliveryService{} +} + +type emailDeliveryService struct{} + +// TestChannelSMTP verifies that a channel's bound mail server is reachable and +// will accept the channel's credentials. +// +// It exists so binding a channel to an SMTP server is a checked action rather +// than a guess. Without it the only way to learn that a host, port or password +// is wrong is to file a real request and watch the outbox fail five times. +// +// The probe goes through the same settings resolution and the same transport +// rules as the sender, so a green probe means the real send would get the same +// treatment. +func (s *emailDeliveryService) TestChannelSMTP(ctx context.Context, channelID int64) (*email.SMTPProbeResult, error) { + channel := ChannelService.Get(channelID) + if channel == nil { + return nil, errorsx.InvalidParamI18n("error.email.channelNotFound") + } + channelCfg, err := emailChannelSettings(channel) + if err != nil { + return nil, err + } + + settings := resolveEmailDeliverySettings(channelCfg) + if settings.Provider != email.ProviderSMTP { + return nil, errorsx.InvalidParamI18n("error.email.probeRequiresSmtp") + } + if strings.TrimSpace(settings.SMTPHost) == "" { + return nil, errorsx.InvalidParamI18n("error.email.smtpHostMissing") + } + + result, err := email.ProbeSMTP(ctx, settings.clientConfig()) + if err != nil { + // The transport detail is the actionable part for whoever bound the + // server, so it is surfaced rather than flattened into a generic failure. + return result, errorsx.BusinessErrorI18n(0, "error.email.probeFailed", err.Error()) + } + return result, nil +} + +// TestSystemSMTP probes the deployment-wide mail server, which is what a channel +// with no host of its own will actually use. +func (s *emailDeliveryService) TestSystemSMTP(ctx context.Context) (*email.SMTPProbeResult, error) { + settings := resolveEmailDeliverySettings(nil) + if settings.Provider != email.ProviderSMTP { + return nil, errorsx.InvalidParamI18n("error.email.probeRequiresSmtp") + } + if strings.TrimSpace(settings.SMTPHost) == "" { + return nil, errorsx.InvalidParamI18n("error.email.smtpHostMissing") + } + result, err := email.ProbeSMTP(ctx, settings.clientConfig()) + if err != nil { + return result, errorsx.BusinessErrorI18n(0, "error.email.probeFailed", err.Error()) + } + return result, nil +} diff --git a/internal/services/email_delivery_settings.go b/internal/services/email_delivery_settings.go new file mode 100644 index 0000000..7c36218 --- /dev/null +++ b/internal/services/email_delivery_settings.go @@ -0,0 +1,125 @@ +package services + +import ( + "strings" + + "agent-desk/internal/email" + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" +) + +// emailDeliverySettings is the fully resolved delivery configuration for one +// channel: the channel's own values with the system defaults filled in behind +// anything the channel left unset. +type emailDeliverySettings struct { + Provider email.DeliveryProvider + APIKey string + SMTPHost string + SMTPPort int + SMTPUser string + SMTPPassword string + SMTPUseTLS bool + // AllowInsecure is true when the channel or the deployment is bound to a + // self-signed test server. + AllowInsecure bool + FromEmail string + FromName string + ReplyTo string +} + +// resolveEmailDeliverySettings is the single place channel and system email +// settings are merged. Sending and the connection probe both go through it, so a +// binding can never verify as reachable while delivering through something else. +func resolveEmailDeliverySettings(channelCfg *dto.EmailChannelConfig) emailDeliverySettings { + var sysEmail config.EmailConfig + if current := config.GetCurrent(); current != nil { + sysEmail = current.Email + } + if channelCfg == nil { + channelCfg = &dto.EmailChannelConfig{} + } + + provider := email.DeliveryProvider(strings.ToLower(strings.TrimSpace(channelCfg.Provider))) + if provider == "" || provider == "default" { + provider = email.DeliveryProvider(strings.ToLower(strings.TrimSpace(sysEmail.Provider))) + } + if provider == "" { + provider = email.ProviderSMTP + } + + settings := emailDeliverySettings{ + Provider: provider, + APIKey: firstNonBlank(channelCfg.APIKey, sysEmail.APIKey), + + SMTPHost: firstNonBlank(channelCfg.SMTPHost, sysEmail.SMTPHost), + SMTPPort: channelCfg.SMTPPort, + SMTPUser: firstNonBlank(channelCfg.SMTPUser, sysEmail.SMTPUser), + SMTPPassword: firstNonBlank(channelCfg.SMTPPassword, sysEmail.SMTPPassword), + // An explicit per-channel value wins; an absent one inherits the system + // setting. The pointer is what makes "explicitly off" expressible, which a + // channel bound to a plaintext test server needs. + SMTPUseTLS: sysEmail.SMTPUseTLS, + AllowInsecure: channelCfg.SMTPAllowInsecure || sysEmail.SMTPAllowInsecure, + + FromEmail: firstNonBlank(channelCfg.EmailAddress, sysEmail.FromAddress), + FromName: firstNonBlank(channelCfg.SenderName, sysEmail.FromName, "Customer Support"), + // Point replies at the channel's own inbound address rather than the + // mailbox the sending agent happens to authenticate with. Without this a + // reply lands in that person's inbox instead of back in AgentDesk, and the + // thread stops being a support channel after the first answer. + ReplyTo: firstNonBlank(channelCfg.EmailAddress, channelCfg.ForwardingAddress), + } + if channelCfg.SMTPUseTLS != nil { + settings.SMTPUseTLS = *channelCfg.SMTPUseTLS + } + if settings.SMTPPort <= 0 { + settings.SMTPPort = sysEmail.SMTPPort + } + if settings.SMTPPort <= 0 { + settings.SMTPPort = 587 + } + if settings.FromEmail == "" { + settings.FromEmail = "support@example.com" + } + return settings +} + +func (s emailDeliverySettings) clientConfig() email.ClientConfig { + return email.ClientConfig{ + Provider: s.Provider, + APIKey: s.APIKey, + SMTPHost: s.SMTPHost, + SMTPPort: s.SMTPPort, + SMTPUser: s.SMTPUser, + SMTPPassword: s.SMTPPassword, + SMTPUseTLS: s.SMTPUseTLS, + SMTPAllowInsecure: s.AllowInsecure, + } +} + +// emailChannelSettings loads a channel's parsed email config. +func emailChannelSettings(channel *models.Channel) (*dto.EmailChannelConfig, error) { + if channel == nil { + return nil, errorsx.InvalidParamI18n("error.email.channelNotFound") + } + if channel.ChannelType != enums.ChannelTypeEmail || channel.Status != enums.StatusOk { + return nil, errorsx.InvalidParamI18n("error.email.channelNotFound") + } + cfg, err := ChannelService.ParseEmailChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil { + return nil, errorsx.InvalidParamI18n("error.email.channelConfigInvalid") + } + return cfg, nil +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/internal/services/email_delivery_settings_test.go b/internal/services/email_delivery_settings_test.go new file mode 100644 index 0000000..1abaee6 --- /dev/null +++ b/internal/services/email_delivery_settings_test.go @@ -0,0 +1,144 @@ +package services + +import ( + "testing" + "time" + + "agent-desk/internal/email" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" +) + +func withEmailConfig(t *testing.T, system config.EmailConfig) { + t.Helper() + previous := config.GetCurrent() + config.SetCurrent(&config.Config{Email: system}) + t.Cleanup(func() { config.SetCurrent(previous) }) +} + +func boolRef(value bool) *bool { return &value } + +func TestResolveEmailDeliverySettingsInheritsSystemDefaults(t *testing.T) { + withEmailConfig(t, config.EmailConfig{ + SMTPHost: "smtp.system.example", + SMTPPort: 2525, + SMTPUser: "system-user", + SMTPPassword: "system-pass", + SMTPUseTLS: true, + FromAddress: "system@example.com", + FromName: "System Support", + }) + + settings := resolveEmailDeliverySettings(&dto.EmailChannelConfig{}) + + if settings.SMTPHost != "smtp.system.example" || settings.SMTPPort != 2525 { + t.Fatalf("smtp endpoint = %s:%d, want the system values", settings.SMTPHost, settings.SMTPPort) + } + if settings.SMTPUser != "system-user" || settings.SMTPPassword != "system-pass" { + t.Fatal("channel with no credentials should inherit the system ones") + } + if !settings.SMTPUseTLS { + t.Fatal("an unset channel TLS flag should inherit the system value") + } + if settings.FromEmail != "system@example.com" || settings.FromName != "System Support" { + t.Fatalf("sender = %s / %s, want the system values", settings.FromEmail, settings.FromName) + } +} + +// The whole reason the flag is a pointer: an explicitly unchecked box must be +// able to turn TLS off for one channel without changing it for everyone else. +func TestResolveEmailDeliverySettingsHonoursExplicitTLSOff(t *testing.T) { + withEmailConfig(t, config.EmailConfig{SMTPHost: "smtp.system.example", SMTPUseTLS: true}) + + settings := resolveEmailDeliverySettings(&dto.EmailChannelConfig{SMTPUseTLS: boolRef(false)}) + + if settings.SMTPUseTLS { + t.Fatal("an explicit smtpUseTls=false must win over the system value") + } +} + +func TestResolveEmailDeliverySettingsPrefersChannelValues(t *testing.T) { + withEmailConfig(t, config.EmailConfig{ + SMTPHost: "smtp.system.example", + SMTPPort: 587, + FromAddress: "system@example.com", + }) + + settings := resolveEmailDeliverySettings(&dto.EmailChannelConfig{ + SMTPHost: "smtp.channel.example", + SMTPPort: 1025, + EmailAddress: "help@channel.example", + SenderName: "Channel Support", + SMTPUseTLS: boolRef(true), + SMTPAllowInsecure: true, + }) + + if settings.SMTPHost != "smtp.channel.example" || settings.SMTPPort != 1025 { + t.Fatalf("smtp endpoint = %s:%d, want the channel values", settings.SMTPHost, settings.SMTPPort) + } + if !settings.SMTPUseTLS || !settings.AllowInsecure { + t.Fatal("channel TLS and insecure flags should both carry through") + } + if settings.FromEmail != "help@channel.example" || settings.FromName != "Channel Support" { + t.Fatalf("sender = %s / %s, want the channel values", settings.FromEmail, settings.FromName) + } + // Reply-To has to be the channel's own inbound address, or a reply lands in + // the sending agent's mailbox and the thread ends. + if settings.ReplyTo != "help@channel.example" { + t.Fatalf("ReplyTo = %q, want the channel inbound address", settings.ReplyTo) + } +} + +func TestResolveEmailDeliverySettingsReplyToFallsBackToForwardingAddress(t *testing.T) { + withEmailConfig(t, config.EmailConfig{FromAddress: "system@example.com"}) + + settings := resolveEmailDeliverySettings(&dto.EmailChannelConfig{ + ForwardingAddress: "forward@channel.example", + }) + + if settings.ReplyTo != "forward@channel.example" { + t.Fatalf("ReplyTo = %q, want the forwarding address", settings.ReplyTo) + } +} + +func TestResolveEmailDeliverySettingsDefaultsPortAndSender(t *testing.T) { + withEmailConfig(t, config.EmailConfig{}) + + settings := resolveEmailDeliverySettings(&dto.EmailChannelConfig{}) + + if settings.SMTPPort != 587 { + t.Fatalf("SMTPPort = %d, want the conventional submission default", settings.SMTPPort) + } + if settings.Provider != email.ProviderSMTP { + t.Fatalf("Provider = %q, want smtp", settings.Provider) + } + if settings.FromEmail != "support@example.com" || settings.FromName != "Customer Support" { + t.Fatalf("sender = %s / %s, want usable fallbacks", settings.FromEmail, settings.FromName) + } +} + +func TestResolveEmailDeliverySettingsAllowInsecureIsEitherSide(t *testing.T) { + withEmailConfig(t, config.EmailConfig{SMTPAllowInsecure: true}) + if !resolveEmailDeliverySettings(&dto.EmailChannelConfig{}).AllowInsecure { + t.Fatal("a system-wide insecure setting should apply to a channel that sets nothing") + } + + withEmailConfig(t, config.EmailConfig{}) + if !resolveEmailDeliverySettings(&dto.EmailChannelConfig{SMTPAllowInsecure: true}).AllowInsecure { + t.Fatal("a channel should be able to opt in on its own") + } +} + +func TestEmailChatLiveWindowDefaultsToThirtyMinutes(t *testing.T) { + previous := config.GetCurrent() + t.Cleanup(func() { config.SetCurrent(previous) }) + + config.SetCurrent(&config.Config{}) + if got := (config.ConversationConfig{}).EmailChatLiveWindow(); got != 30*time.Minute { + t.Fatalf("default window = %s, want 30m", got) + } + config.SetCurrent(&config.Config{Conversation: config.ConversationConfig{EmailChatLiveMinutes: 5}}) + if got := (config.ConversationConfig{EmailChatLiveMinutes: 5}).EmailChatLiveWindow(); got != 5*time.Minute { + t.Fatalf("configured window = %s, want 5m", got) + } +} diff --git a/internal/services/email_inbound_service.go b/internal/services/email_inbound_service.go new file mode 100644 index 0000000..658f303 --- /dev/null +++ b/internal/services/email_inbound_service.go @@ -0,0 +1,243 @@ +package services + +import ( + "context" + "crypto/subtle" + "encoding/json" + "fmt" + "log/slog" + "net/url" + "regexp" + "strconv" + "strings" + + "agent-desk/internal/email" + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/common/strs" + "github.com/mlogclub/simple/sqls" +) + +var ( + EmailInboundService = newEmailInboundService() + ticketIDRegex = regexp.MustCompile(`(?i)(?:\[(?:#|Ticket\s*#?)|(?:#|Ticket\s*#))\s*(\d+)`) +) + +func newEmailInboundService() *emailInboundService { + return &emailInboundService{} +} + +type emailInboundService struct{} + +// HandleWebhook processes an incoming email webhook from any supported provider (Cloudflare, Brevo, SendGrid, Postmark, Mailgun, Resend). +func (s *emailInboundService) HandleWebhook(ctx context.Context, channelID string, secretHeader string, contentType string, rawPayload []byte, form url.Values) error { + channelID = strings.TrimSpace(channelID) + var channel *models.Channel + if channelID != "" { + channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeEmail, enums.StatusOk) + } + + // 1. Parse inbound email items + inboundItems, err := email.ParseInboundWebhook(contentType, rawPayload, form) + if err != nil { + return fmt.Errorf("parse email webhook failed: %w", err) + } + if len(inboundItems) == 0 { + return nil + } + + systemSecret := "" + if cfg := config.GetCurrent(); cfg != nil { + systemSecret = cfg.Email.InboundSecret + } + + for _, item := range inboundItems { + targetChannel := channel + if targetChannel == nil { + targetChannel = ChannelService.GetEnabledEmailChannelByAddress(item.ToEmail) + } + if targetChannel == nil { + targetChannel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeEmail, enums.StatusOk) + } + if targetChannel == nil { + slog.Warn("no active email channel found for recipient", "to", item.ToEmail) + return errorsx.InvalidParam("email channel not found or disabled") + } + + cfg, err := ChannelService.ParseEmailChannelConfig(targetChannel.ConfigJSON) + if err != nil || cfg == nil { + return errorsx.InvalidParam("email channel config invalid") + } + + expectedSecret := cfg.WebhookSecret + if expectedSecret == "" { + expectedSecret = systemSecret + } + + // Constant-time so the comparison cannot be measured to recover the secret + // one byte at a time. Discord and Slack already do this; email did not. + if expectedSecret != "" && subtle.ConstantTimeCompare( + []byte(strings.TrimSpace(secretHeader)), []byte(expectedSecret), + ) != 1 { + return errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + + if err := s.processInboundItem(ctx, targetChannel, item); err != nil { + slog.Error("process inbound email item failed", "from", item.FromEmail, "to", item.ToEmail, "error", err) + return err + } + } + + return nil +} + +func (s *emailInboundService) processInboundItem(ctx context.Context, channel *models.Channel, item email.InboundEmailPayload) error { + fromEmail := strings.ToLower(strings.TrimSpace(item.FromEmail)) + if fromEmail == "" { + return nil + } + fromName := strings.TrimSpace(item.FromName) + if fromName == "" { + parts := strings.Split(fromEmail, "@") + fromName = parts[0] + } + + bodyText := strings.TrimSpace(item.BodyText) + if bodyText == "" && item.BodyHTML != "" { + bodyText = stripHTMLTags(item.BodyHTML) + } + if bodyText == "" { + bodyText = "(Empty email body)" + } + + // Format content with subject if provided + content := bodyText + if item.Subject != "" { + content = fmt.Sprintf("[%s]\n\n%s", item.Subject, bodyText) + } + + // 1. Resolve customer identity + externalUser := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceEmail, + ExternalID: fromEmail, + ExternalName: fromName, + } + + // 2. Threading Resolution: Find existing conversation by Ticket ID or In-Reply-To header + var conversation *models.Conversation + ticketID := s.extractTicketID(item.Subject) + if ticketID > 0 { + existing := ConversationService.Get(ticketID) + if existing != nil && existing.Status != enums.IMConversationStatusClosed { + conversation = existing + } + } + + if conversation == nil && item.InReplyTo != "" { + conversation = s.findConversationByInReplyTo(item.InReplyTo) + } + + // If no existing thread matched, create or match via standard ConversationService + if conversation == nil { + var err error + conversation, err = ConversationService.Create(externalUser, channel.ID, channel.AIAgentID) + if err != nil { + return fmt.Errorf("create email conversation failed: %w", err) + } + } + + // Ensure customer primary_email is populated + if conversation.CustomerID > 0 { + customer := repositories.CustomerRepository.Get(sqls.DB(), conversation.CustomerID) + if customer != nil && customer.PrimaryEmail == "" { + _ = repositories.CustomerRepository.UpdateColumn(sqls.DB(), customer.ID, "primary_email", fromEmail) + } + } + + // 3. Send message through MessageService (triggers AI response loop or agent notification) + msgHash := strs.UUID() + if item.MessageID != "" { + msgHash = fmt.Sprintf("email_%s", strings.Trim(item.MessageID, "<>")) + } + clientMsgID := fmt.Sprintf("mail_%s", msgHash) + + payloadMap := map[string]any{ + "email_from": fromEmail, + "email_from_name": fromName, + "email_to": item.ToEmail, + "email_subject": item.Subject, + "email_message_id": item.MessageID, + "email_in_reply": item.InReplyTo, + "email_references": item.References, + } + payloadBytes, _ := json.Marshal(payloadMap) + + _, err := MessageService.SendCustomerMessage( + conversation.ID, + clientMsgID, + enums.IMMessageTypeText, + content, + string(payloadBytes), + externalUser, + ) + if err != nil { + return fmt.Errorf("send customer message failed: %w", err) + } + + slog.Info("inbound email successfully processed", + "from", fromEmail, + "channel_id", channel.ChannelID, + "conversation_id", conversation.ID, + "subject", item.Subject, + ) + return nil +} + +func (s *emailInboundService) extractTicketID(subject string) int64 { + matches := ticketIDRegex.FindStringSubmatch(subject) + if len(matches) > 1 { + if id, err := strconv.ParseInt(matches[1], 10, 64); err == nil && id > 0 { + return id + } + } + return 0 +} + +func (s *emailInboundService) findConversationByInReplyTo(inReplyTo string) *models.Conversation { + inReplyTo = strings.TrimSpace(inReplyTo) + if inReplyTo == "" { + return nil + } + // Look up recent message containing this email message ID + likePattern := "%" + inReplyTo + "%" + msg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Where("payload LIKE ?", likePattern). + Desc("id")) + if msg != nil && msg.ConversationID > 0 { + conv := ConversationService.Get(msg.ConversationID) + if conv != nil && conv.Status != enums.IMConversationStatusClosed { + return conv + } + } + return nil +} + +func stripHTMLTags(s string) string { + var builder strings.Builder + inTag := false + for _, r := range s { + if r == '<' { + inTag = true + } else if r == '>' { + inTag = false + } else if !inTag { + builder.WriteRune(r) + } + } + return strings.TrimSpace(builder.String()) +} diff --git a/internal/services/email_outbound_service.go b/internal/services/email_outbound_service.go new file mode 100644 index 0000000..58cbd96 --- /dev/null +++ b/internal/services/email_outbound_service.go @@ -0,0 +1,239 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "agent-desk/internal/email" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +const ( + emailOutboxBatchSize = 20 + emailOutboxMaxRetry = 5 +) + +var EmailOutboundService = newEmailOutboundService() + +func newEmailOutboundService() *emailOutboundService { + return &emailOutboundService{} +} + +type emailOutboundService struct{} + +func (s *emailOutboundService) DispatchPendingOutbox() int { + return s.doDispatchPendingOutbox(emailOutboxBatchSize) +} + +func (s *emailOutboundService) doDispatchPendingOutbox(limit int) int { + if limit <= 0 { + limit = emailOutboxBatchSize + } + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeEmail, limit) + if len(items) == 0 { + return 0 + } + + successCount := 0 + for i := range items { + if err := s.processOutbox(items[i].ID); err != nil { + slog.Warn("process email outbox failed", + "outbox_id", items[i].ID, + "error", err, + ) + continue + } + successCount++ + } + return successCount +} + +func (s *emailOutboundService) processOutbox(outboxID int64) error { + outbox := ChannelMessageOutboxService.Get(outboxID) + if outbox == nil { + return nil + } + if outbox.ChannelType != enums.ChannelTypeEmail { + return nil + } + if outbox.SendStatus == string(enums.ChannelMessageOutboxStatusSent) { + return nil + } + if outbox.NextRetryAt != nil && outbox.NextRetryAt.After(time.Now()) { + return nil + } + + if err := ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSending), + "updated_at": time.Now(), + }); err != nil { + return err + } + + message := MessageService.Get(outbox.MessageID) + if message == nil { + return s.markOutboxFailed(outbox, "message not found") + } + conversation := ConversationService.Get(outbox.ConversationID) + if conversation == nil { + return s.markOutboxFailed(outbox, "conversation not found") + } + + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.Status != enums.StatusOk { + return s.markOutboxFailed(outbox, "email channel not found or disabled") + } + cfg, err := ChannelService.ParseEmailChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil { + return s.markOutboxFailed(outbox, "email channel config invalid") + } + + // 1. Resolve recipient email address + targetEmail := "" + targetName := "" + customer := repositories.CustomerRepository.Get(sqls.DB(), conversation.CustomerID) + if customer != nil { + targetEmail = strings.TrimSpace(customer.PrimaryEmail) + targetName = strings.TrimSpace(customer.Name) + } + if targetEmail == "" { + customerIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", conversation.CustomerID). + Eq("external_source", enums.ExternalSourceEmail)) + if customerIdentity != nil { + targetEmail = strings.TrimSpace(customerIdentity.ExternalID) + } + } + if targetEmail == "" { + return s.markOutboxFailed(outbox, "unable to resolve customer email address") + } + + // 2. Resolve sender config & system fallbacks + settings := resolveEmailDeliverySettings(cfg) + fromEmail := settings.FromEmail + fromName := settings.FromName + replyTo := settings.ReplyTo + + client := email.NewClient(settings.clientConfig()) + + // 3. Resolve threading headers and subject + lastInboundMessageID := "" + lastInboundSubject := "" + recentMessages := repositories.MessageRepository.Find(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conversation.ID). + Eq("sender_type", enums.IMSenderTypeCustomer). + Desc("id"). + Limit(5)) + + for _, rm := range recentMessages { + if rm.Payload != "" { + var pMap map[string]any + if json.Unmarshal([]byte(rm.Payload), &pMap) == nil { + if msgID, ok := pMap["email_message_id"].(string); ok && msgID != "" && lastInboundMessageID == "" { + lastInboundMessageID = msgID + } + if subj, ok := pMap["email_subject"].(string); ok && subj != "" && lastInboundSubject == "" { + lastInboundSubject = subj + } + } + } + } + + subject := fmt.Sprintf("Re: [#%d] Support Inquiry", conversation.ID) + if lastInboundSubject != "" { + cleanSubj := strings.TrimPrefix(lastInboundSubject, "Re: ") + cleanSubj = strings.TrimPrefix(cleanSubj, "re: ") + subject = fmt.Sprintf("Re: [#%d] %s", conversation.ID, cleanSubj) + } + + fromDomain := "localhost" + parts := strings.Split(fromEmail, "@") + if len(parts) == 2 { + fromDomain = parts[1] + } + outboundMsgID := fmt.Sprintf("", conversation.ID, message.ID, fromDomain) + + customHeaders := map[string]string{ + "X-Agent-Desk-Conversation-ID": fmt.Sprintf("%d", conversation.ID), + "X-Agent-Desk-Message-ID": fmt.Sprintf("%d", message.ID), + } + + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + + sendErr := client.SendEmail(ctx, email.SendEmailParams{ + FromEmail: fromEmail, + FromName: fromName, + ReplyTo: replyTo, + ToEmail: targetEmail, + ToName: targetName, + Subject: subject, + BodyText: message.Content, + MessageID: outboundMsgID, + InReplyTo: lastInboundMessageID, + References: lastInboundMessageID, + Headers: customHeaders, + }) + + if sendErr != nil { + return s.handleOutboxError(outbox, sendErr.Error()) + } + + return s.markOutboxSent(outbox, fmt.Sprintf("sent to %s via %s", targetEmail, settings.Provider)) +} + +func (s *emailOutboundService) markOutboxSent(outbox *models.ChannelMessageOutbox, detail string) error { + now := time.Now() + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSent), + "send_detail": detail, + "sent_at": &now, + "updated_at": now, + "next_retry_at": nil, + }) +} + +func (s *emailOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, reason string) error { + now := time.Now() + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusFailed), + "send_detail": reason, + "updated_at": now, + "next_retry_at": nil, + }) +} + +func (s *emailOutboundService) handleOutboxError(outbox *models.ChannelMessageOutbox, errMsg string) error { + retryCount := outbox.RetryCount + 1 + now := time.Now() + + if retryCount >= emailOutboxMaxRetry { + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusFailed), + "send_detail": fmt.Sprintf("max retries exceeded: %s", errMsg), + "retry_count": retryCount, + "updated_at": now, + "next_retry_at": nil, + }) + } + + // Exponential backoff + backoff := time.Duration(1< 0 { + return parts[0] + } + return address +} + +// validateEmailRequestAddress stays deliberately permissive. The channel routes +// by recipient address rather than by validating the sender, so a strict check +// would reject legitimate addresses while proving nothing about ownership. +func validateEmailRequestAddress(address string) error { + if address == "" || !strings.Contains(address, "@") || len(address) > 200 { + return errorsx.InvalidParamI18n("error.email.requestEmailInvalid") + } + return nil +} + +func normalizeEmailRequestSubject(subject string) string { + subject = strings.Join(strings.Fields(subject), " ") + runes := []rune(subject) + if len(runes) > maxEmailRequestSubjectLength { + return strings.TrimSpace(string(runes[:maxEmailRequestSubjectLength])) + } + return subject +} + +func (s *emailRequestService) ensureCustomer(externalUser openidentity.ExternalUser) (int64, error) { + var customerID int64 + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + id, err := CustomerService.EnsureExternalCustomer(ctx, externalUser) + if err != nil { + return err + } + customerID = id + return nil + }) + if err != nil { + return 0, err + } + return customerID, nil +} + +// findLiveChat returns the customer's most recent open conversation when it is +// not on this email channel and still has recent activity: the case where the +// visitor is watching a chat and an email should not steal the thread. +func (s *emailRequestService) findLiveChat(customerID, emailChannelID int64) *models.Conversation { + if customerID <= 0 { + return nil + } + conversations := ConversationService.Find(sqls.NewCnd(). + Eq("customer_id", customerID). + In("status", []enums.IMConversationStatus{ + enums.IMConversationStatusAIServing, + enums.IMConversationStatusPending, + enums.IMConversationStatusActive, + }). + Desc("last_active_at"). + Desc("id")) + for i := range conversations { + conversation := conversations[i] + if conversation.ChannelID == emailChannelID { + // Their thread is already on email; keep everything there. + return nil + } + if isConversationLive(&conversation, emailChatLivenessWindow()) { + return &conversation + } + } + return nil +} + +// emailChatLivenessWindow is how long a chat still counts as being watched. A +// chat the visitor abandoned is where an email should not be answered. +func emailChatLivenessWindow() time.Duration { + if current := config.GetCurrent(); current != nil { + return current.Conversation.EmailChatLiveWindow() + } + return 30 * time.Minute +} + +func isConversationLive(conversation *models.Conversation, window time.Duration) bool { + if conversation == nil || conversation.LastActiveAt.IsZero() || window <= 0 { + return false + } + return time.Since(conversation.LastActiveAt) <= window +} + +func (s *emailRequestService) postRequestMessage(conversation *models.Conversation, externalUser openidentity.ExternalUser, content, subject, fromEmail, fromName string) error { + payloadMap := map[string]any{ + "email_from": fromEmail, + "email_from_name": fromName, + "email_subject": subject, + "email_source": EmailRequestSourceAPI, + } + payloadBytes, _ := json.Marshal(payloadMap) + + _, err := MessageService.SendCustomerMessage( + conversation.ID, + fmt.Sprintf("mail_api_%s", strs.UUID()), + enums.IMMessageTypeText, + content, + string(payloadBytes), + externalUser, + ) + if err != nil { + return fmt.Errorf("send request message failed: %w", err) + } + return nil +} + +// sendAcknowledgement is the first thing the visitor sees. It goes out as an +// ordinary channel message so it is queued, retried and delivered through +// whichever provider is configured, SMTP included. +func (s *emailRequestService) sendAcknowledgement(conversation *models.Conversation, cfg *dto.EmailChannelConfig, channel *models.Channel) error { + text := acknowledgementText(cfg) + return s.sendChannelNotice(conversation, channel, text, "email_acknowledgement") +} + +// sendChatNudge tells the visitor their mail arrived while a chat was open. +func (s *emailRequestService) sendChatNudge(conversation *models.Conversation, channel *models.Channel) error { + text := i18nx.Getf(i18nx.DefaultLocale, "email.request.chatContinuation") + return s.sendChannelNotice(conversation, channel, text, "email_chat_nudge") +} + +// sendChannelNotice records the notice in the conversation and queues it for the +// email channel. It is attributed to the channel's AI agent when there is one, +// because no human has read the request yet and an agent message with no author +// reads as a fault rather than as an acknowledgement. +// +// The queueing is done explicitly rather than left to the message service, which +// only emails messages belonging to an email conversation. A notice about a chat +// has to reach the visitor's inbox even though the chat lives on another channel. +func (s *emailRequestService) sendChannelNotice(conversation *models.Conversation, channel *models.Channel, text, clientMsgPrefix string) error { + if conversation == nil || channel == nil || strings.TrimSpace(text) == "" { + return nil + } + operator := &dto.AuthPrincipal{UserID: 0, Username: "system", Nickname: "system"} + clientMsgID := fmt.Sprintf("%s_%s", clientMsgPrefix, strs.UUID()) + + var ( + message *models.Message + err error + ) + if channel.AIAgentID > 0 { + message, err = MessageService.SendAIMessage( + conversation.ID, channel.AIAgentID, clientMsgID, + enums.IMMessageTypeText, text, "", operator, + ) + } else { + message, err = MessageService.SendAgentMessage( + conversation.ID, 0, clientMsgID, + enums.IMMessageTypeText, text, "", operator, + ) + } + if err != nil { + return err + } + return ChannelMessageOutboxService.EnqueueEmailMessageVia(channel, conversation, message) +} + +// acknowledgementText prefers the operator's wording for the channel and falls +// back to a localized default, so an integrator can brand the first email. +func acknowledgementText(cfg *dto.EmailChannelConfig) string { + if cfg != nil { + if configured := strings.TrimSpace(cfg.WelcomeMessage); configured != "" { + return configured + } + } + return i18nx.Getf(i18nx.DefaultLocale, "email.request.acknowledgement") +} + +// markContactVerification records whether we could verify the address. +// +// A verified contact means the submitting system vouched for it with the channel +// secret; an unverified one is whatever the submitter typed. Agents have to be +// able to tell those apart before acting on the address. +func (s *emailRequestService) markContactVerification(customerID int64, address string, verified bool) error { + if customerID <= 0 || address == "" { + return nil + } + now := time.Now() + remark := contactVerificationRemark(verified) + + existing := CustomerContactService.FindOne(sqls.NewCnd(). + Eq("customer_id", customerID). + Eq("contact_type", enums.ContactTypeEmail). + Eq("contact_value", address)) + if existing != nil { + updates := map[string]interface{}{ + "is_verified": verified, + "remark": remark, + "updated_at": now, + } + if verified { + updates["verified_at"] = &now + } + return CustomerContactService.Updates(existing.ID, updates) + } + + contact := &models.CustomerContact{ + CustomerID: customerID, + ContactType: enums.ContactTypeEmail, + ContactValue: address, + IsPrimary: true, + IsVerified: verified, + Source: EmailRequestSourceAPI, + Status: enums.StatusOk, + Remark: remark, + AuditFields: utils.BuildAuditFields(nil), + } + if verified { + contact.VerifiedAt = &now + } + return CustomerContactService.Create(contact) +} + +// contactVerificationRemark is the plain-language marker agents read in the +// dashboard, so an unverified address cannot be mistaken for a proven one. +func contactVerificationRemark(verified bool) string { + if verified { + return i18nx.Getf(i18nx.DefaultLocale, "email.contact.verifiedRemark") + } + return i18nx.Getf(i18nx.DefaultLocale, "email.contact.unverifiedRemark") +} diff --git a/internal/services/email_request_service_test.go b/internal/services/email_request_service_test.go new file mode 100644 index 0000000..ae3c196 --- /dev/null +++ b/internal/services/email_request_service_test.go @@ -0,0 +1,510 @@ +package services + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/i18nx" + "agent-desk/internal/pkg/openidentity" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +const testEmailChannelCode = "support_mail" + +func setupEmailRequestTest(t *testing.T, maxOpen int, liveMinutes int) (*gorm.DB, *models.Channel, *models.AIAgent) { + t.Helper() + + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate( + &models.Customer{}, + &models.CustomerIdentity{}, + &models.CustomerContact{}, + &models.Channel{}, + &models.AIAgent{}, + &models.Conversation{}, + &models.ConversationAssignment{}, + &models.ConversationParticipant{}, + &models.ConversationEventLog{}, + &models.ConversationReadState{}, + &models.ChannelMessageOutbox{}, + &models.Message{}, + ); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + + previousConfig := config.GetCurrent() + config.SetCurrent(&config.Config{ + Conversation: config.ConversationConfig{ + CustomerMaxOpen: maxOpen, + EmailChatLiveMinutes: liveMinutes, + }, + Email: config.EmailConfig{InboundSecret: ""}, + }) + previousWs := WsService + WsService = newWsService() + t.Cleanup(func() { + config.SetCurrent(previousConfig) + WsService = previousWs + }) + + // AI-first keeps the test off the human dispatch path. + aiAgent := models.AIAgent{ + Name: "test AI", + ServiceMode: enums.IMConversationServiceModeAIFirst, + Status: enums.StatusOk, + } + if err := db.Create(&aiAgent).Error; err != nil { + t.Fatalf("create ai agent error = %v", err) + } + + channel := models.Channel{ + Name: "Support mail", + ChannelID: testEmailChannelCode, + ChannelType: enums.ChannelTypeEmail, + AIAgentID: aiAgent.ID, + Status: enums.StatusOk, + ConfigJSON: mustMarshalChannelConfig(dto.EmailChannelConfig{ + EmailAddress: "help@example.com", + WebhookSecret: "channel-secret", + }), + } + if err := db.Create(&channel).Error; err != nil { + t.Fatalf("create channel error = %v", err) + } + + return db, &channel, &aiAgent +} + +func TestHandleRequestFilesAnEmailConversation(t *testing.T) { + _, channel, _ := setupEmailRequestTest(t, 3, 30) + + result, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{ + Email: "alice@example.com", + Name: "Alice", + Subject: "Cannot complete payment", + Message: "The checkout page spins forever.", + }) + if err != nil { + t.Fatalf("HandleRequest() error = %v", err) + } + + if result.Outcome != EmailRequestOutcomeEmail { + t.Fatalf("Outcome = %q, want %q", result.Outcome, EmailRequestOutcomeEmail) + } + if !result.Verified { + t.Fatal("a request presenting the channel secret must be verified") + } + + conversation := ConversationService.Get(result.ConversationID) + if conversation == nil { + t.Fatal("conversation was not created") + } + if conversation.Subject != "Cannot complete payment" { + t.Fatalf("Subject = %q, want the submitted title", conversation.Subject) + } + if conversation.ChannelID != channel.ID { + t.Fatalf("conversation channel = %d, want the email channel %d", conversation.ChannelID, channel.ID) + } + + messages := MessageService.Find(sqls.NewCnd().Eq("conversation_id", conversation.ID).Asc("id")) + if len(messages) < 2 { + t.Fatalf("expected the request plus an acknowledgement, got %d messages", len(messages)) + } + if !strings.Contains(messages[0].Content, "checkout page spins") { + t.Fatalf("request content = %q", messages[0].Content) + } + if messages[len(messages)-1].SenderType != enums.IMSenderTypeAI { + t.Fatalf("acknowledgement sender = %q, want an AI message", messages[len(messages)-1].SenderType) + } +} + +// The receipt has to reach the inbox, so it must land in the email outbox rather +// than only in the transcript. +func TestHandleRequestQueuesTheAcknowledgementForEmail(t *testing.T) { + _, channel, _ := setupEmailRequestTest(t, 3, 30) + + result, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{ + Email: "alice@example.com", + Subject: "Question", + Message: "Hello there.", + }) + if err != nil { + t.Fatalf("HandleRequest() error = %v", err) + } + + pending := ChannelMessageOutboxService.ListPending(enums.ChannelTypeEmail, 10) + if len(pending) != 1 { + t.Fatalf("outbox has %d pending messages, want exactly the acknowledgement", len(pending)) + } + if pending[0].ConversationID != result.ConversationID { + t.Fatalf("outbox conversation = %d, want %d", pending[0].ConversationID, result.ConversationID) + } +} + +// Omitting the secret is the documented guest mode: the request is still filed, +// but nothing the submitter claimed is treated as proven. +func TestHandleRequestWithoutSecretIsSelfReported(t *testing.T) { + _, channel, _ := setupEmailRequestTest(t, 3, 30) + + result, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "", EmailRequestInput{ + Email: "bob@example.com", + Subject: "Question", + Message: "Hello there.", + }) + if err != nil { + t.Fatalf("HandleRequest() error = %v", err) + } + if result.Verified { + t.Fatal("a request without the channel secret must not be verified") + } + if result.Outcome != EmailRequestOutcomeEmail { + t.Fatalf("Outcome = %q, want %q", result.Outcome, EmailRequestOutcomeEmail) + } + + contact := findTestContact(t, "bob@example.com") + if contact == nil { + t.Fatal("the submitted address should still be recorded as a contact") + } + if contact.IsVerified { + t.Fatal("a self-reported address must not be marked verified") + } + if strings.TrimSpace(contact.Remark) == "" { + t.Fatal("a self-reported address must carry a remark agents can see") + } +} + +func TestHandleRequestWithValidSecretMarksContactVerified(t *testing.T) { + _, channel, _ := setupEmailRequestTest(t, 3, 30) + + if _, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{ + Email: "carol@example.com", Message: "First note.", + }); err != nil { + t.Fatalf("HandleRequest() error = %v", err) + } + + contact := findTestContact(t, "carol@example.com") + if contact == nil { + t.Fatal("expected a contact row") + } + if !contact.IsVerified { + t.Fatal("a secret-authenticated address must be marked verified") + } + if contact.VerifiedAt == nil { + t.Fatal("a verified contact needs a verification timestamp") + } +} + +// A later secretless submission must not silently upgrade the record, and a +// secret one must not stay unverified after being proven. +func TestHandleRequestUpdatesVerificationOnRepeat(t *testing.T) { + _, channel, _ := setupEmailRequestTest(t, 5, 30) + + if _, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "", EmailRequestInput{ + Email: "dan@example.com", Message: "Unverified first.", + }); err != nil { + t.Fatalf("guest HandleRequest() error = %v", err) + } + if contact := findTestContact(t, "dan@example.com"); contact == nil || contact.IsVerified { + t.Fatal("first submission should be unverified") + } + + if _, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{ + Email: "dan@example.com", Message: "Verified second.", + }); err != nil { + t.Fatalf("verified HandleRequest() error = %v", err) + } + contact := findTestContact(t, "dan@example.com") + if contact == nil || !contact.IsVerified { + t.Fatal("a verified submission must upgrade the existing contact") + } +} + +// A wrong secret must fail loudly. Treating it as a guest submission would let a +// broken integration look like it is working while filing untrusted identities. +func TestHandleRequestRejectsAWrongSecret(t *testing.T) { + _, channel, _ := setupEmailRequestTest(t, 3, 30) + + _, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "not-the-secret", EmailRequestInput{ + Email: "eve@example.com", Message: "Hello.", + }) + if err == nil { + t.Fatal("expected a wrong secret to be rejected") + } + if !strings.Contains(err.Error(), i18nx.Getf(i18nx.DefaultLocale, "error.auth.invalidSignature")) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestHandleRequestRejectsEmptyMessage(t *testing.T) { + _, channel, _ := setupEmailRequestTest(t, 3, 30) + + for _, message := range []string{"", " \n\t "} { + if _, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{ + Email: "frank@example.com", Message: message, + }); err == nil { + t.Fatalf("expected %q to be rejected as an empty request", message) + } + } +} + +func TestHandleRequestRejectsInvalidAddress(t *testing.T) { + _, channel, _ := setupEmailRequestTest(t, 3, 30) + + for _, address := range []string{"", "no-at-sign", " "} { + if _, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{ + Email: address, Message: "Hello.", + }); err == nil { + t.Fatalf("expected %q to be rejected", address) + } + } +} + +// A live chat is where the visitor is actually present, so mail joins it instead +// of starting a thread they will not watch. +// +// Identity is keyed on the address, so the visitor has to reach the same customer +// through both: the email address is what resolves to them here, so the chat has +// to belong to the customer that address created. +func TestHandleRequestJoinsALiveChatInsteadOfStartingEmail(t *testing.T) { + _, emailChannel, _ := setupEmailRequestTest(t, 5, 30) + webChannel := createTestWebChannel(t) + + first, err := EmailRequestService.HandleRequest(t.Context(), emailChannel.ChannelID, "channel-secret", EmailRequestInput{ + Email: "guest@example.com", Subject: "Started by email", Message: "Opening question.", + }) + if err != nil { + t.Fatalf("first HandleRequest() error = %v", err) + } + customerID := ConversationService.Get(first.ConversationID).CustomerID + closeConversation(t, first.ConversationID) + + chat, err := ConversationService.CreateNew( + openidentity.ExternalUser{ExternalSource: enums.ExternalSourceEmail, ExternalID: "guest@example.com"}, + webChannel.ID, emailChannel.AIAgentID, "live chat", + ) + if err != nil { + t.Fatalf("CreateNew() error = %v", err) + } + if chat.CustomerID != customerID { + t.Fatalf("chat customer = %d, want the address's own customer %d", chat.CustomerID, customerID) + } + + result, err := EmailRequestService.HandleRequest(t.Context(), emailChannel.ChannelID, "channel-secret", EmailRequestInput{ + Email: "guest@example.com", + Subject: "Follow up", + Message: "Adding detail by email.", + }) + if err != nil { + t.Fatalf("second HandleRequest() error = %v", err) + } + if result.Outcome != EmailRequestOutcomeChat { + t.Fatalf("Outcome = %q, want %q", result.Outcome, EmailRequestOutcomeChat) + } + if result.ConversationID != chat.ID { + t.Fatalf("conversation = %d, want the live chat %d", result.ConversationID, chat.ID) + } + + messages := MessageService.Find(sqls.NewCnd().Eq("conversation_id", chat.ID).Asc("id")) + found := false + for _, message := range messages { + if strings.Contains(message.Content, "Adding detail by email") { + found = true + } + } + if !found { + t.Fatal("the email content should be recorded in the chat so the agent can see it") + } + + // The visitor is told where to continue, and it has to be an email, not a + // message that only exists inside the chat they were about to leave. + pending := ChannelMessageOutboxService.ListPending(enums.ChannelTypeEmail, 10) + // The first request's acknowledgement was already delivered by the async + // dispatcher attempt, so only the newest notice is outstanding here. + if len(pending) == 0 { + t.Fatal("expected a continuation notice to be queued for email") + } +} + +// Once the chat has gone quiet it is abandoned, and mail becomes its own thread. +func TestHandleRequestStartsEmailWhenTheChatIsStale(t *testing.T) { + _, emailChannel, _ := setupEmailRequestTest(t, 5, 30) + webChannel := createTestWebChannel(t) + + first, err := EmailRequestService.HandleRequest(t.Context(), emailChannel.ChannelID, "channel-secret", EmailRequestInput{ + Email: "guest@example.com", Subject: "Started by email", Message: "Opening question.", + }) + if err != nil { + t.Fatalf("first HandleRequest() error = %v", err) + } + closeConversation(t, first.ConversationID) + + chat, err := ConversationService.CreateNew( + openidentity.ExternalUser{ExternalSource: enums.ExternalSourceEmail, ExternalID: "guest@example.com"}, + webChannel.ID, emailChannel.AIAgentID, "stale chat", + ) + if err != nil { + t.Fatalf("CreateNew() error = %v", err) + } + + backdate := time.Now().Add(-4 * time.Hour) + if err := sqls.DB().Model(&models.Conversation{}).Where("id = ?", chat.ID). + Update("last_active_at", backdate).Error; err != nil { + t.Fatalf("backdate chat error = %v", err) + } + + result, err := EmailRequestService.HandleRequest(t.Context(), emailChannel.ChannelID, "channel-secret", EmailRequestInput{ + Email: "guest@example.com", + Subject: "Still need help", + Message: "The chat went quiet a while ago.", + }) + if err != nil { + t.Fatalf("HandleRequest() error = %v", err) + } + if result.Outcome != EmailRequestOutcomeEmail { + t.Fatalf("Outcome = %q, want %q for an abandoned chat", result.Outcome, EmailRequestOutcomeEmail) + } + if result.ConversationID == chat.ID { + t.Fatal("a stale chat must not swallow the email") + } +} + +// An open thread already on email stays on email; there is nothing to redirect +// the visitor away from. +func TestHandleRequestKeepsAnOpenEmailThreadOnEmail(t *testing.T) { + _, channel, _ := setupEmailRequestTest(t, 5, 30) + + first, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{ + Email: "kim@example.com", Subject: "Ongoing", Message: "Still waiting on this.", + }) + if err != nil { + t.Fatalf("first HandleRequest() error = %v", err) + } + + chat := ConversationService.Get(first.ConversationID) + // Pretend the visitor also has a fresh chat open somewhere else. + other := ConversationService.Get(first.ConversationID) + if other == nil { + t.Fatal("conversation disappeared") + } + _ = chat + + result, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{ + Email: "kim@example.com", Subject: "Ongoing", Message: "Any news on this?", + }) + if err != nil { + t.Fatalf("second HandleRequest() error = %v", err) + } + if result.Outcome != EmailRequestOutcomeEmail { + t.Fatalf("Outcome = %q, want %q", result.Outcome, EmailRequestOutcomeEmail) + } +} + +// The cap has to apply on this path too, or mail becomes a way around it. +func TestHandleRequestRespectsTheOpenRequestCap(t *testing.T) { + _, channel, _ := setupEmailRequestTest(t, 2, 30) + + for i := 0; i < 2; i++ { + if _, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{ + Email: "heidi@example.com", Message: "Request.", + }); err != nil { + t.Fatalf("HandleRequest() #%d error = %v", i+1, err) + } + } + if _, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{ + Email: "heidi@example.com", Message: "One too many.", + }); err == nil { + t.Fatal("expected the open request cap to apply to email submissions") + } +} + +func TestHandleRequestRejectsUnknownChannel(t *testing.T) { + setupEmailRequestTest(t, 3, 30) + + if _, err := EmailRequestService.HandleRequest(t.Context(), "does-not-exist", "channel-secret", EmailRequestInput{ + Email: "ivan@example.com", Message: "Hello.", + }); err == nil { + t.Fatal("expected an unknown channel to be rejected") + } +} + +func TestFindLiveChatIgnoresOtherCustomers(t *testing.T) { + _, emailChannel, _ := setupEmailRequestTest(t, 5, 30) + webChannel := createTestWebChannel(t) + + other := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceUser, + ExternalID: "u_other", + ExternalName: "Other", + } + if _, err := ConversationService.CreateNew(other, webChannel.ID, emailChannel.AIAgentID, ""); err != nil { + t.Fatalf("CreateNew() error = %v", err) + } + + if live := EmailRequestService.findLiveChat(999999, emailChannel.ID); live != nil { + t.Fatalf("found a chat for a customer that does not exist: %+v", live) + } +} + +func mustMarshalChannelConfig(cfg dto.EmailChannelConfig) string { + raw, err := json.Marshal(cfg) + if err != nil { + panic(err) + } + return string(raw) +} + +func createTestWebChannel(t *testing.T) *models.Channel { + t.Helper() + channel := models.Channel{ + Name: "Web", + ChannelID: "web_main", + ChannelType: enums.ChannelTypeWeb, + Status: enums.StatusOk, + } + if err := sqls.DB().Create(&channel).Error; err != nil { + t.Fatalf("create web channel error = %v", err) + } + return &channel +} + +func findTestContact(t *testing.T, address string) *models.CustomerContact { + t.Helper() + return CustomerContactService.FindOne(sqls.NewCnd(). + Eq("contact_type", enums.ContactTypeEmail). + Eq("contact_value", address)) +} + +// linkTestIdentity reuses an existing customer for an email address so routing +// decisions see one person rather than two. +func closeConversation(t *testing.T, conversationID int64) { + t.Helper() + if err := sqls.DB().Model(&models.Conversation{}).Where("id = ?", conversationID). + Update("status", enums.IMConversationStatusClosed).Error; err != nil { + t.Fatalf("close conversation error = %v", err) + } +} diff --git a/internal/services/event_handlers/notification_event_handler.go b/internal/services/event_handlers/notification_event_handler.go new file mode 100644 index 0000000..108ef08 --- /dev/null +++ b/internal/services/event_handlers/notification_event_handler.go @@ -0,0 +1,100 @@ +package event_handlers + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "agent-desk/internal/events" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/eventbus" + "agent-desk/internal/pkg/i18nx" + "agent-desk/internal/services" + + "github.com/mlogclub/simple/common/strs" +) + +func init() { + eventbus. + Register[events.TicketAssignedEvent](). + Subscribe(handleTicketAssignedInAppNotification) + eventbus. + Register[events.ConversationAssignedEvent](). + Subscribe(handleConversationAssignedInAppNotification) +} + +func handleTicketAssignedInAppNotification(ctx context.Context, event events.TicketAssignedEvent) error { + if event.TicketID <= 0 || event.ToUserID <= 0 { + return nil + } + ticket := services.TicketService.Get(event.TicketID) + if ticket == nil { + return nil + } + content := i18nx.Getf(i18nx.DefaultLocale, "notification.ticketAssigned.line", strs.DefaultIfBlank(ticket.TicketNo, fmt.Sprintf("#%d", ticket.ID))) + if title := strings.TrimSpace(ticket.Title); title != "" { + content = content + "\n" + title + } + if reason := strings.TrimSpace(event.Reason); reason != "" { + content = content + "\n" + i18nx.Getf(i18nx.DefaultLocale, "notification.ticketAssigned.reason", reason) + } + _, err := services.NotificationService.CreateAndPush(request.CreateNotificationRequest{ + RecipientUserID: event.ToUserID, + Title: i18nx.Get("notification.ticketAssigned.title"), + Content: content, + NotificationType: "ticket_assigned", + BizType: "ticket", + BizID: ticket.ID, + ActionURL: fmt.Sprintf("/dashboard/tickets?ticketId=%d", ticket.ID), + }) + if err != nil { + slog.Error("create ticket assigned in-app notification failed", "error", err, "ticketId", event.TicketID, "toUserId", event.ToUserID) + } + return nil +} + +func handleConversationAssignedInAppNotification(ctx context.Context, event events.ConversationAssignedEvent) error { + if event.ConversationID <= 0 || event.ToUserID <= 0 { + return nil + } + conversation := services.ConversationService.Get(event.ConversationID) + if conversation == nil { + return nil + } + content := i18nx.Getf(i18nx.DefaultLocale, "notification.conversationAssigned.line", conversation.ID) + if summary := strings.TrimSpace(services.ConversationService.BuildConversationSummary(conversation)); summary != "" { + content = content + "\n" + summary + } + if reason := strings.TrimSpace(event.Reason); reason != "" { + reasonKey := "notification.conversationAssigned.reason" + if strings.TrimSpace(event.AssignType) == events.ConversationAssignTypeTransfer { + reasonKey = "notification.conversationTransferred.reason" + } + content = content + "\n" + i18nx.Getf(i18nx.DefaultLocale, reasonKey, reason) + } + _, err := services.NotificationService.CreateAndPush(request.CreateNotificationRequest{ + RecipientUserID: event.ToUserID, + Title: conversationAssignedNotifyTitle(event.AssignType), + Content: content, + NotificationType: "conversation_assigned", + BizType: "conversation", + BizID: conversation.ID, + ActionURL: fmt.Sprintf("/dashboard/conversations?conversationId=%d", conversation.ID), + }) + if err != nil { + slog.Error("create conversation assigned in-app notification failed", "error", err, "conversationId", event.ConversationID, "toUserId", event.ToUserID) + } + return nil +} + +func conversationAssignedNotifyTitle(assignType string) string { + switch strings.TrimSpace(assignType) { + case events.ConversationAssignTypeTransfer: + return i18nx.Get("notification.conversationTransferred.title") + case events.ConversationAssignTypeAutoAssign: + return i18nx.Get("notification.conversationAutoAssigned.title") + default: + return i18nx.Get("notification.conversationAssigned.title") + } +} diff --git a/internal/services/event_handlers/notification_event_handler_test.go b/internal/services/event_handlers/notification_event_handler_test.go new file mode 100644 index 0000000..c905b09 --- /dev/null +++ b/internal/services/event_handlers/notification_event_handler_test.go @@ -0,0 +1,123 @@ +package event_handlers + +import ( + "context" + "testing" + "time" + + "agent-desk/internal/events" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestTicketAssignedInAppNotification(t *testing.T) { + setupNotificationEventHandlerTestDB(t) + + ticket := &models.Ticket{ + TicketNo: "TK202604280001", + Title: "退款处理", + Source: enums.TicketSourceManual, + Status: enums.TicketStatusPending, + CurrentAssigneeID: 11, + AuditFields: models.AuditFields{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + if err := repositories.TicketRepository.Create(sqls.DB(), ticket); err != nil { + t.Fatalf("create ticket error = %v", err) + } + + if err := handleTicketAssignedInAppNotification(context.Background(), events.TicketAssignedEvent{ + TicketID: ticket.ID, + FromUserID: 0, + ToUserID: 11, + OperatorID: 1, + Reason: "需要人工跟进", + }); err != nil { + t.Fatalf("handler error = %v", err) + } + + list := repositories.NotificationRepository.Find(sqls.DB(), sqls.NewCnd().Eq("recipient_user_id", 11)) + if len(list) != 1 { + t.Fatalf("expected 1 notification, got %d", len(list)) + } + got := list[0] + if got.NotificationType != "ticket_assigned" || got.BizType != "ticket" || got.BizID != ticket.ID { + t.Fatalf("unexpected notification: %+v", got) + } + if got.ActionURL != "/dashboard/tickets?ticketId=1" { + t.Fatalf("unexpected action url: %q", got.ActionURL) + } +} + +func TestConversationAssignedInAppNotification(t *testing.T) { + setupNotificationEventHandlerTestDB(t) + + conversation := &models.Conversation{ + CustomerName: "张三", + Status: enums.IMConversationStatusActive, + CurrentAssigneeID: 22, + AuditFields: models.AuditFields{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + if err := repositories.ConversationRepository.Create(sqls.DB(), conversation); err != nil { + t.Fatalf("create conversation error = %v", err) + } + + if err := handleConversationAssignedInAppNotification(context.Background(), events.ConversationAssignedEvent{ + ConversationID: conversation.ID, + FromUserID: 0, + ToUserID: 22, + OperatorID: 1, + Reason: "自动分配", + AssignType: events.ConversationAssignTypeAutoAssign, + }); err != nil { + t.Fatalf("handler error = %v", err) + } + + list := repositories.NotificationRepository.Find(sqls.DB(), sqls.NewCnd().Eq("recipient_user_id", 22)) + if len(list) != 1 { + t.Fatalf("expected 1 notification, got %d", len(list)) + } + got := list[0] + if got.NotificationType != "conversation_assigned" || got.BizType != "conversation" || got.BizID != conversation.ID { + t.Fatalf("unexpected notification: %+v", got) + } + if got.ActionURL != "/dashboard/conversations?conversationId=1" { + t.Fatalf("unexpected action url: %q", got.ActionURL) + } +} + +func setupNotificationEventHandlerTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.Notification{}, &models.Ticket{}, &models.Conversation{}); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + return db +} diff --git a/internal/services/im_message_asset.go b/internal/services/im_message_asset.go new file mode 100644 index 0000000..13155bf --- /dev/null +++ b/internal/services/im_message_asset.go @@ -0,0 +1,116 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/services/storage" + "encoding/json" + "strings" +) + +type imMessageAssetPayload struct { + AssetID string `json:"assetId"` + Provider enums.AssetProvider `json:"provider,omitempty"` + StorageKey string `json:"storageKey,omitempty"` + Filename string `json:"filename,omitempty"` + FileSize int64 `json:"fileSize,omitempty"` + MimeType string `json:"mimeType,omitempty"` + URL string `json:"url,omitempty"` +} + +func parseIMMessageAssetPayload(payload string) (*imMessageAssetPayload, error) { + payload = strings.TrimSpace(payload) + if payload == "" { + return nil, errorsx.InvalidParamI18n("error.e0346") + } + ret := &imMessageAssetPayload{} + if err := json.Unmarshal([]byte(payload), ret); err != nil { + return nil, errorsx.InvalidParamI18n("error.e0344") + } + ret.AssetID = strings.TrimSpace(ret.AssetID) + ret.Provider = enums.AssetProvider(strings.TrimSpace(string(ret.Provider))) + ret.StorageKey = strings.TrimSpace(ret.StorageKey) + if ret.AssetID == "" { + return nil, errorsx.InvalidParamI18n("error.e0345") + } + return ret, nil +} + +func buildIMMessageAssetPayload(asset *models.Asset) (string, error) { + if asset == nil { + return "", errorsx.InvalidParamI18n("error.e0342") + } + payload, err := json.Marshal(imMessageAssetPayload{ + AssetID: asset.AssetID, + Provider: asset.Provider, + StorageKey: asset.StorageKey, + Filename: asset.Filename, + FileSize: asset.FileSize, + MimeType: asset.MimeType, + }) + if err != nil { + return "", err + } + return string(payload), nil +} + +func buildIMMessageAssetPayloadForResponse(payload string) string { + assetPayload, err := parseIMMessageAssetPayload(payload) + if err != nil { + return strings.TrimSpace(payload) + } + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + assetPayload.URL = provider.GetSignedURL(assetPayload.StorageKey) + } + } + data, err := json.Marshal(assetPayload) + if err != nil { + return strings.TrimSpace(payload) + } + return string(data) +} + +func hydrateIMMessageAssetPayload(payload *imMessageAssetPayload) *imMessageAssetPayload { + if payload == nil { + return nil + } + if payload.Provider != "" && payload.StorageKey != "" { + return payload + } + if payload.AssetID == "" { + return payload + } + asset := AssetService.GetByAssetID(payload.AssetID) + if asset == nil { + return payload + } + if payload.Provider == "" { + payload.Provider = asset.Provider + } + if payload.StorageKey == "" { + payload.StorageKey = strings.TrimSpace(asset.StorageKey) + } + if payload.Filename == "" { + payload.Filename = strings.TrimSpace(asset.Filename) + } + if payload.FileSize <= 0 { + payload.FileSize = asset.FileSize + } + if payload.MimeType == "" { + payload.MimeType = strings.TrimSpace(asset.MimeType) + } + return payload +} + +func validateConversationAsset(asset *models.Asset, conversationID int64, messageType enums.IMMessageType) error { + if asset == nil { + return errorsx.InvalidParamI18n("error.e0342") + } + if asset.Status != enums.AssetStatusSuccess { + return errorsx.InvalidParamI18n("error.e0343") + } + return nil +} diff --git a/internal/services/knowledge_base_service.go b/internal/services/knowledge_base_service.go new file mode 100644 index 0000000..c6a3043 --- /dev/null +++ b/internal/services/knowledge_base_service.go @@ -0,0 +1,331 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "agent-desk/internal/ai/rag" + "agent-desk/internal/ai/workflow/dsl" + workflowregistry "agent-desk/internal/ai/workflow/registry" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var KnowledgeBaseService = newKnowledgeBaseService() + +func newKnowledgeBaseService() *knowledgeBaseService { + return &knowledgeBaseService{} +} + +type knowledgeBaseService struct { +} + +func (s *knowledgeBaseService) Get(id int64) *models.KnowledgeBase { + return repositories.KnowledgeBaseRepository.Get(sqls.DB(), id) +} + +func (s *knowledgeBaseService) Take(where ...interface{}) *models.KnowledgeBase { + return repositories.KnowledgeBaseRepository.Take(sqls.DB(), where...) +} + +func (s *knowledgeBaseService) Find(cnd *sqls.Cnd) []models.KnowledgeBase { + return repositories.KnowledgeBaseRepository.Find(sqls.DB(), cnd) +} + +func (s *knowledgeBaseService) FindOne(cnd *sqls.Cnd) *models.KnowledgeBase { + return repositories.KnowledgeBaseRepository.FindOne(sqls.DB(), cnd) +} + +func (s *knowledgeBaseService) FindPageByParams(params *params.QueryParams) (list []models.KnowledgeBase, paging *sqls.Paging) { + return repositories.KnowledgeBaseRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *knowledgeBaseService) FindPageByCnd(cnd *sqls.Cnd) (list []models.KnowledgeBase, paging *sqls.Paging) { + return repositories.KnowledgeBaseRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *knowledgeBaseService) Count(cnd *sqls.Cnd) int64 { + return repositories.KnowledgeBaseRepository.Count(sqls.DB(), cnd) +} + +func (s *knowledgeBaseService) Create(t *models.KnowledgeBase) error { + return repositories.KnowledgeBaseRepository.Create(sqls.DB(), t) +} + +func (s *knowledgeBaseService) Update(t *models.KnowledgeBase) error { + return repositories.KnowledgeBaseRepository.Update(sqls.DB(), t) +} + +func (s *knowledgeBaseService) Updates(id int64, columns map[string]interface{}) error { + return repositories.KnowledgeBaseRepository.Updates(sqls.DB(), id, columns) +} + +func (s *knowledgeBaseService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.KnowledgeBaseRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *knowledgeBaseService) Delete(id int64) { + repositories.KnowledgeBaseRepository.Delete(sqls.DB(), id) +} + +func (s *knowledgeBaseService) CreateKnowledgeBase(req request.CreateKnowledgeBaseRequest, operator *dto.AuthPrincipal) (*models.KnowledgeBase, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + item, err := s.buildKnowledgeBaseModel(req) + if err != nil { + return nil, err + } + item.Status = enums.StatusOk + item.AuditFields = utils.BuildAuditFields(operator) + if err := repositories.KnowledgeBaseRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return item, nil +} + +func (s *knowledgeBaseService) UpdateKnowledgeBase(req request.UpdateKnowledgeBaseRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + current := s.Get(req.ID) + if current == nil { + return errorsx.InvalidParamI18n("error.e0283") + } + item, err := s.buildKnowledgeBaseModel(req.CreateKnowledgeBaseRequest) + if err != nil { + return err + } + return repositories.KnowledgeBaseRepository.Updates(sqls.DB(), req.ID, map[string]any{ + "name": item.Name, + "description": item.Description, + "knowledge_type": item.KnowledgeType, + "default_top_k": item.DefaultTopK, + "default_score_threshold": item.DefaultScoreThreshold, + "default_rerank_limit": item.DefaultRerankLimit, + "chunk_provider": item.ChunkProvider, + "chunk_target_tokens": item.ChunkTargetTokens, + "chunk_max_tokens": item.ChunkMaxTokens, + "chunk_overlap_tokens": item.ChunkOverlapTokens, + "answer_mode": item.AnswerMode, + "remark": item.Remark, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) +} + +func (s *knowledgeBaseService) DeleteKnowledgeBase(id int64) error { + current := s.Get(id) + if current == nil { + return errorsx.InvalidParamI18n("error.e0283") + } + + referencingWorkflows := s.findWorkflowReferencesByKnowledgeBaseID(id) + if len(referencingWorkflows) > 0 { + if len(referencingWorkflows) == 1 { + return errorsx.Forbidden(fmt.Sprintf("知识库正在被流程「%s」使用,请先从知识检索节点中移除", referencingWorkflows[0])) + } + return errorsx.Forbidden(fmt.Sprintf("知识库正在被 %d 个流程使用,请先从知识检索节点中移除", len(referencingWorkflows))) + } + + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.KnowledgeDocumentRepository.DeleteByKnowledgeBaseID(ctx.Tx, id); err != nil { + return err + } + if err := repositories.KnowledgeFAQRepository.DeleteByKnowledgeBaseID(ctx.Tx, id); err != nil { + return err + } + return ctx.Tx.Delete(&models.KnowledgeBase{}, "id = ?", id).Error + }); err != nil { + return err + } + + return rag.Index.RemoveKnowledgeBaseIndex(context.Background(), id) +} + +func (s *knowledgeBaseService) findWorkflowReferencesByKnowledgeBaseID(id int64) []string { + names := make(map[string]struct{}) + workflows := repositories.AIWorkflowRepository.Find(sqls.DB(), sqls.NewCnd().Eq("status", enums.StatusOk)) + workflowNames := make(map[int64]string, len(workflows)) + for _, workflow := range workflows { + name := strings.TrimSpace(workflow.Name) + if name == "" { + name = fmt.Sprintf("ID %d", workflow.ID) + } + workflowNames[workflow.ID] = name + if workflowDefinitionUsesKnowledgeBase(workflow.DraftDefinition, id) { + names[name] = struct{}{} + } + } + versions := repositories.AIWorkflowVersionRepository.Find(sqls.DB(), sqls.NewCnd()) + for _, version := range versions { + if !workflowDefinitionUsesKnowledgeBase(version.Definition, id) { + continue + } + name := workflowNames[version.WorkflowID] + if strings.TrimSpace(name) == "" { + name = fmt.Sprintf("ID %d", version.WorkflowID) + } + names[name] = struct{}{} + } + ret := make([]string, 0, len(names)) + for name := range names { + ret = append(ret, name) + } + return ret +} + +func workflowDefinitionUsesKnowledgeBase(definition string, id int64) bool { + definition = strings.TrimSpace(definition) + if definition == "" { + return false + } + var def dsl.Definition + if err := json.Unmarshal([]byte(definition), &def); err != nil { + return false + } + for _, node := range def.Nodes { + if strings.TrimSpace(node.Type) != workflowregistry.NodeTypeKnowledgeRetrieve { + continue + } + for _, knowledgeBaseID := range knowledgeBaseIDsFromWorkflowNodeConfig(node.Data.Config) { + if knowledgeBaseID == id { + return true + } + } + } + return false +} + +func knowledgeBaseIDsFromWorkflowNodeConfig(raw json.RawMessage) []int64 { + if len(raw) == 0 { + return nil + } + var cfg map[string]any + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil + } + items, ok := cfg["knowledgeBaseIds"].([]any) + if !ok { + return nil + } + ret := make([]int64, 0, len(items)) + for _, item := range items { + switch value := item.(type) { + case float64: + ret = append(ret, int64(value)) + case int64: + ret = append(ret, value) + case int: + ret = append(ret, int64(value)) + } + } + return ret +} + +func (s *knowledgeBaseService) UpdateSort(ids []int64) error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + for i, id := range ids { + if err := repositories.KnowledgeBaseRepository.UpdateColumn(ctx.Tx, id, "sort_no", i); err != nil { + return err + } + } + return nil + }) +} + +func (s *knowledgeBaseService) buildKnowledgeBaseModel(req request.CreateKnowledgeBaseRequest) (*models.KnowledgeBase, error) { + item := &models.KnowledgeBase{ + Name: req.Name, + Description: req.Description, + KnowledgeType: req.KnowledgeType, + DefaultTopK: req.DefaultTopK, + DefaultScoreThreshold: req.DefaultScoreThreshold, + DefaultRerankLimit: req.DefaultRerankLimit, + ChunkProvider: req.ChunkProvider, + ChunkTargetTokens: req.ChunkTargetTokens, + ChunkMaxTokens: req.ChunkMaxTokens, + ChunkOverlapTokens: req.ChunkOverlapTokens, + AnswerMode: req.AnswerMode, + Remark: req.Remark, + } + if item.DefaultTopK == 0 { + item.DefaultTopK = 10 + } + if item.KnowledgeType == "" { + item.KnowledgeType = string(enums.KnowledgeBaseTypeDocument) + } + if !isValidKnowledgeType(item.KnowledgeType) { + return nil, errorsx.InvalidParamI18n("error.e0290") + } + if item.DefaultScoreThreshold == 0 { + item.DefaultScoreThreshold = 0.2 + } + if item.DefaultRerankLimit == 0 { + item.DefaultRerankLimit = 5 + } + if item.ChunkProvider == "" { + item.ChunkProvider = string(enums.KnowledgeChunkProviderStructured) + } + if item.KnowledgeType == string(enums.KnowledgeBaseTypeFAQ) { + item.ChunkProvider = string(enums.KnowledgeChunkProviderFAQ) + item.ChunkTargetTokens = 0 + item.ChunkMaxTokens = 0 + item.ChunkOverlapTokens = 0 + } else if item.ChunkProvider == string(enums.KnowledgeChunkProviderFAQ) { + return nil, errorsx.InvalidParamI18n("error.e0219") + } + if !isValidChunkProvider(item.ChunkProvider) { + return nil, errorsx.InvalidParamI18n("error.e0130") + } + if item.KnowledgeType != string(enums.KnowledgeBaseTypeFAQ) && item.ChunkTargetTokens == 0 { + item.ChunkTargetTokens = 300 + } + if item.KnowledgeType != string(enums.KnowledgeBaseTypeFAQ) && item.ChunkMaxTokens == 0 { + item.ChunkMaxTokens = 400 + } + if item.KnowledgeType != string(enums.KnowledgeBaseTypeFAQ) && item.ChunkMaxTokens < item.ChunkTargetTokens { + item.ChunkMaxTokens = item.ChunkTargetTokens + } + if item.KnowledgeType != string(enums.KnowledgeBaseTypeFAQ) && item.ChunkOverlapTokens == 0 { + item.ChunkOverlapTokens = 40 + } + if item.AnswerMode == 0 { + item.AnswerMode = 1 + } + return item, nil +} + +func isValidChunkProvider(provider string) bool { + switch provider { + case string(enums.KnowledgeChunkProviderFixed), + string(enums.KnowledgeChunkProviderStructured), + string(enums.KnowledgeChunkProviderFAQ), + string(enums.KnowledgeChunkProviderSemantic): + return true + default: + return false + } +} + +func isValidKnowledgeType(knowledgeType string) bool { + switch knowledgeType { + case string(enums.KnowledgeBaseTypeDocument), string(enums.KnowledgeBaseTypeFAQ): + return true + default: + return false + } +} diff --git a/internal/services/knowledge_base_service_test.go b/internal/services/knowledge_base_service_test.go new file mode 100644 index 0000000..7e5410d --- /dev/null +++ b/internal/services/knowledge_base_service_test.go @@ -0,0 +1,205 @@ +package services + +import ( + "encoding/json" + "strings" + "testing" + + "agent-desk/internal/ai/workflow/dsl" + workflowregistry "agent-desk/internal/ai/workflow/registry" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func TestBuildKnowledgeBaseModelUsesLowerDefaultScoreThreshold(t *testing.T) { + item, err := KnowledgeBaseService.buildKnowledgeBaseModel(request.CreateKnowledgeBaseRequest{}) + if err != nil { + t.Fatalf("build knowledge base model failed: %v", err) + } + if item.DefaultScoreThreshold != 0.2 { + t.Fatalf("expected default score threshold 0.2, got %v", item.DefaultScoreThreshold) + } +} + +func TestDeleteKnowledgeBaseRejectsWorkflowDraftReference(t *testing.T) { + setupKnowledgeBaseServiceTestDB(t) + kb := createKnowledgeBaseServiceTestBase(t, "Referenced KB") + otherKB := createKnowledgeBaseServiceTestBase(t, "Other KB") + createKnowledgeBaseServiceTestWorkflow(t, "Support Workflow", knowledgeBaseServiceTestWorkflowDefinition([]int64{12, otherKB.ID})) + createKnowledgeBaseServiceTestWorkflow(t, "Knowledge Workflow", knowledgeBaseServiceTestWorkflowDefinition([]int64{12, kb.ID, otherKB.ID})) + + err := KnowledgeBaseService.DeleteKnowledgeBase(kb.ID) + if err == nil { + t.Fatal("DeleteKnowledgeBase() error is nil, want referenced workflow error") + } + if got := err.Error(); !strings.Contains(got, "Knowledge Workflow") { + t.Fatalf("DeleteKnowledgeBase() error = %q, want workflow name", got) + } + if repositories.KnowledgeBaseRepository.Get(sqls.DB(), kb.ID) == nil { + t.Fatal("knowledge base was deleted despite workflow reference") + } +} + +func TestDeleteKnowledgeBaseRejectsWorkflowVersionReference(t *testing.T) { + setupKnowledgeBaseServiceTestDB(t) + kb := createKnowledgeBaseServiceTestBase(t, "Version KB") + workflow := createKnowledgeBaseServiceTestWorkflow(t, "Published Workflow", knowledgeBaseServiceTestWorkflowDefinition([]int64{999})) + raw, err := json.Marshal(knowledgeBaseServiceTestWorkflowDefinition([]int64{kb.ID})) + if err != nil { + t.Fatalf("marshal workflow version definition: %v", err) + } + if err := repositories.AIWorkflowVersionRepository.Create(sqls.DB(), &models.AIWorkflowVersion{ + WorkflowID: workflow.ID, + Version: 1, + Status: enums.StatusOk, + Definition: string(raw), + }); err != nil { + t.Fatalf("create workflow version: %v", err) + } + + err = KnowledgeBaseService.DeleteKnowledgeBase(kb.ID) + if err == nil { + t.Fatal("DeleteKnowledgeBase() error is nil, want referenced workflow version error") + } + if got := err.Error(); !strings.Contains(got, "Published Workflow") { + t.Fatalf("DeleteKnowledgeBase() error = %q, want workflow name", got) + } +} + +func TestDeleteKnowledgeBaseCascadesContentWhenNotReferenced(t *testing.T) { + setupKnowledgeBaseServiceTestDB(t) + kb := createKnowledgeBaseServiceTestBase(t, "Delete KB") + document := &models.KnowledgeDocument{ + KnowledgeBaseID: kb.ID, + Title: "Doc", + ContentType: enums.KnowledgeDocumentContentTypeMarkdown, + Content: "content", + Status: enums.StatusOk, + IndexStatus: enums.KnowledgeDocumentIndexStatusIndexed, + } + if err := repositories.KnowledgeDocumentRepository.Create(sqls.DB(), document); err != nil { + t.Fatalf("create document: %v", err) + } + faq := &models.KnowledgeFAQ{ + KnowledgeBaseID: kb.ID, + Question: "Question", + Answer: "Answer", + Status: enums.StatusOk, + IndexStatus: enums.KnowledgeDocumentIndexStatusIndexed, + } + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), faq); err != nil { + t.Fatalf("create faq: %v", err) + } + if err := repositories.KnowledgeChunkRepository.BatchCreate(sqls.DB(), []models.KnowledgeChunk{ + {KnowledgeBaseID: kb.ID, DocumentID: document.ID, ChunkNo: 1, Status: enums.StatusOk}, + {KnowledgeBaseID: kb.ID, FaqID: faq.ID, ChunkNo: 1, Status: enums.StatusOk}, + }); err != nil { + t.Fatalf("create chunks: %v", err) + } + + if err := KnowledgeBaseService.DeleteKnowledgeBase(kb.ID); err != nil { + t.Fatalf("DeleteKnowledgeBase() error = %v", err) + } + + assertKnowledgeBaseServiceTestCount(t, &models.KnowledgeBase{}, "id = ?", kb.ID, 0) + assertKnowledgeBaseServiceTestCount(t, &models.KnowledgeDocument{}, "knowledge_base_id = ?", kb.ID, 0) + assertKnowledgeBaseServiceTestCount(t, &models.KnowledgeFAQ{}, "knowledge_base_id = ?", kb.ID, 0) + assertKnowledgeBaseServiceTestCount(t, &models.KnowledgeChunk{}, "knowledge_base_id = ?", kb.ID, 0) +} + +func setupKnowledgeBaseServiceTestDB(t *testing.T) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate(&models.KnowledgeBase{}, &models.KnowledgeDocument{}, &models.KnowledgeFAQ{}, &models.KnowledgeChunk{}, &models.AIAgent{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) +} + +func createKnowledgeBaseServiceTestWorkflow(t *testing.T, name string, definition dsl.Definition) *models.AIWorkflow { + t.Helper() + raw, err := json.Marshal(definition) + if err != nil { + t.Fatalf("marshal workflow definition: %v", err) + } + item := &models.AIWorkflow{ + Name: name, + Status: enums.StatusOk, + DraftDefinition: string(raw), + } + if err := repositories.AIWorkflowRepository.Create(sqls.DB(), item); err != nil { + t.Fatalf("create workflow: %v", err) + } + return item +} + +func knowledgeBaseServiceTestWorkflowDefinition(knowledgeBaseIDs []int64) dsl.Definition { + return dsl.Definition{ + SchemaVersion: dsl.SchemaVersion, + Nodes: []dsl.Node{ + { + ID: "start_1", + Type: workflowregistry.NodeTypeStart, + }, + { + ID: "retrieve_1", + Type: workflowregistry.NodeTypeKnowledgeRetrieve, + Data: dsl.NodeData{ + Config: mustKnowledgeBaseServiceTestJSON(map[string]any{"knowledgeBaseIds": knowledgeBaseIDs}), + InputsValues: map[string]dsl.Value{ + "query": dsl.RefValue("start_1", "userMessage"), + }, + }, + }, + { + ID: "end_1", + Type: workflowregistry.NodeTypeEnd, + }, + }, + Edges: []dsl.Edge{ + {SourceNodeID: "start_1", TargetNodeID: "retrieve_1"}, + {SourceNodeID: "retrieve_1", TargetNodeID: "end_1"}, + }, + } +} + +func mustKnowledgeBaseServiceTestJSON(value any) json.RawMessage { + raw, err := json.Marshal(value) + if err != nil { + panic(err) + } + return raw +} + +func createKnowledgeBaseServiceTestBase(t *testing.T, name string) *models.KnowledgeBase { + t.Helper() + item := &models.KnowledgeBase{ + Name: name, + KnowledgeType: string(enums.KnowledgeBaseTypeDocument), + Status: enums.StatusOk, + } + if err := repositories.KnowledgeBaseRepository.Create(sqls.DB(), item); err != nil { + t.Fatalf("create knowledge base: %v", err) + } + return item +} + +func assertKnowledgeBaseServiceTestCount(t *testing.T, model any, query string, arg any, want int64) { + t.Helper() + var count int64 + if err := sqls.DB().Model(model).Where(query, arg).Count(&count).Error; err != nil { + t.Fatalf("count %T: %v", model, err) + } + if count != want { + t.Fatalf("count %T = %d, want %d", model, count, want) + } +} diff --git a/internal/services/knowledge_directory_service.go b/internal/services/knowledge_directory_service.go new file mode 100644 index 0000000..488f5e5 --- /dev/null +++ b/internal/services/knowledge_directory_service.go @@ -0,0 +1,212 @@ +package services + +import ( + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +var KnowledgeDirectoryService = newKnowledgeDirectoryService() + +func newKnowledgeDirectoryService() *knowledgeDirectoryService { + return &knowledgeDirectoryService{} +} + +type knowledgeDirectoryService struct { +} + +func (s *knowledgeDirectoryService) Get(id int64) *models.KnowledgeDirectory { + return repositories.KnowledgeDirectoryRepository.Get(sqls.DB(), id) +} + +func (s *knowledgeDirectoryService) Find(cnd *sqls.Cnd) []models.KnowledgeDirectory { + return repositories.KnowledgeDirectoryRepository.Find(sqls.DB(), cnd) +} + +func (s *knowledgeDirectoryService) FindAllByKnowledgeBaseID(knowledgeBaseID int64) []models.KnowledgeDirectory { + return s.Find(sqls.NewCnd().Eq("knowledge_base_id", knowledgeBaseID).Asc("parent_id").Asc("sort_no").Asc("id")) +} + +func (s *knowledgeDirectoryService) Count(cnd *sqls.Cnd) int64 { + return repositories.KnowledgeDirectoryRepository.Count(sqls.DB(), cnd) +} + +func (s *knowledgeDirectoryService) CreateDirectory(req request.CreateKnowledgeDirectoryRequest, operator *dto.AuthPrincipal) (*models.KnowledgeDirectory, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errorsx.InvalidParamI18n("error.e0275") + } + if req.KnowledgeBaseID <= 0 || KnowledgeBaseService.Get(req.KnowledgeBaseID) == nil { + return nil, errorsx.InvalidParamI18n("error.e0283") + } + if err := s.validateParent(req.KnowledgeBaseID, req.ParentID, 0); err != nil { + return nil, err + } + if existing := s.findByName(req.KnowledgeBaseID, req.ParentID, name); existing != nil { + return nil, errorsx.InvalidParamI18n("error.e0142") + } + item := &models.KnowledgeDirectory{ + KnowledgeBaseID: req.KnowledgeBaseID, + ParentID: req.ParentID, + Name: name, + SortNo: s.NextSortNo(req.KnowledgeBaseID, req.ParentID), + Status: enums.StatusOk, + Remark: strings.TrimSpace(req.Remark), + AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.KnowledgeDirectoryRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return item, nil +} + +func (s *knowledgeDirectoryService) UpdateDirectory(req request.UpdateKnowledgeDirectoryRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + item := s.Get(req.ID) + if item == nil { + return errorsx.InvalidParamI18n("error.e0273") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return errorsx.InvalidParamI18n("error.e0275") + } + if req.KnowledgeBaseID <= 0 { + req.KnowledgeBaseID = item.KnowledgeBaseID + } + if req.KnowledgeBaseID != item.KnowledgeBaseID { + return errorsx.InvalidParamI18n("error.e0274") + } + if err := s.validateParent(item.KnowledgeBaseID, req.ParentID, req.ID); err != nil { + return err + } + if req.ParentID > 0 && s.Count(sqls.NewCnd().Eq("parent_id", req.ID)) > 0 { + return errorsx.InvalidParamI18n("error.e0152") + } + if existing := s.findByName(item.KnowledgeBaseID, req.ParentID, name); existing != nil && existing.ID != req.ID { + return errorsx.InvalidParamI18n("error.e0142") + } + return repositories.KnowledgeDirectoryRepository.Updates(sqls.DB(), req.ID, map[string]any{ + "parent_id": req.ParentID, + "name": name, + "remark": strings.TrimSpace(req.Remark), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) +} + +func (s *knowledgeDirectoryService) DeleteDirectory(id int64) error { + item := s.Get(id) + if item == nil { + return errorsx.InvalidParamI18n("error.e0273") + } + if s.Count(sqls.NewCnd().Eq("parent_id", id)) > 0 { + return errorsx.InvalidParamI18n("error.e0316") + } + if repositories.KnowledgeDocumentRepository.CountActiveByDirectoryID(sqls.DB(), id) > 0 { + return errorsx.InvalidParamI18n("error.e0317") + } + if repositories.KnowledgeFAQRepository.CountActiveByDirectoryID(sqls.DB(), id) > 0 { + return errorsx.InvalidParamI18n("error.e0315") + } + return repositories.KnowledgeDirectoryRepository.Delete(sqls.DB(), id) +} + +func (s *knowledgeDirectoryService) UpdateSort(knowledgeBaseID int64, parentID int64, ids []int64) error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + for i, id := range ids { + item := repositories.KnowledgeDirectoryRepository.Get(ctx.Tx, id) + if item == nil { + return errorsx.InvalidParamI18n("error.e0273") + } + if item.KnowledgeBaseID != knowledgeBaseID || item.ParentID != parentID { + return errorsx.InvalidParamI18n("error.e0140") + } + if err := repositories.KnowledgeDirectoryRepository.UpdateColumn(ctx.Tx, id, "sort_no", i+1); err != nil { + return err + } + } + return nil + }) +} + +func (s *knowledgeDirectoryService) RequireUsableDirectory(knowledgeBaseID int64, directoryID int64) (*models.KnowledgeDirectory, error) { + if directoryID <= 0 { + return nil, nil + } + item := s.Get(directoryID) + if item == nil { + return nil, errorsx.InvalidParamI18n("error.e0287") + } + if item.KnowledgeBaseID != knowledgeBaseID { + return nil, errorsx.InvalidParamI18n("error.e0288") + } + if item.Status != enums.StatusOk { + return nil, errorsx.InvalidParamI18n("error.e0286") + } + return item, nil +} + +func (s *knowledgeDirectoryService) NextSortNo(knowledgeBaseID int64, parentID int64) int { + if temp := repositories.KnowledgeDirectoryRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("knowledge_base_id", knowledgeBaseID).Eq("parent_id", parentID).Desc("sort_no").Desc("id")); temp != nil { + return temp.SortNo + 1 + } + return 1 +} + +func (s *knowledgeDirectoryService) PathMap(knowledgeBaseID int64) map[int64]string { + items := s.FindAllByKnowledgeBaseID(knowledgeBaseID) + byID := make(map[int64]models.KnowledgeDirectory, len(items)) + for _, item := range items { + byID[item.ID] = item + } + ret := make(map[int64]string, len(items)) + for _, item := range items { + if item.ParentID > 0 { + if parent, ok := byID[item.ParentID]; ok { + ret[item.ID] = parent.Name + " / " + item.Name + continue + } + } + ret[item.ID] = item.Name + } + return ret +} + +func (s *knowledgeDirectoryService) validateParent(knowledgeBaseID int64, parentID int64, selfID int64) error { + if parentID <= 0 { + return nil + } + if parentID == selfID { + return errorsx.InvalidParamI18n("error.e0084") + } + parent := s.Get(parentID) + if parent == nil { + return errorsx.InvalidParamI18n("error.e0252") + } + if parent.KnowledgeBaseID != knowledgeBaseID { + return errorsx.InvalidParamI18n("error.e0253") + } + if parent.ParentID > 0 { + return errorsx.InvalidParamI18n("error.e0289") + } + return nil +} + +func (s *knowledgeDirectoryService) findByName(knowledgeBaseID int64, parentID int64, name string) *models.KnowledgeDirectory { + return repositories.KnowledgeDirectoryRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("knowledge_base_id", knowledgeBaseID).Eq("parent_id", parentID).Eq("name", name)) +} diff --git a/internal/services/knowledge_directory_service_test.go b/internal/services/knowledge_directory_service_test.go new file mode 100644 index 0000000..e12eee5 --- /dev/null +++ b/internal/services/knowledge_directory_service_test.go @@ -0,0 +1,153 @@ +package services + +import ( + "testing" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func TestKnowledgeDirectoryRejectsThirdLevel(t *testing.T) { + setupKnowledgeDirectoryTestDB(t) + operator := knowledgeDirectoryTestOperator() + kb := createKnowledgeDirectoryTestBase(t, "Document KB", string(enums.KnowledgeBaseTypeDocument)) + parent, err := KnowledgeDirectoryService.CreateDirectory(request.CreateKnowledgeDirectoryRequest{ + KnowledgeBaseID: kb.ID, + Name: "一级目录", + }, operator) + if err != nil { + t.Fatalf("create parent: %v", err) + } + child, err := KnowledgeDirectoryService.CreateDirectory(request.CreateKnowledgeDirectoryRequest{ + KnowledgeBaseID: kb.ID, + ParentID: parent.ID, + Name: "二级目录", + }, operator) + if err != nil { + t.Fatalf("create child: %v", err) + } + + if _, err := KnowledgeDirectoryService.CreateDirectory(request.CreateKnowledgeDirectoryRequest{ + KnowledgeBaseID: kb.ID, + ParentID: child.ID, + Name: "三级目录", + }, operator); err == nil { + t.Fatal("CreateDirectory() error is nil, want third-level rejection") + } +} + +func TestKnowledgeDocumentRejectsDirectoryFromOtherKnowledgeBase(t *testing.T) { + setupKnowledgeDirectoryTestDB(t) + operator := knowledgeDirectoryTestOperator() + kb := createKnowledgeDirectoryTestBase(t, "Document KB", string(enums.KnowledgeBaseTypeDocument)) + otherKB := createKnowledgeDirectoryTestBase(t, "Other KB", string(enums.KnowledgeBaseTypeDocument)) + directory, err := KnowledgeDirectoryService.CreateDirectory(request.CreateKnowledgeDirectoryRequest{ + KnowledgeBaseID: otherKB.ID, + Name: "其他目录", + }, operator) + if err != nil { + t.Fatalf("create directory: %v", err) + } + + _, err = KnowledgeDocumentService.CreateKnowledgeDocument(request.CreateKnowledgeDocumentRequest{ + KnowledgeBaseID: kb.ID, + DirectoryID: directory.ID, + Title: "Doc", + Content: "content", + }, operator) + if err == nil { + t.Fatal("CreateKnowledgeDocument() error is nil, want cross-knowledge-base directory rejection") + } +} + +func TestKnowledgeDirectoryDeleteRejectsAttachedContent(t *testing.T) { + setupKnowledgeDirectoryTestDB(t) + operator := knowledgeDirectoryTestOperator() + kb := createKnowledgeDirectoryTestBase(t, "FAQ KB", string(enums.KnowledgeBaseTypeFAQ)) + directory, err := KnowledgeDirectoryService.CreateDirectory(request.CreateKnowledgeDirectoryRequest{ + KnowledgeBaseID: kb.ID, + Name: "FAQ目录", + }, operator) + if err != nil { + t.Fatalf("create directory: %v", err) + } + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), &models.KnowledgeFAQ{ + KnowledgeBaseID: kb.ID, + DirectoryID: directory.ID, + Question: "Question", + Answer: "Answer", + Status: enums.StatusOk, + }); err != nil { + t.Fatalf("create faq: %v", err) + } + + if err := KnowledgeDirectoryService.DeleteDirectory(directory.ID); err == nil { + t.Fatal("DeleteDirectory() error is nil, want attached content rejection") + } +} + +func TestKnowledgeDirectoryDeleteAllowsSoftDeletedDocument(t *testing.T) { + setupKnowledgeDirectoryTestDB(t) + operator := knowledgeDirectoryTestOperator() + kb := createKnowledgeDirectoryTestBase(t, "Document KB", string(enums.KnowledgeBaseTypeDocument)) + directory, err := KnowledgeDirectoryService.CreateDirectory(request.CreateKnowledgeDirectoryRequest{ + KnowledgeBaseID: kb.ID, + Name: "已清空目录", + }, operator) + if err != nil { + t.Fatalf("create directory: %v", err) + } + if err := repositories.KnowledgeDocumentRepository.Create(sqls.DB(), &models.KnowledgeDocument{ + KnowledgeBaseID: kb.ID, + DirectoryID: directory.ID, + Title: "Deleted Doc", + ContentType: enums.KnowledgeDocumentContentTypeMarkdown, + Status: enums.StatusDeleted, + IndexStatus: enums.KnowledgeDocumentIndexStatusPending, + }); err != nil { + t.Fatalf("create soft deleted document: %v", err) + } + + if err := KnowledgeDirectoryService.DeleteDirectory(directory.ID); err != nil { + t.Fatalf("DeleteDirectory() error = %v, want nil when only soft deleted documents exist", err) + } +} + +func setupKnowledgeDirectoryTestDB(t *testing.T) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate(&models.KnowledgeBase{}, &models.KnowledgeDirectory{}, &models.KnowledgeDocument{}, &models.KnowledgeFAQ{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) +} + +func createKnowledgeDirectoryTestBase(t *testing.T, name string, knowledgeType string) *models.KnowledgeBase { + t.Helper() + item := &models.KnowledgeBase{ + Name: name, + KnowledgeType: knowledgeType, + Status: enums.StatusOk, + } + if err := repositories.KnowledgeBaseRepository.Create(sqls.DB(), item); err != nil { + t.Fatalf("create knowledge base: %v", err) + } + return item +} + +func knowledgeDirectoryTestOperator() *dto.AuthPrincipal { + return &dto.AuthPrincipal{ + UserID: 1, + Username: "tester", + } +} diff --git a/internal/services/knowledge_document_service.go b/internal/services/knowledge_document_service.go new file mode 100644 index 0000000..0e70047 --- /dev/null +++ b/internal/services/knowledge_document_service.go @@ -0,0 +1,290 @@ +package services + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "log/slog" + "time" + + "agent-desk/internal/ai/rag" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/common/strs" + "github.com/mlogclub/simple/sqls" +) + +var KnowledgeDocumentService = newKnowledgeDocumentService() + +func newKnowledgeDocumentService() *knowledgeDocumentService { + return &knowledgeDocumentService{} +} + +type knowledgeDocumentService struct { +} + +func (s *knowledgeDocumentService) Get(id int64) *models.KnowledgeDocument { + return repositories.KnowledgeDocumentRepository.Get(sqls.DB(), id) +} + +func (s *knowledgeDocumentService) Take(where ...interface{}) *models.KnowledgeDocument { + return repositories.KnowledgeDocumentRepository.Take(sqls.DB(), where...) +} + +func (s *knowledgeDocumentService) Find(cnd *sqls.Cnd) []models.KnowledgeDocument { + return repositories.KnowledgeDocumentRepository.Find(sqls.DB(), cnd) +} + +func (s *knowledgeDocumentService) FindOne(cnd *sqls.Cnd) *models.KnowledgeDocument { + return repositories.KnowledgeDocumentRepository.FindOne(sqls.DB(), cnd) +} + +func (s *knowledgeDocumentService) FindPageByParams(params *params.QueryParams) (list []models.KnowledgeDocument, paging *sqls.Paging) { + return repositories.KnowledgeDocumentRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *knowledgeDocumentService) FindPageByCnd(cnd *sqls.Cnd) (list []models.KnowledgeDocument, paging *sqls.Paging) { + return repositories.KnowledgeDocumentRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *knowledgeDocumentService) FindPageListByCnd(cnd *sqls.Cnd) (list []models.KnowledgeDocument, paging *sqls.Paging) { + return repositories.KnowledgeDocumentRepository.FindPageListByCnd(sqls.DB(), cnd) +} + +func (s *knowledgeDocumentService) Count(cnd *sqls.Cnd) int64 { + return repositories.KnowledgeDocumentRepository.Count(sqls.DB(), cnd) +} + +func (s *knowledgeDocumentService) Create(t *models.KnowledgeDocument) error { + return repositories.KnowledgeDocumentRepository.Create(sqls.DB(), t) +} + +func (s *knowledgeDocumentService) Update(t *models.KnowledgeDocument) error { + return repositories.KnowledgeDocumentRepository.Update(sqls.DB(), t) +} + +func (s *knowledgeDocumentService) Updates(id int64, columns map[string]interface{}) error { + return repositories.KnowledgeDocumentRepository.Updates(sqls.DB(), id, columns) +} + +func (s *knowledgeDocumentService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.KnowledgeDocumentRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *knowledgeDocumentService) Delete(id int64) { + repositories.KnowledgeDocumentRepository.Delete(sqls.DB(), id) +} + +func (s *knowledgeDocumentService) CreateKnowledgeDocument(req request.CreateKnowledgeDocumentRequest, operator *dto.AuthPrincipal) (*models.KnowledgeDocument, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + kb := KnowledgeBaseService.Get(req.KnowledgeBaseID) + if kb == nil { + return nil, errorsx.InvalidParamI18n("error.e0283") + } + if kb.KnowledgeType == string(enums.KnowledgeBaseTypeFAQ) { + return nil, errorsx.InvalidParamI18n("error.e0026") + } + if _, err := KnowledgeDirectoryService.RequireUsableDirectory(req.KnowledgeBaseID, req.DirectoryID); err != nil { + return nil, err + } + item, err := s.buildKnowledgeDocumentModel(req) + if err != nil { + return nil, err + } + item.Status = enums.StatusOk + item.IndexStatus = enums.KnowledgeDocumentIndexStatusPending + item.IndexError = "" + item.IndexedAt = nil + item.AuditFields = utils.BuildAuditFields(operator) + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := ctx.Tx.Create(item).Error; err != nil { + return err + } + return nil + }); err != nil { + return nil, err + } + if err := rag.Index.IndexDocumentByID(context.Background(), item.ID); err != nil { + slog.Error("failed to index created knowledge document", "document_id", item.ID, "error", err) + } + item = s.Get(item.ID) + return item, nil +} + +func (s *knowledgeDocumentService) UpdateKnowledgeDocument(req request.UpdateKnowledgeDocumentRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + current := s.Get(req.ID) + if current == nil { + return errorsx.InvalidParamI18n("error.e0218") + } + kb := KnowledgeBaseService.Get(req.KnowledgeBaseID) + if kb == nil { + return errorsx.InvalidParamI18n("error.e0283") + } + if kb.KnowledgeType == string(enums.KnowledgeBaseTypeFAQ) { + return errorsx.InvalidParamI18n("error.e0026") + } + if _, err := KnowledgeDirectoryService.RequireUsableDirectory(req.KnowledgeBaseID, req.DirectoryID); err != nil { + return err + } + item, err := s.buildKnowledgeDocumentModel(req.CreateKnowledgeDocumentRequest) + if err != nil { + return err + } + oldKnowledgeBaseID := current.KnowledgeBaseID + if err := repositories.KnowledgeDocumentRepository.Updates(sqls.DB(), req.ID, map[string]any{ + "knowledge_base_id": item.KnowledgeBaseID, + "directory_id": item.DirectoryID, + "title": item.Title, + "content_type": item.ContentType, + "content_hash": item.ContentHash, + "content": item.Content, + "index_status": enums.KnowledgeDocumentIndexStatusPending, + "indexed_at": nil, + "index_error": "", + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }); err != nil { + return err + } + + if oldKnowledgeBaseID != item.KnowledgeBaseID { + if err := rag.Index.RemoveDocumentIndex(context.Background(), req.ID); err != nil { + slog.Error("failed to remove old document index after knowledge base change", "document_id", req.ID, "knowledge_base_id", oldKnowledgeBaseID, "error", err) + } + } + + if err := rag.Index.IndexDocumentByID(context.Background(), req.ID); err != nil { + slog.Error("failed to reindex updated knowledge document", "document_id", req.ID, "error", err) + } + return nil +} + +func (s *knowledgeDocumentService) DeleteKnowledgeDocument(id int64) error { + if err := repositories.KnowledgeDocumentRepository.Updates(sqls.DB(), id, map[string]any{ + "status": enums.StatusDeleted, + "updated_at": time.Now(), + }); err != nil { + return err + } + return rag.Index.RemoveDocumentIndex(context.Background(), id) +} + +func (s *knowledgeDocumentService) BatchMoveKnowledgeDocuments(req request.BatchMoveKnowledgeDocumentRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + ids := uniquePositiveIDs(req.IDs) + if len(ids) == 0 { + return errorsx.InvalidParamI18n("error.e0333") + } + kb := KnowledgeBaseService.Get(req.KnowledgeBaseID) + if kb == nil { + return errorsx.InvalidParamI18n("error.e0283") + } + if kb.KnowledgeType == string(enums.KnowledgeBaseTypeFAQ) { + return errorsx.InvalidParamI18n("error.e0026") + } + if _, err := KnowledgeDirectoryService.RequireUsableDirectory(req.KnowledgeBaseID, req.DirectoryID); err != nil { + return err + } + for _, id := range ids { + current := s.Get(id) + if current == nil || current.Status == enums.StatusDeleted { + return errorsx.InvalidParamI18n("error.e0218") + } + if current.KnowledgeBaseID != req.KnowledgeBaseID { + return errorsx.InvalidParamI18n("error.e0139") + } + } + now := time.Now() + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + for _, id := range ids { + if err := repositories.KnowledgeDocumentRepository.Updates(ctx.Tx, id, map[string]any{ + "directory_id": req.DirectoryID, + "index_status": enums.KnowledgeDocumentIndexStatusPending, + "indexed_at": nil, + "index_error": "", + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + } + return nil + }); err != nil { + return err + } + for _, id := range ids { + if err := rag.Index.IndexDocumentByID(context.Background(), id); err != nil { + slog.Error("failed to reindex moved knowledge document", "document_id", id, "error", err) + } + } + return nil +} + +func (s *knowledgeDocumentService) BatchDeleteKnowledgeDocuments(req request.BatchDeleteKnowledgeDocumentRequest) error { + ids := uniquePositiveIDs(req.IDs) + if len(ids) == 0 { + return errorsx.InvalidParamI18n("error.e0331") + } + for _, id := range ids { + if err := s.DeleteKnowledgeDocument(id); err != nil { + return err + } + } + return nil +} + +func (s *knowledgeDocumentService) buildKnowledgeDocumentModel(req request.CreateKnowledgeDocumentRequest) (*models.KnowledgeDocument, error) { + if strs.IsBlank(string(req.ContentType)) { + req.ContentType = enums.KnowledgeDocumentContentTypeHTML + } + if req.ContentType != enums.KnowledgeDocumentContentTypeHTML && req.ContentType != enums.KnowledgeDocumentContentTypeMarkdown { + return nil, errorsx.InvalidParamI18n("error.e0129") + } + + plainText := rag.ExtractPlainText(req.Content, req.ContentType) + item := &models.KnowledgeDocument{ + KnowledgeBaseID: req.KnowledgeBaseID, + DirectoryID: req.DirectoryID, + Title: req.Title, + ContentType: req.ContentType, + Content: req.Content, + } + if plainText != "" { + hash := sha256.Sum256([]byte(plainText)) + item.ContentHash = hex.EncodeToString(hash[:]) + } + return item, nil +} + +func uniquePositiveIDs(ids []int64) []int64 { + result := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} diff --git a/internal/services/knowledge_faq_excel.go b/internal/services/knowledge_faq_excel.go new file mode 100644 index 0000000..cf376fe --- /dev/null +++ b/internal/services/knowledge_faq_excel.go @@ -0,0 +1,371 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "path/filepath" + "strings" + "time" + + "agent-desk/internal/ai/rag" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/i18nx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" + "github.com/xuri/excelize/v2" +) + +const ( + knowledgeFAQExcelSheetName = "FAQ" + knowledgeFAQExcelContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +) + +var indexKnowledgeFAQAfterImport = func(faqID int64) error { + return rag.Index.IndexFAQByID(context.Background(), faqID) +} + +type knowledgeFAQImportRow struct { + RowNo int + Question string + Answer string + SimilarQuestions []string + Remark string +} + +func (s *knowledgeFAQService) BuildKnowledgeFAQImportTemplate() (*response.KnowledgeFAQExportedFile, error) { + workbook := excelize.NewFile() + defer workbook.Close() + if err := writeKnowledgeFAQWorkbookHeader(workbook); err != nil { + return nil, err + } + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "A2", "如何重置密码?") + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "B2", "进入个人设置后点击重置密码。") + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "C2", "忘记密码怎么办\n重置密码在哪里") + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "D2", "账号") + return buildKnowledgeFAQExcelFile("knowledge-faq-import-template.xlsx", workbook) +} + +func (s *knowledgeFAQService) ExportKnowledgeFAQs(knowledgeBaseID int64) (*response.KnowledgeFAQExportedFile, error) { + if _, err := s.requireFAQKnowledgeBase(knowledgeBaseID); err != nil { + return nil, err + } + list := repositories.KnowledgeFAQRepository.FindAllByKnowledgeBaseID(sqls.DB(), knowledgeBaseID) + workbook := excelize.NewFile() + defer workbook.Close() + if err := writeKnowledgeFAQWorkbookHeader(workbook); err != nil { + return nil, err + } + for index, item := range list { + rowNo := index + 2 + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, fmt.Sprintf("A%d", rowNo), item.Question) + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, fmt.Sprintf("B%d", rowNo), item.Answer) + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, fmt.Sprintf("C%d", rowNo), strings.Join(decodeKnowledgeFAQSimilarQuestions(item.SimilarQuestions), "\n")) + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, fmt.Sprintf("D%d", rowNo), item.Remark) + } + filename := fmt.Sprintf("knowledge-faq-%d-%s.xlsx", knowledgeBaseID, time.Now().Format("20060102150405")) + return buildKnowledgeFAQExcelFile(filename, workbook) +} + +func (s *knowledgeFAQService) ImportKnowledgeFAQs(req request.ImportKnowledgeFAQRequest, operator *dto.AuthPrincipal) (*response.KnowledgeFAQImportResult, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + if req.Mode != request.KnowledgeFAQImportModeAppend && req.Mode != request.KnowledgeFAQImportModeOverwrite { + return nil, errorsx.InvalidParamI18n("error.e0177") + } + if req.Reader == nil { + return nil, errorsx.InvalidParamI18n("error.e0327") + } + if strings.ToLower(filepath.Ext(req.Filename)) != ".xlsx" { + return nil, errorsx.InvalidParamI18n("error.e0089") + } + kb, err := s.requireFAQKnowledgeBase(req.KnowledgeBaseID) + if err != nil { + return nil, err + } + rows, result, err := parseKnowledgeFAQImportRows(req.Reader, req.Locale) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return result, nil + } + + questions := make([]string, 0, len(rows)) + for _, row := range rows { + questions = append(questions, row.Question) + } + existingList := repositories.KnowledgeFAQRepository.FindByKnowledgeBaseIDAndQuestions(sqls.DB(), req.KnowledgeBaseID, questions) + existingMap := make(map[string]models.KnowledgeFAQ, len(existingList)) + for _, item := range existingList { + if _, ok := existingMap[item.Question]; !ok { + existingMap[item.Question] = item + } + } + + indexFAQIDs := make([]int64, 0, len(rows)) + err = sqls.WithTransaction(func(ctx *sqls.TxContext) error { + for _, row := range rows { + existing, exists := existingMap[row.Question] + if exists && req.Mode == request.KnowledgeFAQImportModeAppend { + result.Skipped++ + result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: row.RowNo, Message: i18nx.Getf(req.Locale, "error.e0237")}) + continue + } + + similarQuestions, marshalErr := json.Marshal(row.SimilarQuestions) + if marshalErr != nil { + result.Failed++ + result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: row.RowNo, Message: i18nx.Getf(req.Locale, "error.e0280")}) + continue + } + + if exists { + if updateErr := repositories.KnowledgeFAQRepository.Updates(ctx.Tx, existing.ID, map[string]any{ + "answer": row.Answer, + "similar_questions": string(similarQuestions), + "index_status": enums.KnowledgeDocumentIndexStatusPending, + "indexed_at": nil, + "index_error": "", + "remark": row.Remark, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }); updateErr != nil { + return updateErr + } + result.Updated++ + indexFAQIDs = append(indexFAQIDs, existing.ID) + continue + } + + item := &models.KnowledgeFAQ{ + KnowledgeBaseID: req.KnowledgeBaseID, + Question: row.Question, + Answer: row.Answer, + SimilarQuestions: string(similarQuestions), + Status: kb.Status, + IndexStatus: enums.KnowledgeDocumentIndexStatusPending, + IndexError: "", + IndexedAt: nil, + Remark: row.Remark, + AuditFields: utils.BuildAuditFields(operator), + } + if createErr := repositories.KnowledgeFAQRepository.Create(ctx.Tx, item); createErr != nil { + return createErr + } + result.Created++ + indexFAQIDs = append(indexFAQIDs, item.ID) + } + return nil + }) + if err != nil { + return nil, err + } + + for _, faqID := range indexFAQIDs { + if indexErr := indexKnowledgeFAQAfterImport(faqID); indexErr != nil { + slog.Error("failed to index imported knowledge faq", "faq_id", faqID, "error", indexErr) + } + } + return result, nil +} + +func writeKnowledgeFAQWorkbookHeader(workbook *excelize.File) error { + sheet := workbook.GetSheetName(0) + if sheet == "" { + sheet = knowledgeFAQExcelSheetName + workbook.NewSheet(sheet) + } else if sheet != knowledgeFAQExcelSheetName { + if err := workbook.SetSheetName(sheet, knowledgeFAQExcelSheetName); err != nil { + return err + } + } + headers := []string{"标准问题", "答案", "相似问", "备注"} + for index, header := range headers { + cell, err := excelize.CoordinatesToCellName(index+1, 1) + if err != nil { + return err + } + if err := workbook.SetCellValue(knowledgeFAQExcelSheetName, cell, header); err != nil { + return err + } + } + style, err := workbook.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true}, + Alignment: &excelize.Alignment{WrapText: true, Vertical: "top"}, + }) + if err == nil { + _ = workbook.SetCellStyle(knowledgeFAQExcelSheetName, "A1", "D1", style) + } + _ = workbook.SetColWidth(knowledgeFAQExcelSheetName, "A", "A", 36) + _ = workbook.SetColWidth(knowledgeFAQExcelSheetName, "B", "B", 60) + _ = workbook.SetColWidth(knowledgeFAQExcelSheetName, "C", "C", 40) + _ = workbook.SetColWidth(knowledgeFAQExcelSheetName, "D", "D", 24) + return nil +} + +func buildKnowledgeFAQExcelFile(filename string, workbook *excelize.File) (*response.KnowledgeFAQExportedFile, error) { + var buf bytes.Buffer + if err := workbook.Write(&buf); err != nil { + return nil, err + } + return &response.KnowledgeFAQExportedFile{ + Filename: filename, + ContentType: knowledgeFAQExcelContentType, + Data: buf.Bytes(), + }, nil +} + +func parseKnowledgeFAQImportRows(reader io.Reader, locale string) ([]knowledgeFAQImportRow, *response.KnowledgeFAQImportResult, error) { + workbook, err := excelize.OpenReader(reader) + if err != nil { + return nil, nil, errorsx.InvalidParamI18n("error.e0023") + } + defer workbook.Close() + sheet := knowledgeFAQExcelSheetName + if index, _ := workbook.GetSheetIndex(sheet); index < 0 { + sheets := workbook.GetSheetList() + if len(sheets) == 0 { + return nil, nil, errorsx.InvalidParamI18n("error.e0022") + } + sheet = sheets[0] + } + table, err := workbook.GetRows(sheet) + if err != nil { + return nil, nil, errorsx.InvalidParamI18n("error.e0024") + } + if len(table) == 0 { + return nil, nil, errorsx.InvalidParamI18n("error.e0022") + } + headerMap := buildKnowledgeFAQHeaderMap(table[0]) + if _, ok := headerMap["question"]; !ok { + return nil, nil, errorsx.InvalidParamI18n("error.e0297") + } + if _, ok := headerMap["answer"]; !ok { + return nil, nil, errorsx.InvalidParamI18n("error.e0298") + } + + result := &response.KnowledgeFAQImportResult{Errors: make([]response.KnowledgeFAQImportError, 0)} + rows := make([]knowledgeFAQImportRow, 0, len(table)-1) + seen := make(map[string]int) + for index := 1; index < len(table); index++ { + current := table[index] + if isKnowledgeFAQExcelRowEmpty(current) { + continue + } + result.Total++ + rowNo := index + 1 + row := knowledgeFAQImportRow{ + RowNo: rowNo, + Question: getKnowledgeFAQExcelCell(current, headerMap["question"]), + Answer: getKnowledgeFAQExcelCell(current, headerMap["answer"]), + SimilarQuestions: parseKnowledgeFAQSimilarQuestions(getKnowledgeFAQExcelCell(current, headerMap["similarQuestions"])), + Remark: getKnowledgeFAQExcelCell(current, headerMap["remark"]), + } + if row.Question == "" { + result.Failed++ + result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: i18nx.Getf(locale, "error.e0340")}) + continue + } + if len([]rune(row.Question)) > 500 { + result.Failed++ + result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: i18nx.Getf(locale, "error.e0341")}) + continue + } + if row.Answer == "" { + result.Failed++ + result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: i18nx.Getf(locale, "error.e0292")}) + continue + } + if firstRow, exists := seen[row.Question]; exists { + result.Failed++ + result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: i18nx.Getf(locale, "error.knowledgeFAQImport.duplicateQuestionInFile", firstRow)}) + continue + } + seen[row.Question] = rowNo + rows = append(rows, row) + } + return rows, result, nil +} + +func buildKnowledgeFAQHeaderMap(headerRow []string) map[string]int { + accepted := map[string]string{ + "标准问题": "question", + "问题": "question", + "question": "question", + "答案": "answer", + "answer": "answer", + "相似问": "similarQuestions", + "相似问题": "similarQuestions", + "similarquestions": "similarQuestions", + "备注": "remark", + "remark": "remark", + } + result := make(map[string]int) + for index, header := range headerRow { + key := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(header, "\uFEFF"))) + if field, ok := accepted[key]; ok { + if _, exists := result[field]; !exists { + result[field] = index + } + } + } + return result +} + +func getKnowledgeFAQExcelCell(row []string, index int) string { + if index < 0 || index >= len(row) { + return "" + } + return strings.TrimSpace(row[index]) +} + +func isKnowledgeFAQExcelRowEmpty(row []string) bool { + for _, cell := range row { + if strings.TrimSpace(cell) != "" { + return false + } + } + return true +} + +func parseKnowledgeFAQSimilarQuestions(value string) []string { + lines := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n") + items := make([]string, 0, len(lines)) + seen := make(map[string]struct{}, len(lines)) + for _, line := range lines { + item := strings.TrimSpace(strings.ReplaceAll(line, "\r", "")) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + items = append(items, item) + } + return items +} + +func decodeKnowledgeFAQSimilarQuestions(value string) []string { + var items []string + if strings.TrimSpace(value) == "" { + return nil + } + if err := json.Unmarshal([]byte(value), &items); err != nil { + return nil + } + return items +} diff --git a/internal/services/knowledge_faq_excel_test.go b/internal/services/knowledge_faq_excel_test.go new file mode 100644 index 0000000..922d4eb --- /dev/null +++ b/internal/services/knowledge_faq_excel_test.go @@ -0,0 +1,249 @@ +package services + +import ( + "bytes" + "encoding/json" + "testing" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "github.com/xuri/excelize/v2" + "gorm.io/gorm" +) + +func TestKnowledgeFAQExcelTemplateContainsExpectedHeaders(t *testing.T) { + file, err := KnowledgeFAQService.BuildKnowledgeFAQImportTemplate() + if err != nil { + t.Fatalf("BuildKnowledgeFAQImportTemplate() error = %v", err) + } + + workbook, err := excelize.OpenReader(bytes.NewReader(file.Data)) + if err != nil { + t.Fatalf("open template workbook: %v", err) + } + defer workbook.Close() + + rows, err := workbook.GetRows("FAQ") + if err != nil { + t.Fatalf("get FAQ rows: %v", err) + } + if len(rows) == 0 { + t.Fatal("template has no rows") + } + want := []string{"标准问题", "答案", "相似问", "备注"} + for i, value := range want { + if rows[0][i] != value { + t.Fatalf("header %d = %q, want %q", i, rows[0][i], value) + } + } +} + +func TestKnowledgeFAQExcelExportWritesSimilarQuestionsWithLineBreaks(t *testing.T) { + setupKnowledgeFAQExcelTestDB(t) + kb := createKnowledgeFAQExcelTestBase(t, enums.StatusOk) + similarQuestions, _ := json.Marshal([]string{"怎么重置密码", "忘记密码怎么办"}) + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), &models.KnowledgeFAQ{ + KnowledgeBaseID: kb.ID, + Question: "如何重置密码?", + Answer: "进入个人设置后点击重置密码。", + SimilarQuestions: string(similarQuestions), + Status: enums.StatusOk, + IndexStatus: enums.KnowledgeDocumentIndexStatusPending, + Remark: "账号", + }); err != nil { + t.Fatalf("create faq: %v", err) + } + + file, err := KnowledgeFAQService.ExportKnowledgeFAQs(kb.ID) + if err != nil { + t.Fatalf("ExportKnowledgeFAQs() error = %v", err) + } + workbook, err := excelize.OpenReader(bytes.NewReader(file.Data)) + if err != nil { + t.Fatalf("open export workbook: %v", err) + } + defer workbook.Close() + + cell, err := workbook.GetCellValue("FAQ", "C2") + if err != nil { + t.Fatalf("get C2: %v", err) + } + if cell != "怎么重置密码\n忘记密码怎么办" { + t.Fatalf("similar questions cell = %q", cell) + } +} + +func TestImportKnowledgeFAQsAppendSkipsExistingQuestion(t *testing.T) { + setupKnowledgeFAQExcelTestDB(t) + restore := stubKnowledgeFAQImportIndex(t) + defer restore() + kb := createKnowledgeFAQExcelTestBase(t, enums.StatusOk) + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), &models.KnowledgeFAQ{ + KnowledgeBaseID: kb.ID, + Question: "如何重置密码?", + Answer: "旧答案", + Status: enums.StatusOk, + IndexStatus: enums.KnowledgeDocumentIndexStatusIndexed, + }); err != nil { + t.Fatalf("create faq: %v", err) + } + + result, err := KnowledgeFAQService.ImportKnowledgeFAQs(request.ImportKnowledgeFAQRequest{ + KnowledgeBaseID: kb.ID, + Mode: request.KnowledgeFAQImportModeAppend, + Filename: "faq.xlsx", + Reader: bytes.NewReader(buildKnowledgeFAQExcelWorkbook(t, [][]string{ + {"标准问题", "答案", "相似问", "备注"}, + {"如何重置密码?", "新答案", "重置密码在哪里\n忘记密码", "账号"}, + {"支持哪些渠道?", "支持网页和企业微信。", "", "渠道"}, + })), + }, knowledgeFAQExcelTestOperator()) + if err != nil { + t.Fatalf("ImportKnowledgeFAQs() error = %v", err) + } + if result.Created != 1 || result.Updated != 0 || result.Skipped != 1 || result.Failed != 0 { + t.Fatalf("result = %+v", result) + } + + list := repositories.KnowledgeFAQRepository.Find(sqls.DB(), sqls.NewCnd().Eq("knowledge_base_id", kb.ID)) + if len(list) != 2 { + t.Fatalf("faq count = %d, want 2", len(list)) + } + existing := repositories.KnowledgeFAQRepository.Find(sqls.DB(), sqls.NewCnd().Eq("question", "如何重置密码?")) + if len(existing) != 1 || existing[0].Answer != "旧答案" { + t.Fatalf("existing faq changed: %+v", existing) + } +} + +func TestImportKnowledgeFAQsOverwriteUpdatesExistingQuestion(t *testing.T) { + setupKnowledgeFAQExcelTestDB(t) + restore := stubKnowledgeFAQImportIndex(t) + defer restore() + kb := createKnowledgeFAQExcelTestBase(t, enums.StatusDisabled) + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), &models.KnowledgeFAQ{ + KnowledgeBaseID: kb.ID, + Question: "如何重置密码?", + Answer: "旧答案", + Status: enums.StatusOk, + IndexStatus: enums.KnowledgeDocumentIndexStatusIndexed, + }); err != nil { + t.Fatalf("create faq: %v", err) + } + + result, err := KnowledgeFAQService.ImportKnowledgeFAQs(request.ImportKnowledgeFAQRequest{ + KnowledgeBaseID: kb.ID, + Mode: request.KnowledgeFAQImportModeOverwrite, + Filename: "faq.xlsx", + Reader: bytes.NewReader(buildKnowledgeFAQExcelWorkbook(t, [][]string{ + {"标准问题", "答案", "相似问", "备注"}, + {"如何重置密码?", "新答案", "重置密码在哪里\n重置密码在哪里\n忘记密码", "账号"}, + {"支持哪些渠道?", "支持网页和企业微信。", "", "渠道"}, + })), + }, knowledgeFAQExcelTestOperator()) + if err != nil { + t.Fatalf("ImportKnowledgeFAQs() error = %v", err) + } + if result.Created != 1 || result.Updated != 1 || result.Skipped != 0 || result.Failed != 0 { + t.Fatalf("result = %+v", result) + } + + updated := repositories.KnowledgeFAQRepository.Find(sqls.DB(), sqls.NewCnd().Eq("question", "如何重置密码?")) + if len(updated) != 1 { + t.Fatalf("updated faq count = %d", len(updated)) + } + if updated[0].Answer != "新答案" { + t.Fatalf("updated answer = %q", updated[0].Answer) + } + if updated[0].IndexStatus != enums.KnowledgeDocumentIndexStatusPending { + t.Fatalf("index status = %q", updated[0].IndexStatus) + } + var similar []string + if err := json.Unmarshal([]byte(updated[0].SimilarQuestions), &similar); err != nil { + t.Fatalf("unmarshal similar questions: %v", err) + } + if len(similar) != 2 || similar[0] != "重置密码在哪里" || similar[1] != "忘记密码" { + t.Fatalf("similar questions = %#v", similar) + } + + created := repositories.KnowledgeFAQRepository.Find(sqls.DB(), sqls.NewCnd().Eq("question", "支持哪些渠道?")) + if len(created) != 1 || created[0].Status != enums.StatusDisabled { + t.Fatalf("created faq = %+v", created) + } +} + +func setupKnowledgeFAQExcelTestDB(t *testing.T) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate(&models.KnowledgeBase{}, &models.KnowledgeFAQ{}, &models.KnowledgeChunk{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) +} + +func createKnowledgeFAQExcelTestBase(t *testing.T, status enums.Status) *models.KnowledgeBase { + t.Helper() + item := &models.KnowledgeBase{ + Name: "FAQ", + KnowledgeType: string(enums.KnowledgeBaseTypeFAQ), + Status: status, + } + if err := repositories.KnowledgeBaseRepository.Create(sqls.DB(), item); err != nil { + t.Fatalf("create knowledge base: %v", err) + } + return item +} + +func buildKnowledgeFAQExcelWorkbook(t *testing.T, rows [][]string) []byte { + t.Helper() + workbook := excelize.NewFile() + defer workbook.Close() + sheet := workbook.GetSheetName(0) + if err := workbook.SetSheetName(sheet, "FAQ"); err != nil { + t.Fatalf("set sheet name: %v", err) + } + for rowIndex, row := range rows { + for colIndex, value := range row { + cell, err := excelize.CoordinatesToCellName(colIndex+1, rowIndex+1) + if err != nil { + t.Fatalf("cell name: %v", err) + } + if err := workbook.SetCellValue("FAQ", cell, value); err != nil { + t.Fatalf("set cell value: %v", err) + } + } + } + var buf bytes.Buffer + if err := workbook.Write(&buf); err != nil { + t.Fatalf("write workbook: %v", err) + } + return buf.Bytes() +} + +func knowledgeFAQExcelTestOperator() *dto.AuthPrincipal { + return &dto.AuthPrincipal{UserID: 1, Username: "admin"} +} + +func stubKnowledgeFAQImportIndex(t *testing.T) func() { + t.Helper() + original := indexKnowledgeFAQAfterImport + indexed := make([]int64, 0) + indexKnowledgeFAQAfterImport = func(faqID int64) error { + indexed = append(indexed, faqID) + return nil + } + return func() { + if len(indexed) == 0 { + t.Fatalf("expected imported FAQs to be indexed") + } + indexKnowledgeFAQAfterImport = original + } +} diff --git a/internal/services/knowledge_faq_service.go b/internal/services/knowledge_faq_service.go new file mode 100644 index 0000000..76d8edb --- /dev/null +++ b/internal/services/knowledge_faq_service.go @@ -0,0 +1,238 @@ +package services + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "agent-desk/internal/ai/rag" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var KnowledgeFAQService = newKnowledgeFAQService() + +func newKnowledgeFAQService() *knowledgeFAQService { + return &knowledgeFAQService{} +} + +type knowledgeFAQService struct{} + +func (s *knowledgeFAQService) Get(id int64) *models.KnowledgeFAQ { + return repositories.KnowledgeFAQRepository.Get(sqls.DB(), id) +} + +func (s *knowledgeFAQService) FindPageByCnd(cnd *sqls.Cnd) (list []models.KnowledgeFAQ, paging *sqls.Paging) { + return repositories.KnowledgeFAQRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *knowledgeFAQService) FindPageByParams(queryParams *params.QueryParams) (list []models.KnowledgeFAQ, paging *sqls.Paging) { + return repositories.KnowledgeFAQRepository.FindPageByParams(sqls.DB(), queryParams) +} + +func (s *knowledgeFAQService) Count(cnd *sqls.Cnd) int64 { + return repositories.KnowledgeFAQRepository.Count(sqls.DB(), cnd) +} + +func (s *knowledgeFAQService) CreateKnowledgeFAQ(req request.CreateKnowledgeFAQRequest, operator *dto.AuthPrincipal) (*models.KnowledgeFAQ, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + kb, err := s.requireFAQKnowledgeBase(req.KnowledgeBaseID) + if err != nil { + return nil, err + } + if _, err := KnowledgeDirectoryService.RequireUsableDirectory(req.KnowledgeBaseID, req.DirectoryID); err != nil { + return nil, err + } + item, err := s.buildKnowledgeFAQModel(req) + if err != nil { + return nil, err + } + item.Status = kb.Status + item.IndexStatus = enums.KnowledgeDocumentIndexStatusPending + item.IndexError = "" + item.IndexedAt = nil + item.AuditFields = utils.BuildAuditFields(operator) + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + if err := rag.Index.IndexFAQByID(context.Background(), item.ID); err != nil { + slog.Error("failed to index created knowledge faq", "faq_id", item.ID, "error", err) + return item, nil + } + return s.Get(item.ID), nil +} + +func (s *knowledgeFAQService) UpdateKnowledgeFAQ(req request.UpdateKnowledgeFAQRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + current := s.Get(req.ID) + if current == nil { + return errorsx.InvalidParamI18n("error.e0025") + } + if _, err := s.requireFAQKnowledgeBase(req.KnowledgeBaseID); err != nil { + return err + } + if _, err := KnowledgeDirectoryService.RequireUsableDirectory(req.KnowledgeBaseID, req.DirectoryID); err != nil { + return err + } + item, err := s.buildKnowledgeFAQModel(req.CreateKnowledgeFAQRequest) + if err != nil { + return err + } + if err := repositories.KnowledgeFAQRepository.Updates(sqls.DB(), req.ID, map[string]any{ + "knowledge_base_id": item.KnowledgeBaseID, + "directory_id": item.DirectoryID, + "question": item.Question, + "answer": item.Answer, + "similar_questions": item.SimilarQuestions, + "index_status": enums.KnowledgeDocumentIndexStatusPending, + "indexed_at": nil, + "index_error": "", + "remark": item.Remark, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }); err != nil { + return err + } + return rag.Index.IndexFAQByID(context.Background(), req.ID) +} + +func (s *knowledgeFAQService) DeleteKnowledgeFAQ(id int64) error { + current := s.Get(id) + if current == nil { + return errorsx.InvalidParamI18n("error.e0025") + } + if err := repositories.KnowledgeFAQRepository.Delete(sqls.DB(), id); err != nil { + return err + } + return rag.Index.RemoveFAQIndex(context.Background(), id) +} + +func (s *knowledgeFAQService) BatchMoveKnowledgeFAQs(req request.BatchMoveKnowledgeFAQRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + ids := uniquePositiveIDs(req.IDs) + if len(ids) == 0 { + return errorsx.InvalidParamI18n("error.e0332") + } + if _, err := s.requireFAQKnowledgeBase(req.KnowledgeBaseID); err != nil { + return err + } + if _, err := KnowledgeDirectoryService.RequireUsableDirectory(req.KnowledgeBaseID, req.DirectoryID); err != nil { + return err + } + for _, id := range ids { + current := s.Get(id) + if current == nil { + return errorsx.InvalidParamI18n("error.e0025") + } + if current.KnowledgeBaseID != req.KnowledgeBaseID { + return errorsx.InvalidParamI18n("error.e0138") + } + } + now := time.Now() + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + for _, id := range ids { + if err := repositories.KnowledgeFAQRepository.Updates(ctx.Tx, id, map[string]any{ + "directory_id": req.DirectoryID, + "index_status": enums.KnowledgeDocumentIndexStatusPending, + "indexed_at": nil, + "index_error": "", + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + } + return nil + }); err != nil { + return err + } + for _, id := range ids { + if err := rag.Index.IndexFAQByID(context.Background(), id); err != nil { + slog.Error("failed to reindex moved knowledge faq", "faq_id", id, "error", err) + } + } + return nil +} + +func (s *knowledgeFAQService) BatchDeleteKnowledgeFAQs(req request.BatchDeleteKnowledgeFAQRequest) error { + ids := uniquePositiveIDs(req.IDs) + if len(ids) == 0 { + return errorsx.InvalidParamI18n("error.e0330") + } + for _, id := range ids { + if err := s.DeleteKnowledgeFAQ(id); err != nil { + return err + } + } + return nil +} + +func (s *knowledgeFAQService) buildKnowledgeFAQModel(req request.CreateKnowledgeFAQRequest) (*models.KnowledgeFAQ, error) { + if req.KnowledgeBaseID <= 0 { + return nil, errorsx.InvalidParamI18n("error.e0283") + } + if req.Question == "" { + return nil, errorsx.InvalidParamI18n("error.e0340") + } + if req.Answer == "" { + return nil, errorsx.InvalidParamI18n("error.e0292") + } + similarQuestions, err := json.Marshal(normalizeSimilarQuestions(req.SimilarQuestions)) + if err != nil { + return nil, errorsx.InvalidParamI18n("error.e0280") + } + return &models.KnowledgeFAQ{ + KnowledgeBaseID: req.KnowledgeBaseID, + DirectoryID: req.DirectoryID, + Question: req.Question, + Answer: req.Answer, + SimilarQuestions: string(similarQuestions), + Remark: req.Remark, + }, nil +} + +func (s *knowledgeFAQService) requireFAQKnowledgeBase(knowledgeBaseID int64) (*models.KnowledgeBase, error) { + kb := KnowledgeBaseService.Get(knowledgeBaseID) + if kb == nil { + return nil, errorsx.InvalidParamI18n("error.e0283") + } + if kb.KnowledgeType != "faq" { + return nil, errorsx.InvalidParamI18n("error.e0199") + } + return kb, nil +} + +func normalizeSimilarQuestions(values []string) []string { + items := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, item := range values { + value := item + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + items = append(items, value) + } + return items +} diff --git a/internal/services/knowledge_retrieve_log_service.go b/internal/services/knowledge_retrieve_log_service.go new file mode 100644 index 0000000..5a92c1e --- /dev/null +++ b/internal/services/knowledge_retrieve_log_service.go @@ -0,0 +1,47 @@ +package services + +import ( + "agent-desk/internal/models" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var KnowledgeRetrieveLogService = newKnowledgeRetrieveLogService() + +func newKnowledgeRetrieveLogService() *knowledgeRetrieveLogService { + return &knowledgeRetrieveLogService{} +} + +type knowledgeRetrieveLogService struct { +} + +func (s *knowledgeRetrieveLogService) Get(id int64) *models.KnowledgeRetrieveLog { + ret := &models.KnowledgeRetrieveLog{} + if err := sqls.DB().First(ret, "id = ?", id).Error; err != nil { + return nil + } + return ret +} + +func (s *knowledgeRetrieveLogService) FindPageByParams(params *params.QueryParams) (list []models.KnowledgeRetrieveLog, paging *sqls.Paging) { + cnd := ¶ms.Cnd + cnd.Find(sqls.DB(), &list) + count := cnd.Count(sqls.DB(), &models.KnowledgeRetrieveLog{}) + paging = &sqls.Paging{ + Page: cnd.Paging.Page, + Limit: cnd.Paging.Limit, + Total: count, + } + return +} + +func (s *knowledgeRetrieveLogService) FindHitsByRetrieveLogID(retrieveLogID int64) []models.KnowledgeRetrieveHit { + if retrieveLogID <= 0 { + return nil + } + var list []models.KnowledgeRetrieveHit + sqls.DB().Where("retrieve_log_id = ?", retrieveLogID).Order("rank_no asc, id asc").Find(&list) + return list +} diff --git a/internal/services/login_credential_log_service.go b/internal/services/login_credential_log_service.go new file mode 100644 index 0000000..a4fae5d --- /dev/null +++ b/internal/services/login_credential_log_service.go @@ -0,0 +1,67 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var LoginCredentialLogService = newLoginCredentialLogService() + +func newLoginCredentialLogService() *loginCredentialLogService { + return &loginCredentialLogService{} +} + +type loginCredentialLogService struct { +} + +func (s *loginCredentialLogService) Get(id int64) *models.LoginCredentialLog { + return repositories.LoginCredentialLogRepository.Get(sqls.DB(), id) +} + +func (s *loginCredentialLogService) Take(where ...interface{}) *models.LoginCredentialLog { + return repositories.LoginCredentialLogRepository.Take(sqls.DB(), where...) +} + +func (s *loginCredentialLogService) Find(cnd *sqls.Cnd) []models.LoginCredentialLog { + return repositories.LoginCredentialLogRepository.Find(sqls.DB(), cnd) +} + +func (s *loginCredentialLogService) FindOne(cnd *sqls.Cnd) *models.LoginCredentialLog { + return repositories.LoginCredentialLogRepository.FindOne(sqls.DB(), cnd) +} + +func (s *loginCredentialLogService) FindPageByParams(params *params.QueryParams) (list []models.LoginCredentialLog, paging *sqls.Paging) { + return repositories.LoginCredentialLogRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *loginCredentialLogService) FindPageByCnd(cnd *sqls.Cnd) (list []models.LoginCredentialLog, paging *sqls.Paging) { + return repositories.LoginCredentialLogRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *loginCredentialLogService) Count(cnd *sqls.Cnd) int64 { + return repositories.LoginCredentialLogRepository.Count(sqls.DB(), cnd) +} + +func (s *loginCredentialLogService) Create(t *models.LoginCredentialLog) error { + return repositories.LoginCredentialLogRepository.Create(sqls.DB(), t) +} + +func (s *loginCredentialLogService) Update(t *models.LoginCredentialLog) error { + return repositories.LoginCredentialLogRepository.Update(sqls.DB(), t) +} + +func (s *loginCredentialLogService) Updates(id int64, columns map[string]interface{}) error { + return repositories.LoginCredentialLogRepository.Updates(sqls.DB(), id, columns) +} + +func (s *loginCredentialLogService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.LoginCredentialLogRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *loginCredentialLogService) Delete(id int64) { + repositories.LoginCredentialLogRepository.Delete(sqls.DB(), id) +} diff --git a/internal/services/login_session_service.go b/internal/services/login_session_service.go new file mode 100644 index 0000000..498ac15 --- /dev/null +++ b/internal/services/login_session_service.go @@ -0,0 +1,95 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/repositories" + "time" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var LoginSessionService = newLoginSessionService() + +func newLoginSessionService() *loginSessionService { + return &loginSessionService{} +} + +type loginSessionService struct { +} + +func (s *loginSessionService) Get(id int64) *models.LoginSession { + return repositories.LoginSessionRepository.Get(sqls.DB(), id) +} + +func (s *loginSessionService) Take(where ...interface{}) *models.LoginSession { + return repositories.LoginSessionRepository.Take(sqls.DB(), where...) +} + +func (s *loginSessionService) Find(cnd *sqls.Cnd) []models.LoginSession { + return repositories.LoginSessionRepository.Find(sqls.DB(), cnd) +} + +func (s *loginSessionService) FindOne(cnd *sqls.Cnd) *models.LoginSession { + return repositories.LoginSessionRepository.FindOne(sqls.DB(), cnd) +} + +func (s *loginSessionService) FindPageByParams(params *params.QueryParams) (list []models.LoginSession, paging *sqls.Paging) { + return repositories.LoginSessionRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *loginSessionService) FindPageByCnd(cnd *sqls.Cnd) (list []models.LoginSession, paging *sqls.Paging) { + return repositories.LoginSessionRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *loginSessionService) Count(cnd *sqls.Cnd) int64 { + return repositories.LoginSessionRepository.Count(sqls.DB(), cnd) +} + +func (s *loginSessionService) Create(t *models.LoginSession) error { + return repositories.LoginSessionRepository.Create(sqls.DB(), t) +} + +func (s *loginSessionService) Update(t *models.LoginSession) error { + return repositories.LoginSessionRepository.Update(sqls.DB(), t) +} + +func (s *loginSessionService) Updates(id int64, columns map[string]interface{}) error { + return repositories.LoginSessionRepository.Updates(sqls.DB(), id, columns) +} + +func (s *loginSessionService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.LoginSessionRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *loginSessionService) Delete(id int64) { + repositories.LoginSessionRepository.Delete(sqls.DB(), id) +} + +func (s *loginSessionService) Revoke(id int64, operatorID int64, operatorName string) error { + session := s.Get(id) + if session == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + now := time.Now() + return s.Updates(id, map[string]any{ + "revoked_at": now, + "update_user_id": operatorID, + "update_user_name": operatorName, + "updated_at": now, + }) +} + +func (s *loginSessionService) RevokeByUser(userID int64, operatorID int64, operatorName string) error { + now := time.Now() + return sqls.DB().Model(&models.LoginSession{}). + Where("user_id = ? AND revoked_at IS NULL", userID). + Updates(map[string]any{ + "revoked_at": now, + "update_user_id": operatorID, + "update_user_name": operatorName, + "updated_at": now, + }).Error +} diff --git a/internal/services/mcp_debug_service.go b/internal/services/mcp_debug_service.go new file mode 100644 index 0000000..9b3deab --- /dev/null +++ b/internal/services/mcp_debug_service.go @@ -0,0 +1,155 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "slices" + "strings" + "time" + + "agent-desk/internal/ai/mcps" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/errorsx" +) + +var MCPDebugService = newMCPDebugService() + +func newMCPDebugService() *mCPDebugService { + return &mCPDebugService{ + client: mcps.NewClient(), + } +} + +type mCPDebugService struct { + client *mcps.Client +} + +func (s *mCPDebugService) ListServers() []mcps.ServerInfo { + cfg := config.Current() + if len(cfg.MCP.Servers) == 0 { + return nil + } + keys := make([]string, 0, len(cfg.MCP.Servers)) + for code := range cfg.MCP.Servers { + keys = append(keys, code) + } + slices.Sort(keys) + + ret := make([]mcps.ServerInfo, 0, len(keys)) + for _, code := range keys { + server := cfg.MCP.Servers[code] + ret = append(ret, mcps.ServerInfo{ + Code: code, + Enabled: server.Enabled, + Endpoint: strings.TrimSpace(server.Endpoint), + TimeoutMS: server.TimeoutMS, + }) + } + return ret +} + +func (s *mCPDebugService) TestConnection(ctx context.Context, serverCode string) (*mcps.ConnectionResult, error) { + server, err := s.resolveServer(serverCode) + if err != nil { + return nil, err + } + startedAt := time.Now() + result, err := s.client.TestConnection(ctx, server) + s.logResult("test_connection", serverCode, "", time.Since(startedAt), err) + if err != nil { + return nil, err + } + return result, nil +} + +func (s *mCPDebugService) ListTools(ctx context.Context, serverCode string) ([]mcps.ToolInfo, error) { + server, err := s.resolveServer(serverCode) + if err != nil { + return nil, err + } + startedAt := time.Now() + result, err := s.client.ListTools(ctx, server) + s.logResult("list_tools", serverCode, "", time.Since(startedAt), err) + if err != nil { + return nil, err + } + return result, nil +} + +func (s *mCPDebugService) CallTool(ctx context.Context, serverCode string, toolName string, arguments map[string]any) (*mcps.ToolCallResult, error) { + server, err := s.resolveServer(serverCode) + if err != nil { + return nil, err + } + startedAt := time.Now() + result, err := s.client.CallTool(ctx, server, toolName, arguments) + s.logResult("call_tool", serverCode, toolName, time.Since(startedAt), err) + if err != nil { + return nil, err + } + return result, nil +} + +func (s *mCPDebugService) resolveServer(serverCode string) (mcps.ServerConfig, error) { + cfg := config.Current() + if !cfg.MCP.Enabled { + return mcps.ServerConfig{}, errorsx.InvalidParamI18n("error.e0035") + } + serverCode = strings.TrimSpace(serverCode) + if serverCode == "" { + return mcps.ServerConfig{}, errorsx.InvalidParamI18n("error.e0070") + } + server, ok := cfg.MCP.Servers[serverCode] + if !ok { + return mcps.ServerConfig{}, errorsx.InvalidParamI18n("error.e0034") + } + if !server.Enabled { + return mcps.ServerConfig{}, errorsx.InvalidParamI18n("error.e0033") + } + return mcps.ServerConfig{ + Code: serverCode, + Endpoint: strings.TrimSpace(server.Endpoint), + TimeoutMS: server.TimeoutMS, + Headers: cloneHeaders(server.Headers), + }, nil +} + +func (s *mCPDebugService) logResult(action string, serverCode string, toolName string, elapsed time.Duration, err error) { + fields := []any{ + "action", action, + "server_code", serverCode, + "tool_name", toolName, + "elapsed_ms", elapsed.Milliseconds(), + } + if err != nil { + fields = append(fields, "success", false, "error", err.Error()) + slog.Warn("mcp debug request failed", fields...) + return + } + fields = append(fields, "success", true) + slog.Info("mcp debug request finished", fields...) +} + +func cloneHeaders(headers map[string]string) map[string]string { + if len(headers) == 0 { + return nil + } + ret := make(map[string]string, len(headers)) + for key, value := range headers { + ret[key] = value + } + return ret +} + +func DumpPayload(value any) string { + if value == nil { + return "" + } + buf, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(buf) +} diff --git a/internal/services/message_service.go b/internal/services/message_service.go new file mode 100644 index 0000000..516e373 --- /dev/null +++ b/internal/services/message_service.go @@ -0,0 +1,747 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/pkg/tracex" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + "log/slog" + "slices" + "strings" + "time" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/common/strs" + "github.com/mlogclub/simple/sqls" +) + +var MessageService = newMessageService() + +func newMessageService() *messageService { + return &messageService{} +} + +type messageService struct { +} + +func (s *messageService) Get(id int64) *models.Message { + return repositories.MessageRepository.Get(sqls.DB(), id) +} + +func (s *messageService) Take(where ...interface{}) *models.Message { + return repositories.MessageRepository.Take(sqls.DB(), where...) +} + +func (s *messageService) Find(cnd *sqls.Cnd) []models.Message { + return repositories.MessageRepository.Find(sqls.DB(), cnd) +} + +// FindByConversationIDCursor 按 id 游标分页:cursor=0 取最新 limit 条;cursor>0 取 id 100 { + limit = 100 + } else if limit <= 0 { + limit = 20 + } + cnd := sqls.NewCnd().Eq("conversation_id", conversationID).Limit(limit).Desc("id") + if cursor > 0 { + cnd.Lt("id", cursor) + } + if strs.IsNotBlank(senderType) { + cnd.Eq("sender_type", senderType) + } + if strs.IsNotBlank(messageType) { + cnd.Eq("message_type", messageType) + } + list = s.Find(cnd) + nextCursor = cursor + hasMore = false + if len(list) > 0 { + nextCursor = list[len(list)-1].ID + hasMore = len(list) == limit + } + slices.Reverse(list) + return list, nextCursor, hasMore +} + +func (s *messageService) FindOne(cnd *sqls.Cnd) *models.Message { + return repositories.MessageRepository.FindOne(sqls.DB(), cnd) +} + +func (s *messageService) FindPageByParams(params *params.QueryParams) (list []models.Message, paging *sqls.Paging) { + return repositories.MessageRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *messageService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Message, paging *sqls.Paging) { + return repositories.MessageRepository.FindPageByCnd(sqls.DB(), cnd) +} + +// FindPageByCndForImListAscending 与 FindPageByCnd 相同分页条件,将结果按 seq 升序排列(开放 IM 时间正序展示)。 +func (s *messageService) FindPageByCndForImListAscending(cnd *sqls.Cnd) (list []models.Message, paging *sqls.Paging) { + list, paging = s.FindPageByCnd(cnd) + if len(list) <= 1 { + return list, paging + } + for i, j := 0, len(list)-1; i < j; i, j = i+1, j-1 { + list[i], list[j] = list[j], list[i] + } + return list, paging +} + +func (s *messageService) Count(cnd *sqls.Cnd) int64 { + return repositories.MessageRepository.Count(sqls.DB(), cnd) +} + +func (s *messageService) Create(t *models.Message) error { + return repositories.MessageRepository.Create(sqls.DB(), t) +} + +func (s *messageService) Update(t *models.Message) error { + return repositories.MessageRepository.Update(sqls.DB(), t) +} + +func (s *messageService) Updates(id int64, columns map[string]interface{}) error { + return repositories.MessageRepository.Updates(sqls.DB(), id, columns) +} + +func (s *messageService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.MessageRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *messageService) Delete(id int64) { + repositories.MessageRepository.Delete(sqls.DB(), id) +} + +func (s *messageService) GetConversationReadTarget(conversationID, messageID int64) (*models.Message, error) { + if messageID > 0 { + message := s.Get(messageID) + if message == nil || message.ConversationID != conversationID { + return nil, errorsx.InvalidParamI18n("error.e0244") + } + return message, nil + } + return s.FindOne(sqls.NewCnd().Eq("conversation_id", conversationID).Desc("id")), nil +} + +func (s *messageService) SendMessage(conversationID int64, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser) (*models.Message, error) { + switch senderType { + case enums.IMSenderTypeAgent: + return s.sendMessage(conversationID, enums.IMSenderTypeAgent, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, "", 0) + case enums.IMSenderTypeAI: + return s.sendMessage(conversationID, enums.IMSenderTypeAI, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, "", 0) + case enums.IMSenderTypeCustomer: + return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, external, "", 0) + default: + return nil, errorsx.InvalidParamI18n("error.e0080") + } +} + +func (s *messageService) SendAgentMessage(conversationID int64, reqSenderID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal) (*models.Message, error) { + return s.SendAgentMessageWithRequestID(conversationID, reqSenderID, clientMsgID, messageType, content, payload, operator, "") +} + +func (s *messageService) SendAgentMessageWithRequestID(conversationID int64, reqSenderID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, requestID string) (*models.Message, error) { + return s.sendMessage(conversationID, enums.IMSenderTypeAgent, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, requestID, 0) +} + +func (s *messageService) RecallAgentMessage(messageID int64, operator *dto.AuthPrincipal) (*models.Message, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + if messageID <= 0 { + return nil, errorsx.InvalidParamI18n("error.e0244") + } + + message := s.Get(messageID) + if message == nil { + return nil, errorsx.InvalidParamI18n("error.e0244") + } + if message.SenderType != enums.IMSenderTypeAgent { + return nil, errorsx.InvalidParamI18n("error.e0091") + } + if message.SenderID != operator.UserID { + return nil, errorsx.ForbiddenI18n("error.e0087") + } + if message.RecalledAt != nil || message.SendStatus == enums.IMMessageStatusRecalled { + return nil, errorsx.InvalidParamI18n("error.e0246") + } + + conversation, err := s.ValidateConversationSender(message.ConversationID, enums.IMSenderTypeAgent, operator, nil) + if err != nil { + return nil, err + } + + now := time.Now() + err = sqls.WithTransaction(func(ctx *sqls.TxContext) error { + updates := map[string]any{ + "send_status": int(enums.IMMessageStatusRecalled), + "recalled_at": now, + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + } + if err := repositories.MessageRepository.Updates(ctx.Tx, message.ID, updates); err != nil { + return err + } + + message.SendStatus = enums.IMMessageStatusRecalled + message.RecalledAt = &now + message.UpdatedAt = now + message.UpdateUserID = operator.UserID + message.UpdateUserName = operator.Username + + agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID) + agentUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(agentReadState), enums.IMSenderTypeCustomer) + if err != nil { + return err + } + customerUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI) + if err != nil { + return err + } + + conversationUpdates := map[string]any{ + "agent_unread_count": agentUnreadCount, + "customer_unread_count": customerUnreadCount, + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + } + if conversation.LastMessageID == message.ID { + lastMessage := repositories.MessageRepository.FindLastUnrecalledByConversationID(ctx.Tx, conversation.ID) + if lastMessage != nil { + conversationUpdates["last_message_id"] = lastMessage.ID + conversationUpdates["last_message_at"] = lastMessage.SentAt + conversationUpdates["last_message_summary"] = limitText(buildMessageSummary(lastMessage.MessageType, lastMessage.Content), 255) + } else { + conversationUpdates["last_message_id"] = 0 + conversationUpdates["last_message_at"] = nil + conversationUpdates["last_message_summary"] = "" + } + } + if err := repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, conversationUpdates); err != nil { + return err + } + + if err := ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeMessageRecall, enums.IMSenderTypeAgent, operator.UserID, "客服撤回消息", ""); err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + + if updatedConversation := ConversationService.Get(conversation.ID); updatedConversation != nil { + WsService.PublishMessageRecalled(updatedConversation, message) + WsService.PublishConversationChanged(updatedConversation, enums.IMRealtimeEventConversationUpdated) + } + return message, nil +} + +func (s *messageService) SendAIMessage(conversationID int64, aiAgentID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal) (*models.Message, error) { + return s.SendAIMessageWithRequestID(conversationID, aiAgentID, clientMsgID, messageType, content, payload, operator, "") +} + +func (s *messageService) SendAIMessageWithRequestID(conversationID int64, aiAgentID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, requestID string) (*models.Message, error) { + return s.SendAIMessageWithRequestIDAndWorkflowRunID(conversationID, aiAgentID, clientMsgID, messageType, content, payload, operator, requestID, 0) +} + +func (s *messageService) SendAIMessageWithRequestIDAndWorkflowRunID(conversationID int64, aiAgentID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, requestID string, workflowRunID int64) (*models.Message, error) { + return s.sendMessage(conversationID, enums.IMSenderTypeAI, aiAgentID, clientMsgID, messageType, content, payload, operator, nil, requestID, workflowRunID) +} + +func (s *messageService) SendAIServiceNotice(conversationID int64, aiAgentID int64, content string) (*models.Message, error) { + return s.SendAIServiceNoticeWithRequestID(conversationID, aiAgentID, content, "") +} + +func (s *messageService) SendAIServiceNoticeWithRequestID(conversationID int64, aiAgentID int64, content string, requestID string) (*models.Message, error) { + conversation := ConversationService.Get(conversationID) + if conversation == nil { + return nil, errorsx.InvalidParamI18n("error.e0116") + } + if conversation.Status == enums.IMConversationStatusClosed { + return nil, errorsx.InvalidParamI18n("error.e0119") + } + return s.sendValidatedMessage(conversation, enums.IMSenderTypeAI, aiAgentID, strs.UUID(), enums.IMMessageTypeText, content, "", &dto.AuthPrincipal{ + UserID: 0, + Username: "system", + Nickname: "system", + }, nil, requestID, 0) +} + +func (s *messageService) createAIWelcomeMessage(ctx *sqls.TxContext, conversation *models.Conversation, aiAgent *models.AIAgent, now time.Time) (*models.Message, error) { + if ctx == nil || conversation == nil || aiAgent == nil || strings.TrimSpace(aiAgent.WelcomeMessage) == "" { + return nil, nil + } + + content, payload, summary, err := s.normalizeMessageContent(conversation.ID, enums.IMMessageTypeText, aiAgent.WelcomeMessage, "") + if err != nil { + return nil, err + } + if strs.IsBlank(content) && strs.IsBlank(payload) { + return nil, nil + } + + operator := &dto.AuthPrincipal{ + UserID: 0, + Username: "system", + Nickname: "system", + } + message := &models.Message{ + ConversationID: conversation.ID, + ClientMsgID: strs.UUID(), + SenderType: enums.IMSenderTypeAI, + SenderID: aiAgent.ID, + MessageType: enums.IMMessageTypeText, + Content: content, + Payload: payload, + SendStatus: enums.IMMessageStatusSent, + SentAt: &now, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: operator.UserID, + CreateUserName: operator.Username, + UpdatedAt: now, + UpdateUserID: operator.UserID, + UpdateUserName: operator.Username, + }, + } + if err := repositories.MessageRepository.Create(ctx.Tx, message); err != nil { + return nil, err + } + + if _, err := ConversationReadStateService.MarkAgentRead(ctx, conversation, operator, message); err != nil { + return nil, err + } + agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID) + agentUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(agentReadState), enums.IMSenderTypeCustomer) + if err != nil { + return nil, err + } + customerUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI) + if err != nil { + return nil, err + } + + conversationUpdates := map[string]any{ + "last_message_id": message.ID, + "last_message_at": now, + "last_active_at": now, + "last_message_summary": limitText(summary, 255), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + "agent_unread_count": agentUnreadCount, + "customer_unread_count": customerUnreadCount, + } + if err := repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, conversationUpdates); err != nil { + return nil, err + } + if err := ConversationEventLogService.CreateEvent(ctx, + conversation.ID, + enums.IMEventTypeMessageSend, + enums.IMSenderTypeAI, + 0, + enums.GetIMSenderTypeLabel(enums.IMSenderTypeAI)+"发送消息", + "", + ); err != nil { + return nil, err + } + + conversation.LastMessageID = message.ID + conversation.LastMessageAt = now + conversation.LastActiveAt = now + conversation.LastMessageSummary = limitText(summary, 255) + conversation.AgentUnreadCount = int(agentUnreadCount) + conversation.CustomerUnreadCount = int(customerUnreadCount) + conversation.UpdatedAt = now + conversation.UpdateUserID = operator.UserID + conversation.UpdateUserName = operator.Username + return message, nil +} + +func (s *messageService) SendCustomerMessage(conversationID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, external openidentity.ExternalUser) (*models.Message, error) { + return s.SendCustomerMessageWithRequestID(conversationID, clientMsgID, messageType, content, payload, external, "") +} + +func (s *messageService) SendCustomerMessageWithRequestID(conversationID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, external openidentity.ExternalUser, requestID string) (*models.Message, error) { + ext := external + return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, &ext, requestID, 0) +} + +func (s *messageService) sendMessage(conversationID int64, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string, + messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string, workflowRunID int64) (*models.Message, error) { + + if senderType == enums.IMSenderTypeCustomer { + if external == nil || strings.TrimSpace(external.ExternalID) == "" { + return nil, errorsx.UnauthorizedI18n("error.e0149") + } + } else if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + + if strs.IsBlank(string(messageType)) { + messageType = enums.IMMessageTypeText + } + conversation, err := s.ValidateConversationSender(conversationID, senderType, operator, external) + if err != nil { + return nil, err + } + return s.sendValidatedMessage(conversation, senderType, reqSenderID, clientMsgID, messageType, content, payload, operator, external, requestID, workflowRunID) +} + +func (s *messageService) sendValidatedMessage(conversation *models.Conversation, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string, + messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string, workflowRunID int64) (*models.Message, error) { + + var err error + var summary string + content, payload, summary, err = s.normalizeMessageContent(conversation.ID, messageType, content, payload) + if err != nil { + return nil, err + } + if strs.IsBlank(content) && strs.IsBlank(payload) { + return nil, errorsx.InvalidParamI18n("error.e0245") + } + + // 防抖,消息存在就不再发送了 + if strs.IsNotBlank(clientMsgID) { + if existing := repositories.MessageRepository.GetByClientMsgID(sqls.DB(), conversation.ID, clientMsgID); existing != nil { + return existing, nil + } + } + + var ( + now = time.Now() + traceID = tracex.NormalizeRequestID(requestID) + auditUserID = int64(0) + auditUserName = "" + ) + if operator != nil { + auditUserID = operator.UserID + auditUserName = operator.Username + } + if senderType == enums.IMSenderTypeCustomer && external != nil { + auditUserID = 0 + auditUserName = displayExternalName(external) + } + message := &models.Message{ + ConversationID: conversation.ID, + RequestID: traceID, + WorkflowRunID: workflowRunID, + ClientMsgID: clientMsgID, + SenderType: senderType, + SenderID: reqSenderID, + MessageType: messageType, + Content: content, + Payload: payload, + SendStatus: enums.IMMessageStatusSent, + SentAt: &now, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: auditUserID, + CreateUserName: auditUserName, + UpdatedAt: now, + UpdateUserID: auditUserID, + UpdateUserName: auditUserName, + }, + } + + switch senderType { + case enums.IMSenderTypeAgent: + if message.SenderID == 0 { + message.SenderID = operator.UserID + } + case enums.IMSenderTypeAI: + if message.SenderID == 0 { + message.SenderID = reqSenderID + } + default: + message.SenderID = 0 + } + + err = sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.MessageRepository.Create(ctx.Tx, message); err != nil { + return err + } + + // 处理已读、维度 + agentUnreadCount, customerUnreadCount, err := s.handleReadState(ctx, senderType, conversation, operator, message, external) + if err != nil { + return err + } + + conversation.LastMessageID = message.ID + conversation.LastMessageAt = now + conversation.LastActiveAt = now + conversation.LastMessageSummary = limitText(summary, 255) + conversation.UpdateUserID = int64(0) + conversation.UpdateUserName = "" + if operator != nil { + conversation.UpdateUserID = operator.UserID + conversation.UpdateUserName = operator.Username + } + if senderType == enums.IMSenderTypeCustomer && external != nil { + conversation.UpdateUserID = 0 + conversation.UpdateUserName = displayExternalName(external) + } + conversation.UpdatedAt = now + conversation.AgentUnreadCount = int(agentUnreadCount) + conversation.CustomerUnreadCount = int(customerUnreadCount) + if err := repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, map[string]any{ + "last_message_id": conversation.LastMessageID, + "last_message_at": conversation.LastMessageAt, + "last_active_at": conversation.LastActiveAt, + "last_message_summary": conversation.LastMessageSummary, + "update_user_id": conversation.UpdateUserID, + "update_user_name": conversation.UpdateUserName, + "updated_at": conversation.UpdatedAt, + "agent_unread_count": conversation.AgentUnreadCount, + "customer_unread_count": conversation.CustomerUnreadCount, + }); err != nil { + return err + } + + // 记录事件日志 + if err := ConversationEventLogService.CreateEventWithRequestID(ctx, conversation.ID, traceID, enums.IMEventTypeMessageSend, senderType, + func() int64 { + if operator != nil { + return operator.UserID + } + return 0 + }(), + enums.GetIMSenderTypeLabel(senderType)+"发送消息", + "", + ); err != nil { + return err + } + + return nil + }) + if err != nil { + return nil, err + } + + // 处理websocket消息 + WsService.PublishMessageCreated(conversation, message) + WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationUpdated) + + // Telegram 渠道消息入队,异步发送 + if enqueueErr := ChannelMessageOutboxService.EnqueueTelegramMessage(conversation, message); enqueueErr != nil { + slog.Error("enqueue telegram outbox failed", + "conversation_id", conversation.ID, + "message_id", message.ID, + "error", enqueueErr, + ) + } + + // Zalo OA 渠道消息入队,异步发送 + if enqueueErr := ChannelMessageOutboxService.EnqueueZaloOAMessage(conversation, message); enqueueErr != nil { + slog.Error("enqueue zalo oa outbox failed", + "conversation_id", conversation.ID, + "message_id", message.ID, + "error", enqueueErr, + ) + } + + // Slack 渠道消息入队,异步发送 + if enqueueErr := ChannelMessageOutboxService.EnqueueSlackMessage(conversation, message); enqueueErr != nil { + slog.Error("enqueue slack outbox failed", + "conversation_id", conversation.ID, + "message_id", message.ID, + "error", enqueueErr, + ) + } + + // Discord 渠道消息入队,异步发送 + if enqueueErr := ChannelMessageOutboxService.EnqueueDiscordMessage(conversation, message); enqueueErr != nil { + slog.Error("enqueue discord outbox failed", + "conversation_id", conversation.ID, + "message_id", message.ID, + "error", enqueueErr, + ) + } + + // Email 渠道消息入队,异步发送 + if enqueueErr := ChannelMessageOutboxService.EnqueueEmailMessage(conversation, message); enqueueErr != nil { + slog.Error("enqueue email outbox failed", + "conversation_id", conversation.ID, + "message_id", message.ID, + "error", enqueueErr, + ) + } + + // 客户发送消息,触发AI回复 + if senderType == enums.IMSenderTypeCustomer { + if TriggerAIReplyAsyncHook != nil { + TriggerAIReplyAsyncHook(*conversation, *message) + } + } + return message, err +} + +// handleReadState 根据发送者类型更新会话已读状态,并返回更新后的客服和客户未读消息数。 +func (s *messageService) handleReadState(ctx *sqls.TxContext, senderType enums.IMSenderType, conversation *models.Conversation, operator *dto.AuthPrincipal, message *models.Message, external *openidentity.ExternalUser) (agentUnreadCount int64, customerUnreadCount int64, err error) { + readStateType := senderType + if senderType == enums.IMSenderTypeAI { + readStateType = enums.IMSenderTypeAgent + } + if readStateType == enums.IMSenderTypeAgent { + if _, err := ConversationReadStateService.MarkAgentRead(ctx, conversation, operator, message); err != nil { + return 0, 0, err + } + } else { + if _, err := ConversationReadStateService.MarkCustomerRead(ctx, conversation, external, message); err != nil { + return 0, 0, err + } + } + agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID) + if agentUnreadCount, err = ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(agentReadState), enums.IMSenderTypeCustomer); err != nil { + return 0, 0, err + } + if customerUnreadCount, err = ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI); err != nil { + return 0, 0, err + } + return agentUnreadCount, customerUnreadCount, nil +} + +func limitText(value string, maxLen int) string { + if maxLen <= 0 { + return "" + } + value = strings.TrimSpace(value) + runes := []rune(value) + if len(runes) <= maxLen { + return value + } + return string(runes[:maxLen]) +} + +func buildMessageSummary(messageType enums.IMMessageType, content string) string { + content = strings.TrimSpace(content) + if content != "" { + return content + } + switch messageType { + case enums.IMMessageTypeImage: + return "[图片]" + case enums.IMMessageTypeAttachment: + return "[附件]" + case enums.IMMessageTypeHTML: + return utils.BuildHTMLSummary(content) + case "": + return "" + default: + return "[" + string(messageType) + "]" + } +} + +func (s *messageService) normalizeMessageContent(conversationID int64, messageType enums.IMMessageType, content, payload string) (string, string, string, error) { + switch messageType { + case enums.IMMessageTypeHTML: + sanitized := utils.SanitizeMessageHTML(content) + normalized, err := utils.NormalizeMessageHTMLAssets(sanitized) + if err != nil { + return "", "", "", errorsx.InvalidParamI18n("error.e0030") + } + summary := utils.BuildHTMLSummary(normalized) + if summary == "" { + return "", "", "", errorsx.InvalidParamI18n("error.e0245") + } + return normalized, "", summary, nil + case enums.IMMessageTypeImage, enums.IMMessageTypeAttachment: + assetPayload, err := parseIMMessageAssetPayload(payload) + if err != nil { + return "", "", "", err + } + asset := AssetService.GetByAssetID(assetPayload.AssetID) + if err := validateConversationAsset(asset, conversationID, messageType); err != nil { + return "", "", "", err + } + canonicalPayload, err := buildIMMessageAssetPayload(asset) + if err != nil { + return "", "", "", err + } + summary := "[附件]" + if messageType == enums.IMMessageTypeImage { + summary = "[图片]" + } + content = strings.TrimSpace(asset.Filename) + return content, canonicalPayload, summary + s.suffixFilenameForSummary(asset.Filename), nil + default: + content = strings.TrimSpace(content) + if content == "" && strings.TrimSpace(payload) == "" { + return "", "", "", errorsx.InvalidParamI18n("error.e0245") + } + return content, strings.TrimSpace(payload), buildMessageSummary(messageType, content), nil + } +} + +func (s *messageService) ValidateConversationSender(conversationID int64, senderType enums.IMSenderType, operator *dto.AuthPrincipal, external *openidentity.ExternalUser) (*models.Conversation, error) { + conversation := ConversationService.Get(conversationID) + if conversation == nil { + return nil, errorsx.InvalidParamI18n("error.e0116") + } + if conversation.Status == enums.IMConversationStatusClosed { + return nil, errorsx.InvalidParamI18n("error.e0119") + } + switch senderType { + case enums.IMSenderTypeAgent: + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + if conversation.Status != enums.IMConversationStatusActive || conversation.CurrentAssigneeID == 0 { + return nil, errorsx.InvalidParamI18n("error.e0120") + } + if conversation.CurrentAssigneeID != operator.UserID { + return nil, errorsx.ForbiddenI18n("error.e0191") + } + case enums.IMSenderTypeAI: + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + if conversation.Status != enums.IMConversationStatusAIServing && !s.allowAIMessageOnPendingHandoff(conversation) { + return nil, errorsx.ForbiddenI18n("error.e0189") + } + if conversation.CurrentAssigneeID != 0 { + return nil, errorsx.ForbiddenI18n("error.e0192") + } + case enums.IMSenderTypeCustomer: + if external == nil || !ConversationService.IsCustomerConversationOwner(conversation, *external) { + return nil, errorsx.ForbiddenI18n("error.e0222") + } + default: + return nil, errorsx.InvalidParamI18n("error.e0080") + } + return conversation, nil +} + +func (s *messageService) allowAIMessageOnPendingHandoff(conversation *models.Conversation) bool { + if conversation == nil { + return false + } + return conversation.Status == enums.IMConversationStatusPending && + conversation.HandoffAt != nil && + conversation.CurrentAssigneeID == 0 +} + +func (s *messageService) suffixFilenameForSummary(filename string) string { + filename = strings.TrimSpace(filename) + if filename == "" { + return "" + } + return " " + filename +} + +func (s *messageService) readMessageID(state *models.ConversationReadState) int64 { + if state == nil { + return 0 + } + return state.LastReadMessageID +} diff --git a/internal/services/message_service_test.go b/internal/services/message_service_test.go new file mode 100644 index 0000000..222e636 --- /dev/null +++ b/internal/services/message_service_test.go @@ -0,0 +1,461 @@ +package services + +import ( + "fmt" + "strings" + "sync" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/openidentity" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestAllowAIMessageOnPendingHandoff(t *testing.T) { + conversation := &models.Conversation{ + Status: enums.IMConversationStatusPending, + CurrentAssigneeID: 0, + HandoffAt: ptrTime(time.Now()), + } + if !MessageService.allowAIMessageOnPendingHandoff(conversation) { + t.Fatalf("expected pending handoff conversation to allow ai handoff notice") + } + + conversation.Status = enums.IMConversationStatusAIServing + if MessageService.allowAIMessageOnPendingHandoff(conversation) { + t.Fatalf("expected ai serving conversation not to use pending handoff allowance") + } +} + +func ptrTime(v time.Time) *time.Time { + return &v +} + +func setupMessageWelcomeTestDB(t *testing.T) *gorm.DB { + t.Helper() + + dbName := "message_welcome_test_" + strings.NewReplacer("/", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("get sqlite db: %v", err) + } + t.Cleanup(func() { + if err := sqlDB.Close(); err != nil { + t.Fatalf("close sqlite db: %v", err) + } + }) + if err := db.AutoMigrate( + &models.AIAgent{}, + &models.Channel{}, + &models.ChannelMessageOutbox{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &models.Conversation{}, + &models.ConversationParticipant{}, + &models.ConversationReadState{}, + &models.ConversationEventLog{}, + &models.Message{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + return db +} + +func createWelcomeTestAIAgent(t *testing.T, db *gorm.DB, welcomeMessage string) *models.AIAgent { + t.Helper() + + now := time.Now() + aiAgent := &models.AIAgent{ + Name: "welcome-test-agent", + Status: enums.StatusOk, + ServiceMode: enums.IMConversationServiceModeAIOnly, + WelcomeMessage: welcomeMessage, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + } + if err := db.Create(aiAgent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + return aiAgent +} + +func welcomeTestExternalUser(id string) openidentity.ExternalUser { + return openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceUser, + ExternalID: id, + ExternalName: "访客" + id, + } +} + +func createMessageTestConversation(t *testing.T, db *gorm.DB, aiAgentID int64) *models.Conversation { + t.Helper() + now := time.Now() + conversation := &models.Conversation{ + CustomerID: 1, + ChannelID: 11, + AIAgentID: aiAgentID, + Status: enums.IMConversationStatusAIServing, + LastActiveAt: now, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + } + if err := db.Create(conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + return conversation +} + +func workflowTestAIPrincipal() *dto.AuthPrincipal { + return &dto.AuthPrincipal{UserID: 0, Username: "AI", Nickname: "AI"} +} + +func TestConversationCreateCreatesAIWelcomeMessage(t *testing.T) { + db := setupMessageWelcomeTestDB(t) + aiAgent := createWelcomeTestAIAgent(t, db, " 您好,请问有什么可以帮您? ") + + conversation, err := ConversationService.Create(welcomeTestExternalUser("welcome-1"), 11, aiAgent.ID) + if err != nil { + t.Fatalf("create conversation: %v", err) + } + if conversation == nil { + t.Fatalf("expected conversation") + } + + var messages []models.Message + if err := db.Find(&messages).Error; err != nil { + t.Fatalf("find messages: %v", err) + } + if len(messages) != 1 { + t.Fatalf("expected exactly one welcome message, got %d", len(messages)) + } + message := messages[0] + if message.ConversationID != conversation.ID { + t.Fatalf("expected conversation_id %d, got %d", conversation.ID, message.ConversationID) + } + if message.SenderType != enums.IMSenderTypeAI { + t.Fatalf("expected sender type ai, got %q", message.SenderType) + } + if message.SenderID != aiAgent.ID { + t.Fatalf("expected sender id %d, got %d", aiAgent.ID, message.SenderID) + } + if message.MessageType != enums.IMMessageTypeText { + t.Fatalf("expected message type text, got %q", message.MessageType) + } + if message.Content != "您好,请问有什么可以帮您?" { + t.Fatalf("expected trimmed welcome content, got %q", message.Content) + } + if message.SendStatus != enums.IMMessageStatusSent { + t.Fatalf("expected sent status, got %d", message.SendStatus) + } + + var updated models.Conversation + if err := db.First(&updated, conversation.ID).Error; err != nil { + t.Fatalf("find conversation: %v", err) + } + if updated.LastMessageID != message.ID { + t.Fatalf("expected last message id %d, got %d", message.ID, updated.LastMessageID) + } + if updated.LastMessageSummary != "您好,请问有什么可以帮您?" { + t.Fatalf("expected last message summary, got %q", updated.LastMessageSummary) + } + if updated.CustomerUnreadCount != 1 { + t.Fatalf("expected customer unread count 1, got %d", updated.CustomerUnreadCount) + } + if updated.AgentUnreadCount != 0 { + t.Fatalf("expected agent unread count 0, got %d", updated.AgentUnreadCount) + } +} + +func TestSendCustomerMessageStoresRequestIDOnMessageAndEvent(t *testing.T) { + db := setupMessageWelcomeTestDB(t) + aiAgent := createWelcomeTestAIAgent(t, db, "") + external := welcomeTestExternalUser("trace-user") + conversation, err := ConversationService.Create(external, 11, aiAgent.ID) + if err != nil { + t.Fatalf("create conversation: %v", err) + } + + message, err := MessageService.SendCustomerMessageWithRequestID( + conversation.ID, + "client-msg-trace", + enums.IMMessageTypeText, + "hello", + "", + external, + "trace-123", + ) + if err != nil { + t.Fatalf("SendCustomerMessageWithRequestID() error = %v", err) + } + if message.RequestID != "trace-123" { + t.Fatalf("message.RequestID=%q want %q", message.RequestID, "trace-123") + } + + var event models.ConversationEventLog + if err := db.Where("conversation_id = ?", conversation.ID).Order("id DESC").First(&event).Error; err != nil { + t.Fatalf("find event: %v", err) + } + if event.RequestID != "trace-123" { + t.Fatalf("event.RequestID=%q want %q", event.RequestID, "trace-123") + } +} + +func TestSendCustomerMessagesConcurrentlyAssignsUniqueIDs(t *testing.T) { + db := setupMessageWelcomeTestDB(t) + aiAgent := createWelcomeTestAIAgent(t, db, "") + external := welcomeTestExternalUser("concurrent-user") + conversation, err := ConversationService.Create(external, 11, aiAgent.ID) + if err != nil { + t.Fatalf("create conversation: %v", err) + } + + const messageCount = 10 + var wg sync.WaitGroup + errCh := make(chan error, messageCount) + for i := 0; i < messageCount; i++ { + i := i + wg.Add(1) + go func() { + defer wg.Done() + _, err := MessageService.SendCustomerMessageWithRequestID( + conversation.ID, + fmt.Sprintf("client-msg-concurrent-%d", i), + enums.IMMessageTypeText, + "hello concurrent", + "", + external, + "trace-concurrent", + ) + errCh <- err + }() + } + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + t.Fatalf("SendCustomerMessageWithRequestID() concurrent error = %v", err) + } + } + + var messages []models.Message + if err := db. + Where("conversation_id = ? AND sender_type = ?", conversation.ID, enums.IMSenderTypeCustomer). + Order("id ASC"). + Find(&messages).Error; err != nil { + t.Fatalf("find messages: %v", err) + } + if len(messages) != messageCount { + t.Fatalf("expected %d customer messages, got %d", messageCount, len(messages)) + } + seen := make(map[int64]struct{}, messageCount) + for _, message := range messages { + if message.ID <= 0 { + t.Fatalf("expected persisted message id, got %d", message.ID) + } + if _, ok := seen[message.ID]; ok { + t.Fatalf("duplicate message id %d", message.ID) + } + seen[message.ID] = struct{}{} + } +} + +func TestUnreadCountUsesLastReadMessageID(t *testing.T) { + db := setupMessageWelcomeTestDB(t) + aiAgent := createWelcomeTestAIAgent(t, db, "") + conversation := createMessageTestConversation(t, db, aiAgent.ID) + now := time.Now() + + messages := []models.Message{ + { + ConversationID: conversation.ID, + ClientMsgID: "read-message", + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "read", + SendStatus: enums.IMMessageStatusSent, + SentAt: &now, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + }, + { + ConversationID: conversation.ID, + ClientMsgID: "unread-message-1", + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "unread 1", + SendStatus: enums.IMMessageStatusSent, + SentAt: &now, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + }, + { + ConversationID: conversation.ID, + ClientMsgID: "unread-message-2", + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "unread 2", + SendStatus: enums.IMMessageStatusSent, + SentAt: &now, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + }, + } + if err := db.Create(&messages).Error; err != nil { + t.Fatalf("create messages: %v", err) + } + + readState := &models.ConversationReadState{ + ConversationID: conversation.ID, + ReaderType: enums.IMSenderTypeAgent, + ReaderID: 1, + LastReadMessageID: messages[0].ID, + LastReadAt: &now, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(readState).Error; err != nil { + t.Fatalf("create read state: %v", err) + } + + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + count, err := ConversationService.countUnreadByState(ctx, conversation.ID, readState, enums.IMSenderTypeCustomer) + if err != nil { + return err + } + if count != 2 { + t.Fatalf("unread count=%d want 2", count) + } + return nil + }) + if err != nil { + t.Fatalf("count unread: %v", err) + } +} + +func TestSendAIMessageStoresWorkflowRunID(t *testing.T) { + db := setupMessageWelcomeTestDB(t) + aiAgent := createWelcomeTestAIAgent(t, db, "") + conversation := createMessageTestConversation(t, db, aiAgent.ID) + + message, err := MessageService.SendAIMessageWithRequestIDAndWorkflowRunID( + conversation.ID, + aiAgent.ID, + "ai-reply-workflow-1", + enums.IMMessageTypeText, + "AI reply", + "", + workflowTestAIPrincipal(), + "trace-workflow-1", + 9988, + ) + if err != nil { + t.Fatalf("SendAIMessageWithRequestIDAndWorkflowRunID() error = %v", err) + } + if message.WorkflowRunID != 9988 { + t.Fatalf("message.WorkflowRunID=%d want 9988", message.WorkflowRunID) + } + + var stored models.Message + if err := db.First(&stored, message.ID).Error; err != nil { + t.Fatalf("find message: %v", err) + } + if stored.WorkflowRunID != 9988 { + t.Fatalf("stored.WorkflowRunID=%d want 9988", stored.WorkflowRunID) + } +} + +func TestConversationCreateDoesNotDuplicateWelcomeMessageForExistingConversation(t *testing.T) { + db := setupMessageWelcomeTestDB(t) + aiAgent := createWelcomeTestAIAgent(t, db, "欢迎咨询") + external := welcomeTestExternalUser("u-2") + + first, err := ConversationService.Create(external, 11, aiAgent.ID) + if err != nil { + t.Fatalf("create first conversation: %v", err) + } + second, err := ConversationService.Create(external, 11, aiAgent.ID) + if err != nil { + t.Fatalf("create second conversation: %v", err) + } + if first.ID != second.ID { + t.Fatalf("expected existing conversation id %d, got %d", first.ID, second.ID) + } + + var count int64 + if err := db.Model(&models.Message{}).Where("conversation_id = ?", first.ID).Count(&count).Error; err != nil { + t.Fatalf("count messages: %v", err) + } + if count != 1 { + t.Fatalf("expected exactly one welcome message, got %d", count) + } +} + +func TestConversationCreateSkipsBlankWelcomeMessage(t *testing.T) { + db := setupMessageWelcomeTestDB(t) + aiAgent := createWelcomeTestAIAgent(t, db, " ") + + conversation, err := ConversationService.Create(welcomeTestExternalUser("blank-welcome-1"), 11, aiAgent.ID) + if err != nil { + t.Fatalf("create conversation: %v", err) + } + if conversation == nil { + t.Fatalf("expected conversation") + } + + var count int64 + if err := db.Model(&models.Message{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error; err != nil { + t.Fatalf("count messages: %v", err) + } + if count != 0 { + t.Fatalf("expected no welcome messages, got %d", count) + } + + var updated models.Conversation + if err := db.First(&updated, conversation.ID).Error; err != nil { + t.Fatalf("find conversation: %v", err) + } + if updated.LastMessageID != 0 { + t.Fatalf("expected last message id 0, got %d", updated.LastMessageID) + } + if updated.CustomerUnreadCount != 0 { + t.Fatalf("expected customer unread count 0, got %d", updated.CustomerUnreadCount) + } +} + +func TestConversationCreateWelcomeMessageDoesNotTriggerAIReplyHook(t *testing.T) { + db := setupMessageWelcomeTestDB(t) + aiAgent := createWelcomeTestAIAgent(t, db, "欢迎咨询") + + previousHook := TriggerAIReplyAsyncHook + called := false + TriggerAIReplyAsyncHook = func(conversation models.Conversation, message models.Message) { + called = true + } + t.Cleanup(func() { + TriggerAIReplyAsyncHook = previousHook + }) + + if _, err := ConversationService.Create(welcomeTestExternalUser("hook-welcome-1"), 11, aiAgent.ID); err != nil { + t.Fatalf("create conversation: %v", err) + } + if called { + t.Fatalf("expected welcome message not to trigger ai reply hook") + } +} diff --git a/internal/services/migration_service.go b/internal/services/migration_service.go new file mode 100644 index 0000000..7d8aa9a --- /dev/null +++ b/internal/services/migration_service.go @@ -0,0 +1,67 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var MigrationService = newMigrationService() + +func newMigrationService() *migrationService { + return &migrationService{} +} + +type migrationService struct { +} + +func (s *migrationService) Get(id int64) *models.Migration { + return repositories.MigrationRepository.Get(sqls.DB(), id) +} + +func (s *migrationService) Take(where ...interface{}) *models.Migration { + return repositories.MigrationRepository.Take(sqls.DB(), where...) +} + +func (s *migrationService) Find(cnd *sqls.Cnd) []models.Migration { + return repositories.MigrationRepository.Find(sqls.DB(), cnd) +} + +func (s *migrationService) FindOne(cnd *sqls.Cnd) *models.Migration { + return repositories.MigrationRepository.FindOne(sqls.DB(), cnd) +} + +func (s *migrationService) FindPageByParams(params *params.QueryParams) (list []models.Migration, paging *sqls.Paging) { + return repositories.MigrationRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *migrationService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Migration, paging *sqls.Paging) { + return repositories.MigrationRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *migrationService) Count(cnd *sqls.Cnd) int64 { + return repositories.MigrationRepository.Count(sqls.DB(), cnd) +} + +func (s *migrationService) Create(t *models.Migration) error { + return repositories.MigrationRepository.Create(sqls.DB(), t) +} + +func (s *migrationService) Update(t *models.Migration) error { + return repositories.MigrationRepository.Update(sqls.DB(), t) +} + +func (s *migrationService) Updates(id int64, columns map[string]interface{}) error { + return repositories.MigrationRepository.Updates(sqls.DB(), id, columns) +} + +func (s *migrationService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.MigrationRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *migrationService) Delete(id int64) { + repositories.MigrationRepository.Delete(sqls.DB(), id) +} diff --git a/internal/services/notification_service.go b/internal/services/notification_service.go new file mode 100644 index 0000000..804c534 --- /dev/null +++ b/internal/services/notification_service.go @@ -0,0 +1,105 @@ +package services + +import ( + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +var NotificationService = newNotificationService() + +func newNotificationService() *notificationService { + return ¬ificationService{} +} + +type notificationService struct { +} + +func (s *notificationService) Create(req request.CreateNotificationRequest) (*models.Notification, error) { + if req.RecipientUserID <= 0 { + return nil, errorsx.InvalidParamI18n("error.e0212") + } + now := time.Now() + item := &models.Notification{ + RecipientUserID: req.RecipientUserID, + Title: strings.TrimSpace(req.Title), + Content: strings.TrimSpace(req.Content), + NotificationType: strings.TrimSpace(req.NotificationType), + BizType: strings.TrimSpace(req.BizType), + BizID: req.BizID, + ActionURL: strings.TrimSpace(req.ActionURL), + Status: enums.StatusOk, + CreatedAt: now, + } + if err := repositories.NotificationRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return item, nil +} + +func (s *notificationService) CreateAndPush(req request.CreateNotificationRequest) (*models.Notification, error) { + item, err := s.Create(req) + if err != nil { + return nil, err + } + WsService.PublishNotificationCreated(item.RecipientUserID, response.NotificationResponse{ + ID: item.ID, + RecipientUserID: item.RecipientUserID, + Title: item.Title, + Content: item.Content, + NotificationType: item.NotificationType, + BizType: item.BizType, + BizID: item.BizID, + ActionURL: item.ActionURL, + ReadAt: utils.FormatTimePtr(item.ReadAt), + CreatedAt: utils.FormatTime(item.CreatedAt), + }) + return item, nil +} + +func (s *notificationService) FindPageByCnd(cnd *sqls.Cnd) ([]models.Notification, *sqls.Paging) { + return repositories.NotificationRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *notificationService) CountUnread(userID int64) int64 { + if userID <= 0 { + return 0 + } + return repositories.NotificationRepository.Count(sqls.DB(), sqls.NewCnd(). + Eq("recipient_user_id", userID). + Eq("status", enums.StatusOk). + Where("read_at IS NULL")) +} + +func (s *notificationService) MarkRead(id int64, userID int64) error { + if id <= 0 { + return errorsx.InvalidParamI18n("error.e0337") + } + item := repositories.NotificationRepository.Get(sqls.DB(), id) + if item == nil || item.RecipientUserID != userID { + return errorsx.InvalidParamI18n("error.e0337") + } + if item.ReadAt != nil { + return nil + } + now := time.Now() + return repositories.NotificationRepository.Updates(sqls.DB(), id, map[string]any{ + "read_at": now, + }) +} + +func (s *notificationService) MarkAllRead(userID int64) error { + if userID <= 0 { + return errorsx.InvalidParamI18n("error.e0212") + } + return repositories.NotificationRepository.MarkAllRead(sqls.DB(), userID, time.Now()) +} diff --git a/internal/services/notification_service_test.go b/internal/services/notification_service_test.go new file mode 100644 index 0000000..18ca459 --- /dev/null +++ b/internal/services/notification_service_test.go @@ -0,0 +1,126 @@ +package services_test + +import ( + "testing" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/services" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestNotificationServiceCreateAndUnreadCount(t *testing.T) { + setupNotificationTestDB(t) + + item, err := services.NotificationService.Create(request.CreateNotificationRequest{ + RecipientUserID: 101, + Title: "工单指派提醒", + Content: "工单 TK-1 已指派给你", + NotificationType: "ticket_assigned", + BizType: "ticket", + BizID: 1, + ActionURL: "/dashboard/tickets/1", + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if item.ID == 0 { + t.Fatalf("expected notification id to be assigned") + } + if item.RecipientUserID != 101 || item.ReadAt != nil { + t.Fatalf("unexpected notification: %+v", item) + } + if got := services.NotificationService.CountUnread(101); got != 1 { + t.Fatalf("expected unread count 1, got %d", got) + } + if got := services.NotificationService.CountUnread(102); got != 0 { + t.Fatalf("expected unread count 0 for another user, got %d", got) + } +} + +func TestNotificationServiceMarkReadRequiresOwner(t *testing.T) { + setupNotificationTestDB(t) + + item, err := services.NotificationService.Create(request.CreateNotificationRequest{ + RecipientUserID: 201, + Title: "会话分配提醒", + Content: "会话 #9 已分配给你", + NotificationType: "conversation_assigned", + BizType: "conversation", + BizID: 9, + ActionURL: "/dashboard/conversations?conversationId=9", + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + if err := services.NotificationService.MarkRead(item.ID, 202); err == nil { + t.Fatalf("expected foreign user mark read to fail") + } + if got := services.NotificationService.CountUnread(201); got != 1 { + t.Fatalf("expected notification to remain unread, got %d", got) + } + if err := services.NotificationService.MarkRead(item.ID, 201); err != nil { + t.Fatalf("MarkRead() owner error = %v", err) + } + if got := services.NotificationService.CountUnread(201); got != 0 { + t.Fatalf("expected unread count 0 after mark read, got %d", got) + } +} + +func TestNotificationServiceMarkAllReadOnlyCurrentUser(t *testing.T) { + setupNotificationTestDB(t) + + for _, userID := range []int64{301, 301, 302} { + if _, err := services.NotificationService.Create(request.CreateNotificationRequest{ + RecipientUserID: userID, + Title: "工单指派提醒", + Content: "工单已指派给你", + NotificationType: "ticket_assigned", + BizType: "ticket", + BizID: userID, + ActionURL: "/dashboard/tickets/1", + }); err != nil { + t.Fatalf("Create() error = %v", err) + } + } + + if err := services.NotificationService.MarkAllRead(301); err != nil { + t.Fatalf("MarkAllRead() error = %v", err) + } + if got := services.NotificationService.CountUnread(301); got != 0 { + t.Fatalf("expected user 301 unread count 0, got %d", got) + } + if got := services.NotificationService.CountUnread(302); got != 1 { + t.Fatalf("expected user 302 unread count 1, got %d", got) + } +} + +func setupNotificationTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.Notification{}); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + return db +} diff --git a/internal/services/oidc_login_service.go b/internal/services/oidc_login_service.go new file mode 100644 index 0000000..e764500 --- /dev/null +++ b/internal/services/oidc_login_service.go @@ -0,0 +1,416 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/oidcclient" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/repositories" + "context" + "crypto/sha256" + "encoding/hex" + "strconv" + "strings" + "time" + "unicode" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var OIDCLoginService = newOIDCLoginService() + +type oidcLoginService struct { +} + +type oidcLoginProfile = oidcclient.Profile + +func newOIDCLoginService() *oidcLoginService { + return &oidcLoginService{} +} + +func (s *oidcLoginService) BuildOIDCLoginURL(next string) (string, error) { + return oidcclient.BuildAuthCodeURL(next) +} + +func (s *oidcLoginService) LoginByOIDC(ctx context.Context, code, state string, authCfg config.AuthConfig, clientIP, userAgent string) (string, string, error) { + next, verifier, err := oidcclient.ParseState(state) + if err != nil { + return "", "", err + } + profile, err := oidcclient.ExchangeCode(ctx, code, verifier) + if err != nil { + return "", "", err + } + loginResp, err := s.loginWithOIDCProfile(profile, authCfg, clientIP, userAgent) + if err != nil { + return "", "", err + } + ticket, err := oidcclient.IssueLoginTicket(loginResp) + if err != nil { + return "", "", err + } + return ticket, next, nil +} + +func (s *oidcLoginService) ExchangeOIDCLoginTicket(ticket string) (*response.LoginResponse, error) { + return oidcclient.ConsumeLoginTicket(ticket) +} + +func (s *oidcLoginService) loginWithOIDCProfile(profile *oidcLoginProfile, authCfg config.AuthConfig, clientIP, userAgent string) (*response.LoginResponse, error) { + if profile == nil || strings.TrimSpace(profile.Subject) == "" { + return nil, errorsx.BusinessErrorI18n(2, "error.oidc.profileMissing") + } + + var ret *response.LoginResponse + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + var ( + identity = repositories.UserIdentityRepository.GetBy(ctx.Tx, enums.ThirdProviderOIDC, "", profile.Subject) + user *models.User + err error + ) + if identity == nil { + user, identity, err = s.createOIDCUser(ctx, profile) + if err != nil { + return err + } + } else { + if identity.Status != enums.StatusOk { + return errorsx.BusinessErrorI18n(3, "error.oidc.bindingDisabled") + } + user = repositories.UserRepository.Get(ctx.Tx, identity.UserID) + if user == nil { + return errorsx.BusinessErrorI18n(4, "error.oidc.boundUserMissing") + } + } + + if user.Status != enums.StatusOk { + return errorsx.UnauthorizedI18n("error.e0200") + } + + userUpdates := map[string]any{ + "nickname": s.resolveOIDCNickname(user.Nickname, profile), + "avatar": s.resolveOIDCAvatar(user.Avatar, profile), + "last_login_at": time.Now(), + "last_login_ip": clientIP, + "update_user_id": user.ID, + "update_user_name": user.Username, + "updated_at": time.Now(), + } + if (user.Email == nil || *user.Email == "") && profile.Email != "" { + if email := s.availableEmail(ctx.Tx, profile.Email); email != nil { + userUpdates["email"] = *email + } + } + + if err = repositories.UserRepository.Updates(ctx.Tx, user.ID, userUpdates); err != nil { + return err + } + + s.ensureDefaultOIDCRole(ctx.Tx, user) + s.syncOIDCUserOrganizations(ctx.Tx, user, profile) + + if err = repositories.UserIdentityRepository.Updates(ctx.Tx, identity.ID, map[string]any{ + "provider_name": enums.GetThirdProviderLabel(enums.ThirdProviderOIDC), + "raw_profile": profile.RawProfile, + "last_auth_at": time.Now(), + "status": enums.StatusOk, + "update_user_id": user.ID, + "update_user_name": user.Username, + "updated_at": time.Now(), + }); err != nil { + return err + } + + ret, err = AuthService.issueTokens(ctx, user, clientIP, userAgent, authCfg) + return err + }) + if err != nil { + return nil, err + } + return ret, nil +} + +func (s *oidcLoginService) createOIDCUser(ctx *sqls.TxContext, profile *oidcLoginProfile) (*models.User, *models.UserIdentity, error) { + now := time.Now() + email := s.availableEmail(ctx.Tx, profile.Email) + username := s.availableUsername(ctx.Tx, profile) + + user := &models.User{ + Username: username, + Nickname: s.resolveOIDCNickname("", profile), + Avatar: s.resolveOIDCAvatar("", profile), + Email: email, + Password: "", + PasswordSalt: "", + UserType: enums.UserTypeEmployee, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: 0, + CreateUserName: enums.GetThirdProviderLabel(enums.ThirdProviderOIDC), + UpdatedAt: now, + UpdateUserID: 0, + UpdateUserName: enums.GetThirdProviderLabel(enums.ThirdProviderOIDC), + }, + } + if err := repositories.UserRepository.Create(ctx.Tx, user); err != nil { + return nil, nil, err + } + + identity := &models.UserIdentity{ + UserID: user.ID, + Provider: enums.ThirdProviderOIDC, + ProviderUserID: strings.TrimSpace(profile.Subject), + ProviderCorpID: "", + ProviderName: enums.GetThirdProviderLabel(enums.ThirdProviderOIDC), + RawProfile: profile.RawProfile, + Status: enums.StatusOk, + LastAuthAt: &now, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: user.ID, + CreateUserName: user.Username, + UpdatedAt: now, + UpdateUserID: user.ID, + UpdateUserName: user.Username, + }, + } + if err := repositories.UserIdentityRepository.Create(ctx.Tx, identity); err != nil { + return nil, nil, err + } + s.ensureDefaultOIDCRole(ctx.Tx, user) + return user, identity, nil +} + +func (s *oidcLoginService) availableEmail(tx *gorm.DB, email string) *string { + email = strings.TrimSpace(strings.ToLower(email)) + if email == "" || repositories.UserRepository.GetByEmail(tx, email) != nil { + return nil + } + return &email +} + +func (s *oidcLoginService) availableUsername(tx *gorm.DB, profile *oidcLoginProfile) string { + for _, candidate := range []string{ + profile.PreferredUsername, + strings.Split(strings.TrimSpace(profile.Email), "@")[0], + } { + username := normalizeOIDCUsername(candidate) + if username != "" && repositories.UserRepository.GetByUsername(tx, username) == nil { + return username + } + } + base := "oidc_" + shortSubjectHash(profile.Subject) + if repositories.UserRepository.GetByUsername(tx, base) == nil { + return base + } + for i := 1; i < 100; i++ { + username := base + "_" + strconv.Itoa(i) + if repositories.UserRepository.GetByUsername(tx, username) == nil { + return username + } + } + return base + "_" + shortSubjectHash(time.Now().String()) +} + +func (s *oidcLoginService) resolveOIDCNickname(current string, profile *oidcLoginProfile) string { + if profile != nil { + for _, candidate := range []string{profile.Name, profile.PreferredUsername, profile.Email, profile.Subject} { + if candidate = strings.TrimSpace(candidate); candidate != "" { + return candidate + } + } + } + return strings.TrimSpace(current) +} + +func (s *oidcLoginService) resolveOIDCAvatar(current string, profile *oidcLoginProfile) string { + if profile != nil { + if picture := strings.TrimSpace(profile.Picture); picture != "" { + return picture + } + } + return strings.TrimSpace(current) +} + +func normalizeOIDCUsername(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + var b strings.Builder + for _, r := range value { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' { + b.WriteRune(r) + } + } + ret := strings.Trim(b.String(), "._-") + if len(ret) > 100 { + ret = ret[:100] + } + return ret +} + +func shortSubjectHash(subject string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(subject))) + return hex.EncodeToString(sum[:])[:16] +} + +// ensureDefaultOIDCRole gives a first-time OIDC user the lowest staff role. +// The provider vouches for who the user is, not what they may administer, so +// a user arriving without any role must never escalate to an administrative +// one. +func (s *oidcLoginService) ensureDefaultOIDCRole(tx *gorm.DB, user *models.User) { + if user == nil || user.ID <= 0 { + return + } + existingRole := repositories.UserRoleRepository.FindOne(tx, sqls.NewCnd().Eq("user_id", user.ID)) + if existingRole != nil { + return + } + defaultRole := repositories.RoleRepository.GetByCode(tx, constants.RoleCodeCsUser) + if defaultRole == nil { + return + } + now := time.Now() + _ = repositories.UserRoleRepository.Create(tx, &models.UserRole{ + UserID: user.ID, + RoleID: defaultRole.ID, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: user.ID, + CreateUserName: user.Username, + UpdatedAt: now, + UpdateUserID: user.ID, + UpdateUserName: user.Username, + }, + }) +} + +func (s *oidcLoginService) syncOIDCUserOrganizations(tx *gorm.DB, user *models.User, profile *oidcLoginProfile) { + if user == nil || user.ID <= 0 { + return + } + now := time.Now() + var activeOrgID int64 = user.ActiveOrgID + + if len(profile.Organizations) > 0 { + for _, orgClaim := range profile.Organizations { + orgCode := strings.TrimSpace(orgClaim.ID) + if orgCode == "" { + continue + } + orgName := strings.TrimSpace(orgClaim.Name) + if orgName == "" { + orgName = orgCode + } + role := strings.ToUpper(strings.TrimSpace(orgClaim.Role)) + if role == "" { + role = "MEMBER" + } + + org := repositories.OrganizationRepository.GetByCode(tx, orgCode) + if org == nil { + org = &models.Organization{ + Code: orgCode, + Name: orgName, + Plan: "free", + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: user.ID, + CreateUserName: user.Username, + UpdatedAt: now, + UpdateUserID: user.ID, + UpdateUserName: user.Username, + }, + } + if err := repositories.OrganizationRepository.Create(tx, org); err != nil { + continue + } + } else if orgName != "" && org.Name != orgName { + _ = repositories.OrganizationRepository.UpdateColumn(tx, org.ID, "name", orgName) + } + + member := repositories.OrganizationMemberRepository.GetByOrgAndUser(tx, org.ID, user.ID) + if member == nil { + member = &models.OrganizationMember{ + OrganizationID: org.ID, + UserID: user.ID, + Role: role, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: user.ID, + CreateUserName: user.Username, + UpdatedAt: now, + UpdateUserID: user.ID, + UpdateUserName: user.Username, + }, + } + _ = repositories.OrganizationMemberRepository.Create(tx, member) + } else if member.Role != role || member.Status != enums.StatusOk { + _ = repositories.OrganizationMemberRepository.Updates(tx, member.ID, map[string]any{ + "role": role, + "status": enums.StatusOk, + "update_user_id": user.ID, + "update_user_name": user.Username, + "updated_at": now, + }) + } + + if activeOrgID == 0 { + activeOrgID = org.ID + } + } + } else { + existingMemberships := repositories.OrganizationMemberRepository.Find(tx, sqls.NewCnd().Eq("user_id", user.ID).Eq("status", enums.StatusOk)) + if len(existingMemberships) == 0 { + defaultOrgCode := "org_" + shortSubjectHash(profile.Subject) + defaultOrgName := s.resolveOIDCNickname(user.Nickname, profile) + "'s Workspace" + org := repositories.OrganizationRepository.GetByCode(tx, defaultOrgCode) + if org == nil { + org = &models.Organization{ + Code: defaultOrgCode, + Name: defaultOrgName, + Plan: "free", + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: user.ID, + CreateUserName: user.Username, + UpdatedAt: now, + UpdateUserID: user.ID, + UpdateUserName: user.Username, + }, + } + _ = repositories.OrganizationRepository.Create(tx, org) + } + if org.ID > 0 { + _ = repositories.OrganizationMemberRepository.Create(tx, &models.OrganizationMember{ + OrganizationID: org.ID, + UserID: user.ID, + Role: "OWNER", + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: user.ID, + CreateUserName: user.Username, + UpdatedAt: now, + UpdateUserID: user.ID, + UpdateUserName: user.Username, + }, + }) + activeOrgID = org.ID + } + } else if activeOrgID == 0 { + activeOrgID = existingMemberships[0].OrganizationID + } + } + + if activeOrgID > 0 && user.ActiveOrgID != activeOrgID { + user.ActiveOrgID = activeOrgID + _ = repositories.UserRepository.UpdateColumn(tx, user.ID, "active_org_id", activeOrgID) + } +} diff --git a/internal/services/oidc_login_service_test.go b/internal/services/oidc_login_service_test.go new file mode 100644 index 0000000..daa1909 --- /dev/null +++ b/internal/services/oidc_login_service_test.go @@ -0,0 +1,153 @@ +package services + +import ( + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/enums" +) + +func TestOIDCLoginAutoCreatesSystemUser(t *testing.T) { + db := setupAuthServiceTestDB(t) + svc := newOIDCLoginService() + + ret, err := svc.loginWithOIDCProfile(&oidcLoginProfile{ + Subject: "sub-123", + Email: "ada@example.com", + PreferredUsername: "ada", + Name: "Ada Lovelace", + Picture: "https://example.com/ada.png", + RawProfile: `{"sub":"sub-123"}`, + }, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test") + if err != nil { + t.Fatalf("loginWithOIDCProfile() error = %v", err) + } + if ret == nil || !strings.HasPrefix(ret.AccessToken, "ak_") { + t.Fatalf("expected ak_ access token, got %+v", ret) + } + + var user models.User + if err := db.Take(&user, "username = ?", "ada").Error; err != nil { + t.Fatalf("expected OIDC user to be created: %v", err) + } + if user.Nickname != "Ada Lovelace" || user.Avatar != "https://example.com/ada.png" { + t.Fatalf("unexpected created user profile: %+v", user) + } + if user.Email == nil || *user.Email != "ada@example.com" { + t.Fatalf("expected email to be stored, got %+v", user.Email) + } + if user.Password != "" { + t.Fatalf("expected OIDC-created user password to be empty, got %q", user.Password) + } + + var identity models.UserIdentity + if err := db.Take(&identity, "provider = ? AND provider_user_id = ?", enums.ThirdProviderOIDC, "sub-123").Error; err != nil { + t.Fatalf("expected OIDC identity to be created: %v", err) + } + if identity.UserID != user.ID || identity.ProviderName != "OIDC" || identity.Status != enums.StatusOk { + t.Fatalf("unexpected OIDC identity: %+v", identity) + } + + var sessions []models.LoginSession + if err := db.Find(&sessions).Error; err != nil { + t.Fatalf("query login sessions: %v", err) + } + if len(sessions) != 1 || sessions[0].UserID != user.ID || sessions[0].Token != ret.AccessToken { + t.Fatalf("unexpected login sessions: %+v", sessions) + } +} + +func TestOIDCLoginReusesExistingIdentity(t *testing.T) { + db := setupAuthServiceTestDB(t) + user := createAuthTestUser(t, db, "existing", "secret") + if err := db.Create(&models.UserIdentity{ + UserID: user.ID, + Provider: enums.ThirdProviderOIDC, + ProviderUserID: "sub-123", + ProviderName: "OIDC", + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("seed OIDC identity: %v", err) + } + + ret, err := newOIDCLoginService().loginWithOIDCProfile(&oidcLoginProfile{ + Subject: "sub-123", + PreferredUsername: "ignored", + Name: "Updated Name", + Picture: "https://example.com/updated.png", + RawProfile: `{"sub":"sub-123"}`, + }, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test") + if err != nil { + t.Fatalf("loginWithOIDCProfile() error = %v", err) + } + if ret == nil || ret.User == nil || ret.User.ID != user.ID { + t.Fatalf("expected existing user login response, got %+v", ret) + } + + var count int64 + if err := db.Model(&models.User{}).Count(&count).Error; err != nil { + t.Fatalf("count users: %v", err) + } + if count != 1 { + t.Fatalf("expected existing identity to reuse user, got %d users", count) + } +} + +// A first-time OIDC user is a stranger the provider vouched for, nothing +// more: they must land on the lowest staff role, never on an administrative +// one. Seeds every candidate role so the assignment cannot pass by accident +// of a missing row. +func TestOIDCLoginFirstUserGetsLowestStaffRole(t *testing.T) { + db := setupAuthServiceTestDB(t) + now := time.Now() + for _, role := range []struct{ name, code string }{ + {"Super Admin", constants.RoleCodeSuperAdmin}, + {"Admin", constants.RoleCodeAdmin}, + {"Support Agent", constants.RoleCodeCsUser}, + } { + if err := db.Create(&models.Role{ + Name: role.name, + Code: role.code, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + }).Error; err != nil { + t.Fatalf("seed role %s: %v", role.code, err) + } + } + + if _, err := newOIDCLoginService().loginWithOIDCProfile(&oidcLoginProfile{ + Subject: "sub-777", + Email: "stranger@example.com", + PreferredUsername: "stranger", + Name: "Stranger", + RawProfile: `{"sub":"sub-777"}`, + }, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test"); err != nil { + t.Fatalf("loginWithOIDCProfile() error = %v", err) + } + + var user models.User + if err := db.Take(&user, "username = ?", "stranger").Error; err != nil { + t.Fatalf("expected OIDC user to be created: %v", err) + } + + var roles []models.Role + if err := db. + Joins("JOIN t_user_role ON t_user_role.role_id = t_role.id"). + Where("t_user_role.user_id = ?", user.ID). + Find(&roles).Error; err != nil { + t.Fatalf("query user roles: %v", err) + } + if len(roles) != 1 { + t.Fatalf("expected exactly one role for a first-time OIDC user, got %d", len(roles)) + } + if roles[0].Code != constants.RoleCodeCsUser { + t.Fatalf("first-time OIDC user role = %q, want %q", roles[0].Code, constants.RoleCodeCsUser) + } +} diff --git a/internal/services/organization_service.go b/internal/services/organization_service.go new file mode 100644 index 0000000..9ead70e --- /dev/null +++ b/internal/services/organization_service.go @@ -0,0 +1,98 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +var OrganizationService = newOrganizationService() + +func newOrganizationService() *organizationService { + return &organizationService{} +} + +type organizationService struct{} + +func (s *organizationService) GetUserOrganizations(userID int64) (*response.UserOrganizationListResponse, error) { + user := repositories.UserRepository.Get(sqls.DB(), userID) + if user == nil { + return nil, errorsx.InvalidAccountI18n("error.e0260") + } + + memberships := repositories.OrganizationMemberRepository.Find(sqls.DB(), sqls.NewCnd().Eq("user_id", userID).Eq("status", enums.StatusOk)) + if len(memberships) == 0 { + return &response.UserOrganizationListResponse{ + CurrentOrganizationID: user.ActiveOrgID, + Organizations: []response.OrganizationResponse{}, + }, nil + } + + orgIDs := make([]int64, 0, len(memberships)) + roleMap := make(map[int64]string, len(memberships)) + for _, m := range memberships { + orgIDs = append(orgIDs, m.OrganizationID) + roleMap[m.OrganizationID] = m.Role + } + + orgs := repositories.OrganizationRepository.Find(sqls.DB(), sqls.NewCnd().In("id", orgIDs).Eq("status", enums.StatusOk)) + + resList := make([]response.OrganizationResponse, 0, len(orgs)) + for _, org := range orgs { + resList = append(resList, response.OrganizationResponse{ + ID: org.ID, + Code: org.Code, + Name: org.Name, + Logo: org.Logo, + Plan: org.Plan, + Status: org.Status, + Role: roleMap[org.ID], + IsActive: org.ID == user.ActiveOrgID, + CreatedAt: org.CreatedAt, + }) + } + + return &response.UserOrganizationListResponse{ + CurrentOrganizationID: user.ActiveOrgID, + Organizations: resList, + }, nil +} + +func (s *organizationService) SwitchActiveOrganization(userID int64, orgID int64) (*models.Organization, error) { + user := repositories.UserRepository.Get(sqls.DB(), userID) + if user == nil { + return nil, errorsx.InvalidAccountI18n("error.e0260") + } + + member := repositories.OrganizationMemberRepository.GetByOrgAndUser(sqls.DB(), orgID, userID) + if member == nil || member.Status != enums.StatusOk { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + + org := repositories.OrganizationRepository.Get(sqls.DB(), orgID) + if org == nil || org.Status != enums.StatusOk { + return nil, errorsx.InvalidParam("organization not found or disabled") + } + + if err := repositories.UserRepository.UpdateColumn(sqls.DB(), userID, "active_org_id", orgID); err != nil { + return nil, err + } + + return org, nil +} + +func (s *organizationService) GetActiveOrganization(principal *dto.AuthPrincipal) *models.Organization { + if principal == nil || principal.UserID <= 0 { + return nil + } + user := repositories.UserRepository.Get(sqls.DB(), principal.UserID) + if user == nil || user.ActiveOrgID <= 0 { + return nil + } + return repositories.OrganizationRepository.Get(sqls.DB(), user.ActiveOrgID) +} diff --git a/internal/services/permission_service.go b/internal/services/permission_service.go new file mode 100644 index 0000000..330c930 --- /dev/null +++ b/internal/services/permission_service.go @@ -0,0 +1,144 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "fmt" + "time" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var PermissionService = newPermissionService() + +func newPermissionService() *permissionService { + return &permissionService{} +} + +type permissionService struct { +} + +func (s *permissionService) Get(id int64) *models.Permission { + return repositories.PermissionRepository.Get(sqls.DB(), id) +} + +func (s *permissionService) Take(where ...interface{}) *models.Permission { + return repositories.PermissionRepository.Take(sqls.DB(), where...) +} + +func (s *permissionService) Find(cnd *sqls.Cnd) []models.Permission { + return repositories.PermissionRepository.Find(sqls.DB(), cnd) +} + +func (s *permissionService) FindOne(cnd *sqls.Cnd) *models.Permission { + return repositories.PermissionRepository.FindOne(sqls.DB(), cnd) +} + +func (s *permissionService) FindPageByParams(params *params.QueryParams) (list []models.Permission, paging *sqls.Paging) { + return repositories.PermissionRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *permissionService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Permission, paging *sqls.Paging) { + return repositories.PermissionRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *permissionService) Count(cnd *sqls.Cnd) int64 { + return repositories.PermissionRepository.Count(sqls.DB(), cnd) +} + +func (s *permissionService) Create(t *models.Permission) error { + return repositories.PermissionRepository.Create(sqls.DB(), t) +} + +func (s *permissionService) Update(t *models.Permission) error { + return repositories.PermissionRepository.Update(sqls.DB(), t) +} + +func (s *permissionService) Updates(id int64, columns map[string]interface{}) error { + return repositories.PermissionRepository.Updates(sqls.DB(), id, columns) +} + +func (s *permissionService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.PermissionRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *permissionService) Delete(id int64) { + repositories.PermissionRepository.Delete(sqls.DB(), id) +} + +func (s *permissionService) SyncBuiltinPermissions() (*response.PermissionSyncResponse, error) { + result := &response.PermissionSyncResponse{} + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + permissions := make(map[string]*models.Permission, len(constants.Permissions)) + now := time.Now() + + for _, spec := range constants.Permissions { + permission := repositories.PermissionRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("code", spec.Code)) + if permission == nil { + permission = &models.Permission{ + Name: spec.Name, Code: spec.Code, Type: spec.Type, GroupName: spec.GroupName, + Method: spec.Method, APIPath: spec.APIPath, SortNo: spec.SortNo, + Status: enums.StatusOk, IsBuiltin: true, + AuditFields: systemPermissionAuditFields(now), + } + if err := repositories.PermissionRepository.Create(ctx.Tx, permission); err != nil { + return err + } + result.Created++ + } else { + if err := repositories.PermissionRepository.Updates(ctx.Tx, permission.ID, map[string]any{ + "name": spec.Name, "type": spec.Type, "group_name": spec.GroupName, + "method": spec.Method, "api_path": spec.APIPath, "sort_no": spec.SortNo, + "status": enums.StatusOk, "is_builtin": true, + "update_user_id": constants.SystemAuditUserID, + "update_user_name": constants.SystemAuditUserName, "updated_at": now, + }); err != nil { + return err + } + permission = repositories.PermissionRepository.Get(ctx.Tx, permission.ID) + result.Updated++ + } + permissions[spec.Code] = permission + } + + for roleCode, specs := range constants.RolePermissions { + role := repositories.RoleRepository.GetByCode(ctx.Tx, roleCode) + if role == nil { + return fmt.Errorf("builtin role not found: %s", roleCode) + } + for _, spec := range specs { + permission := permissions[spec.Code] + if permission == nil { + return fmt.Errorf("builtin permission not found: %s", spec.Code) + } + if repositories.RolePermissionRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("role_id", role.ID).Eq("permission_id", permission.ID)) != nil { + continue + } + if err := repositories.RolePermissionRepository.Create(ctx.Tx, &models.RolePermission{ + RoleID: role.ID, PermissionID: permission.ID, + AuditFields: systemPermissionAuditFields(now), + }); err != nil { + return err + } + result.RolePermissionsAdded++ + } + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func systemPermissionAuditFields(now time.Time) models.AuditFields { + return models.AuditFields{ + CreatedAt: now, CreateUserID: constants.SystemAuditUserID, CreateUserName: constants.SystemAuditUserName, + UpdatedAt: now, UpdateUserID: constants.SystemAuditUserID, UpdateUserName: constants.SystemAuditUserName, + } +} diff --git a/internal/services/permission_service_test.go b/internal/services/permission_service_test.go new file mode 100644 index 0000000..7752332 --- /dev/null +++ b/internal/services/permission_service_test.go @@ -0,0 +1,100 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/enums" + "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestPermissionServiceSyncBuiltinPermissions(t *testing.T) { + db := setupPermissionServiceTestDB(t) + now := time.Now() + for _, spec := range constants.Roles { + if err := db.Create(&models.Role{ + Name: spec.Name, Code: spec.Code, Status: enums.StatusOk, IsSystem: true, SortNo: spec.SortNo, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + }).Error; err != nil { + t.Fatalf("create role %s: %v", spec.Code, err) + } + } + customPermission := &models.Permission{ + Name: "Custom permission", Code: "custom.keep", Type: "api", GroupName: "custom", + Status: enums.StatusOk, AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(customPermission).Error; err != nil { + t.Fatalf("create custom permission: %v", err) + } + superAdmin := &models.Role{} + if err := db.First(superAdmin, "code = ?", constants.RoleCodeSuperAdmin).Error; err != nil { + t.Fatalf("find super admin role: %v", err) + } + if err := db.Create(&models.RolePermission{ + RoleID: superAdmin.ID, PermissionID: customPermission.ID, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + }).Error; err != nil { + t.Fatalf("create custom role permission: %v", err) + } + + first, err := PermissionService.SyncBuiltinPermissions() + if err != nil { + t.Fatalf("first sync: %v", err) + } + if first.Created != len(constants.Permissions) || first.Updated != 0 { + t.Fatalf("unexpected first sync result: %+v", first) + } + + wantRolePermissions := 0 + for _, permissions := range constants.RolePermissions { + wantRolePermissions += len(permissions) + } + if first.RolePermissionsAdded != wantRolePermissions { + t.Fatalf("role permissions added=%d want=%d", first.RolePermissionsAdded, wantRolePermissions) + } + + second, err := PermissionService.SyncBuiltinPermissions() + if err != nil { + t.Fatalf("second sync: %v", err) + } + if second.Created != 0 || second.Updated != len(constants.Permissions) || second.RolePermissionsAdded != 0 { + t.Fatalf("sync is not idempotent: %+v", second) + } + + var permissionCount int64 + if err := db.Model(&models.Permission{}).Count(&permissionCount).Error; err != nil { + t.Fatalf("count permissions: %v", err) + } + if permissionCount != int64(len(constants.Permissions)+1) { + t.Fatalf("permission count=%d want=%d", permissionCount, len(constants.Permissions)+1) + } + var customRolePermissionCount int64 + if err := db.Model(&models.RolePermission{}). + Where("role_id = ? AND permission_id = ?", superAdmin.ID, customPermission.ID). + Count(&customRolePermissionCount).Error; err != nil { + t.Fatalf("count custom role permission: %v", err) + } + if customRolePermissionCount != 1 { + t.Fatalf("custom role permission was removed") + } +} + +func setupPermissionServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, + }) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate(&models.Role{}, &models.Permission{}, &models.RolePermission{}); err != nil { + t.Fatalf("migrate permission tables: %v", err) + } + sqls.SetDB(db) + return db +} diff --git a/internal/services/quick_reply_service.go b/internal/services/quick_reply_service.go new file mode 100644 index 0000000..460056d --- /dev/null +++ b/internal/services/quick_reply_service.go @@ -0,0 +1,125 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + "strings" + "time" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var QuickReplyService = newQuickReplyService() + +func newQuickReplyService() *quickReplyService { + return &quickReplyService{} +} + +type quickReplyService struct { +} + +func (s *quickReplyService) Get(id int64) *models.QuickReply { + return repositories.QuickReplyRepository.Get(sqls.DB(), id) +} + +func (s *quickReplyService) Take(where ...interface{}) *models.QuickReply { + return repositories.QuickReplyRepository.Take(sqls.DB(), where...) +} + +func (s *quickReplyService) Find(cnd *sqls.Cnd) []models.QuickReply { + return repositories.QuickReplyRepository.Find(sqls.DB(), cnd) +} + +func (s *quickReplyService) FindOne(cnd *sqls.Cnd) *models.QuickReply { + return repositories.QuickReplyRepository.FindOne(sqls.DB(), cnd) +} + +func (s *quickReplyService) FindPageByParams(params *params.QueryParams) (list []models.QuickReply, paging *sqls.Paging) { + return repositories.QuickReplyRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *quickReplyService) FindPageByCnd(cnd *sqls.Cnd) (list []models.QuickReply, paging *sqls.Paging) { + return repositories.QuickReplyRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *quickReplyService) Count(cnd *sqls.Cnd) int64 { + return repositories.QuickReplyRepository.Count(sqls.DB(), cnd) +} + +func (s *quickReplyService) Create(t *models.QuickReply) error { + return repositories.QuickReplyRepository.Create(sqls.DB(), t) +} + +func (s *quickReplyService) Update(t *models.QuickReply) error { + return repositories.QuickReplyRepository.Update(sqls.DB(), t) +} + +func (s *quickReplyService) Updates(id int64, columns map[string]interface{}) error { + return repositories.QuickReplyRepository.Updates(sqls.DB(), id, columns) +} + +func (s *quickReplyService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.QuickReplyRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *quickReplyService) Delete(id int64) { + repositories.QuickReplyRepository.Delete(sqls.DB(), id) +} + +func (s *quickReplyService) CreateQuickReply(req request.CreateQuickReplyRequest, operator *dto.AuthPrincipal) (*models.QuickReply, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + title := strings.TrimSpace(req.Title) + content := strings.TrimSpace(req.Content) + if title == "" || content == "" { + return nil, errorsx.InvalidParamI18n("error.e0240") + } + item := &models.QuickReply{ + GroupName: strings.TrimSpace(req.GroupName), + Title: title, + Content: content, + Status: req.Status, + SortNo: req.SortNo, + AuditFields: utils.BuildAuditFields(operator), + } + if err := s.Create(item); err != nil { + return nil, err + } + return item, nil +} + +func (s *quickReplyService) UpdateQuickReply(req request.UpdateQuickReplyRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + item := s.Get(req.ID) + if item == nil { + return errorsx.InvalidParamI18n("error.e0203") + } + return s.Updates(req.ID, map[string]any{ + "group_name": strings.TrimSpace(req.GroupName), + "title": strings.TrimSpace(req.Title), + "content": strings.TrimSpace(req.Content), + "status": req.Status, + "sort_no": req.SortNo, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) +} + +func (s *quickReplyService) DeleteQuickReply(id int64) error { + item := s.Get(id) + if item == nil { + return errorsx.InvalidParamI18n("error.e0203") + } + s.Delete(id) + return nil +} diff --git a/internal/services/role_permission_service.go b/internal/services/role_permission_service.go new file mode 100644 index 0000000..9dc4e86 --- /dev/null +++ b/internal/services/role_permission_service.go @@ -0,0 +1,67 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var RolePermissionService = newRolePermissionService() + +func newRolePermissionService() *rolePermissionService { + return &rolePermissionService{} +} + +type rolePermissionService struct { +} + +func (s *rolePermissionService) Get(id int64) *models.RolePermission { + return repositories.RolePermissionRepository.Get(sqls.DB(), id) +} + +func (s *rolePermissionService) Take(where ...interface{}) *models.RolePermission { + return repositories.RolePermissionRepository.Take(sqls.DB(), where...) +} + +func (s *rolePermissionService) Find(cnd *sqls.Cnd) []models.RolePermission { + return repositories.RolePermissionRepository.Find(sqls.DB(), cnd) +} + +func (s *rolePermissionService) FindOne(cnd *sqls.Cnd) *models.RolePermission { + return repositories.RolePermissionRepository.FindOne(sqls.DB(), cnd) +} + +func (s *rolePermissionService) FindPageByParams(params *params.QueryParams) (list []models.RolePermission, paging *sqls.Paging) { + return repositories.RolePermissionRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *rolePermissionService) FindPageByCnd(cnd *sqls.Cnd) (list []models.RolePermission, paging *sqls.Paging) { + return repositories.RolePermissionRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *rolePermissionService) Count(cnd *sqls.Cnd) int64 { + return repositories.RolePermissionRepository.Count(sqls.DB(), cnd) +} + +func (s *rolePermissionService) Create(t *models.RolePermission) error { + return repositories.RolePermissionRepository.Create(sqls.DB(), t) +} + +func (s *rolePermissionService) Update(t *models.RolePermission) error { + return repositories.RolePermissionRepository.Update(sqls.DB(), t) +} + +func (s *rolePermissionService) Updates(id int64, columns map[string]interface{}) error { + return repositories.RolePermissionRepository.Updates(sqls.DB(), id, columns) +} + +func (s *rolePermissionService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.RolePermissionRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *rolePermissionService) Delete(id int64) { + repositories.RolePermissionRepository.Delete(sqls.DB(), id) +} diff --git a/internal/services/role_service.go b/internal/services/role_service.go new file mode 100644 index 0000000..6003f44 --- /dev/null +++ b/internal/services/role_service.go @@ -0,0 +1,204 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + "slices" + "strings" + "time" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var RoleService = newRoleService() + +func newRoleService() *roleService { + return &roleService{} +} + +type roleService struct { +} + +func (s *roleService) Get(id int64) *models.Role { + return repositories.RoleRepository.Get(sqls.DB(), id) +} + +func (s *roleService) Take(where ...interface{}) *models.Role { + return repositories.RoleRepository.Take(sqls.DB(), where...) +} + +func (s *roleService) Find(cnd *sqls.Cnd) []models.Role { + return repositories.RoleRepository.Find(sqls.DB(), cnd) +} + +func (s *roleService) FindOne(cnd *sqls.Cnd) *models.Role { + return repositories.RoleRepository.FindOne(sqls.DB(), cnd) +} + +func (s *roleService) FindPageByParams(params *params.QueryParams) (list []models.Role, paging *sqls.Paging) { + return repositories.RoleRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *roleService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Role, paging *sqls.Paging) { + return repositories.RoleRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *roleService) Count(cnd *sqls.Cnd) int64 { + return repositories.RoleRepository.Count(sqls.DB(), cnd) +} + +func (s *roleService) Create(t *models.Role) error { + return repositories.RoleRepository.Create(sqls.DB(), t) +} + +func (s *roleService) Update(t *models.Role) error { + return repositories.RoleRepository.Update(sqls.DB(), t) +} + +func (s *roleService) Updates(id int64, columns map[string]interface{}) error { + return repositories.RoleRepository.Updates(sqls.DB(), id, columns) +} + +func (s *roleService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.RoleRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *roleService) Delete(id int64) { + repositories.RoleRepository.Delete(sqls.DB(), id) +} + +func (s *roleService) CreateRole(req request.CreateRoleRequest, operator *dto.AuthPrincipal) (*models.Role, error) { + name := strings.TrimSpace(req.Name) + code := strings.TrimSpace(req.Code) + if name == "" || code == "" { + return nil, errorsx.InvalidParamI18n("error.e0306") + } + if s.Take("code = ?", code) != nil { + return nil, errorsx.InvalidParamI18n("error.e0308") + } + + role := &models.Role{ + Name: name, + Code: code, + Status: enums.StatusOk, + IsSystem: false, + SortNo: s.NextSortNo(), + Remark: strings.TrimSpace(req.Remark), + AuditFields: utils.BuildAuditFields(operator), + } + if err := s.Create(role); err != nil { + return nil, err + } + return role, nil +} + +func (s *roleService) UpdateRole(req request.UpdateRoleRequest, operator *dto.AuthPrincipal) error { + role := s.Get(req.ID) + if role == nil { + return errorsx.InvalidParamI18n("error.e0305") + } + now := time.Now() + return s.Updates(req.ID, map[string]any{ + "name": strings.TrimSpace(req.Name), + "sort_no": req.SortNo, + "remark": strings.TrimSpace(req.Remark), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }) +} + +func (s *roleService) NextSortNo() int { + if latest := s.FindOne(sqls.NewCnd().Desc("sort_no").Desc("id")); latest != nil { + return latest.SortNo + 1 + } + return 0 +} + +func (s *roleService) UpdateSort(ids []int64) error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + for i, id := range ids { + if err := repositories.RoleRepository.UpdateColumn(ctx.Tx, id, "sort_no", i); err != nil { + return err + } + } + return nil + }) +} + +func (s *roleService) DeleteRole(id int64) error { + role := s.Get(id) + if role == nil { + return errorsx.InvalidParamI18n("error.e0305") + } + if role.IsSystem { + return errorsx.ForbiddenI18n("error.e0293") + } + if UserRoleService.Take("role_id = ?", id) != nil { + return errorsx.ForbiddenI18n("error.e0307") + } + s.Delete(id) + return nil +} + +func (s *roleService) UpdateStatus(id int64, status enums.Status, operator *dto.AuthPrincipal) error { + role := s.Get(id) + if role == nil { + return errorsx.InvalidParamI18n("error.e0305") + } + if !slices.Contains(enums.StatusValues, status) { + return errorsx.InvalidParamI18n("error.e0254") + } + if err := s.Updates(id, map[string]any{ + "status": status, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }); err != nil { + return err + } + return nil +} + +func (s *roleService) AssignPermissions(roleID int64, permissionIDs []int64, operator *dto.AuthPrincipal) error { + role := s.Get(roleID) + if role == nil { + return errorsx.InvalidParamI18n("error.e0305") + } + if role.IsSystem && (operator == nil || !slices.Contains(operator.Roles, string(constants.RoleCodeSuperAdmin))) { + return errorsx.ForbiddenI18n("error.e0293") + } + + return s.replaceRolePermissions(roleID, permissionIDs, operator) +} + +func (s *roleService) replaceRolePermissions(roleID int64, permissionIDs []int64, operator *dto.AuthPrincipal) error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := ctx.Tx.Where("role_id = ?", roleID).Delete(&models.RolePermission{}).Error; err != nil { + return err + } + for _, permissionID := range permissionIDs { + permission := PermissionService.Get(permissionID) + if permission == nil { + return errorsx.InvalidParamI18n("error.e0236") + } + relation := &models.RolePermission{ + RoleID: roleID, + PermissionID: permissionID, + AuditFields: utils.BuildAuditFields(operator), + } + if err := ctx.Tx.Create(relation).Error; err != nil { + return err + } + } + return nil + }) +} diff --git a/internal/services/setup_readiness_service.go b/internal/services/setup_readiness_service.go new file mode 100644 index 0000000..d5abc96 --- /dev/null +++ b/internal/services/setup_readiness_service.go @@ -0,0 +1,1630 @@ +package services + +import ( + "fmt" + "math" + "slices" + "strconv" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + + "github.com/mlogclub/simple/sqls" +) + +var SetupReadinessService = newSetupReadinessService() + +func newSetupReadinessService() *setupReadinessService { + return &setupReadinessService{} +} + +type setupReadinessService struct{} + +const ( + QuickFixActionQuickSetupAll = "quick_setup_all" + QuickFixActionEnsureTeamSchedule = "ensure_team_and_schedule" + QuickFixActionEnableMyProfile = "enable_my_operator_profile" + QuickFixActionFixAIAgentHandoff = "fix_ai_agent_handoff" + QuickFixActionEnsureDefaultChannel = "ensure_default_channel" +) + +func (s *setupReadinessService) GetReadiness(operator *dto.AuthPrincipal) (*response.SetupReadinessResponse, error) { + now := time.Now() + db := sqls.DB() + + var teams []models.AgentTeam + if err := db.Order("id ASC").Find(&teams).Error; err != nil { + return nil, err + } + activeTeamMap := make(map[int64]models.AgentTeam) + activeTeamIDs := make([]int64, 0, len(teams)) + for _, team := range teams { + if team.Status == enums.StatusOk { + activeTeamMap[team.ID] = team + activeTeamIDs = append(activeTeamIDs, team.ID) + } + } + + var schedules []models.AgentTeamSchedule + if err := db.Order("id ASC").Find(&schedules).Error; err != nil { + return nil, err + } + activeScheduleNowTeamSet := make(map[int64]bool) + anyScheduleTeamSet := make(map[int64]bool) + for _, sch := range schedules { + if sch.Status != enums.StatusOk { + continue + } + anyScheduleTeamSet[sch.TeamID] = true + if !sch.StartAt.After(now) && sch.EndAt.After(now) { + activeScheduleNowTeamSet[sch.TeamID] = true + } + } + + var profiles []models.AgentProfile + if err := db.Order("id ASC").Find(&profiles).Error; err != nil { + return nil, err + } + + var users []models.User + if err := db.Find(&users).Error; err != nil { + return nil, err + } + activeUserMap := make(map[int64]models.User, len(users)) + for _, u := range users { + if u.Status == enums.StatusOk && u.DeletedAt == nil { + activeUserMap[u.ID] = u + } + } + + activeCounts, err := ConversationDispatchService.findActiveConversationCountMap(extractProfileUserIDs(profiles)) + if err != nil { + return nil, err + } + + var aiAgents []models.AIAgent + if err := db.Order("id ASC").Find(&aiAgents).Error; err != nil { + return nil, err + } + + var channels []models.Channel + if err := db.Order("id ASC").Find(&channels).Error; err != nil { + return nil, err + } + + // Step 1: Operator Teams + step1 := s.evaluateTeamsStep(teams, activeTeamIDs) + + // Step 2: Shift Schedules (Active Now) + step2 := s.evaluateSchedulesStep(teams, activeTeamMap, activeScheduleNowTeamSet, anyScheduleTeamSet) + + // Step 3: Operator Profiles + step3, readyOperatorTeams := s.evaluateOperatorsStep(profiles, activeTeamMap, activeScheduleNowTeamSet, activeUserMap, activeCounts) + + // Step 4: AI Agents & Handoff Configuration + step4, readyAIAgentMap := s.evaluateAIAgentsStep(aiAgents, activeTeamMap, activeScheduleNowTeamSet, readyOperatorTeams) + + // Step 5: Inbound Channels + step5, channelBriefs := s.evaluateChannelsStep(channels, aiAgents) + + // Step 6: End-to-End Routing Path + step6, canReceiveRequestsNow := s.evaluateEndToEndStep(channels, aiAgents, readyAIAgentMap, activeTeamMap, activeScheduleNowTeamSet, readyOperatorTeams) + + steps := []response.SetupStepItem{step1, step2, step3, step4, step5, step6} + for i := range steps { + steps[i] = s.filterStepQuickFixes(steps[i], operator) + } + + readySteps := 0 + hasError := false + hasWarning := false + for _, st := range steps { + switch st.Status { + case "ok": + readySteps++ + case "error": + hasError = true + case "warning": + hasWarning = true + } + } + + overallStatus := "ok" + if hasError || !canReceiveRequestsNow { + if hasError { + overallStatus = "error" + } else { + overallStatus = "warning" + } + } else if hasWarning { + overallStatus = "warning" + } + + currentOp := s.evaluateCurrentOperator(operator, profiles, activeTeamMap, activeScheduleNowTeamSet, activeCounts) + + return &response.SetupReadinessResponse{ + OverallStatus: overallStatus, + ReadySteps: readySteps, + TotalSteps: len(steps), + CanReceiveRequestsNow: canReceiveRequestsNow, + Steps: steps, + CurrentOperator: currentOp, + Channels: channelBriefs, + }, nil +} + +func (s *setupReadinessService) evaluateTeamsStep(teams []models.AgentTeam, activeTeamIDs []int64) response.SetupStepItem { + issues := make([]response.SetupIssueItem, 0) + quickFixes := make([]string, 0) + status := "ok" + summaryCode := "ok" + + if len(activeTeamIDs) == 0 { + status = "error" + summaryCode = "no_active_team" + issues = append(issues, response.SetupIssueItem{ + Code: "no_active_team", + Severity: "error", + EntityType: "team", + QuickFixAction: QuickFixActionEnsureTeamSchedule, + }) + quickFixes = append(quickFixes, QuickFixActionEnsureTeamSchedule) + } + + return response.SetupStepItem{ + Key: "teams", + StepNumber: 1, + Status: status, + SummaryCode: summaryCode, + TotalCount: len(teams), + ReadyCount: len(activeTeamIDs), + Issues: issues, + QuickFixActions: quickFixes, + ManagePath: "/dashboard/agents", + } +} + +func (s *setupReadinessService) evaluateSchedulesStep( + teams []models.AgentTeam, + activeTeamMap map[int64]models.AgentTeam, + activeScheduleNowTeamSet map[int64]bool, + anyScheduleTeamSet map[int64]bool, +) response.SetupStepItem { + issues := make([]response.SetupIssueItem, 0) + quickFixes := make([]string, 0) + readyTeamCount := 0 + + for _, team := range teams { + if team.Status != enums.StatusOk { + continue + } + if activeScheduleNowTeamSet[team.ID] { + readyTeamCount++ + continue + } + issueCode := "team_no_schedule_configured" + if anyScheduleTeamSet[team.ID] { + issueCode = "team_off_hours_now" + } + issues = append(issues, response.SetupIssueItem{ + Code: issueCode, + Severity: "warning", + EntityType: "team", + EntityID: team.ID, + EntityName: team.Name, + QuickFixAction: QuickFixActionEnsureTeamSchedule, + }) + } + + status := "ok" + summaryCode := "ok" + if len(activeTeamMap) == 0 { + status = "error" + summaryCode = "no_active_team" + quickFixes = append(quickFixes, QuickFixActionEnsureTeamSchedule) + } else if readyTeamCount == 0 { + status = "error" + summaryCode = "no_schedule_now" + quickFixes = append(quickFixes, QuickFixActionEnsureTeamSchedule) + } else if len(issues) > 0 { + status = "warning" + summaryCode = "partial_schedule_now" + quickFixes = append(quickFixes, QuickFixActionEnsureTeamSchedule) + } + + return response.SetupStepItem{ + Key: "schedules", + StepNumber: 2, + Status: status, + SummaryCode: summaryCode, + TotalCount: len(activeTeamMap), + ReadyCount: readyTeamCount, + Issues: issues, + QuickFixActions: quickFixes, + ManagePath: "/dashboard/agent-team-schedules", + } +} + +func (s *setupReadinessService) evaluateOperatorsStep( + profiles []models.AgentProfile, + activeTeamMap map[int64]models.AgentTeam, + activeScheduleNowTeamSet map[int64]bool, + activeUserMap map[int64]models.User, + activeCounts map[int64]int, +) (response.SetupStepItem, map[int64]bool) { + issues := make([]response.SetupIssueItem, 0) + quickFixes := make([]string, 0) + readyCount := 0 + readyOperatorTeams := make(map[int64]bool) + + for _, p := range profiles { + displayName := strings.TrimSpace(p.DisplayName) + if displayName == "" { + if u, ok := activeUserMap[p.UserID]; ok { + displayName = firstNonEmpty(u.Nickname, u.Username) + } + } + if displayName == "" { + displayName = fmt.Sprintf("#%d", p.UserID) + } + + if p.Status != enums.StatusOk { + issues = append(issues, response.SetupIssueItem{ + Code: "operator_profile_disabled", + Severity: "warning", + EntityType: "operator", + EntityID: p.ID, + EntityName: displayName, + QuickFixAction: QuickFixActionEnableMyProfile, + }) + continue + } + if _, userOk := activeUserMap[p.UserID]; !userOk { + issues = append(issues, response.SetupIssueItem{ + Code: "operator_user_disabled", + Severity: "warning", + EntityType: "operator", + EntityID: p.ID, + EntityName: displayName, + }) + continue + } + if _, teamOk := activeTeamMap[p.TeamID]; !teamOk { + issues = append(issues, response.SetupIssueItem{ + Code: "operator_no_active_team", + Severity: "error", + EntityType: "operator", + EntityID: p.ID, + EntityName: displayName, + QuickFixAction: QuickFixActionEnableMyProfile, + }) + continue + } + if !p.AutoAssignEnabled { + issues = append(issues, response.SetupIssueItem{ + Code: "operator_auto_assign_off", + Severity: "warning", + EntityType: "operator", + EntityID: p.ID, + EntityName: displayName, + QuickFixAction: QuickFixActionEnableMyProfile, + }) + continue + } + if p.ServiceStatus != enums.ServiceStatusIdle { + issues = append(issues, response.SetupIssueItem{ + Code: "operator_busy", + Severity: "warning", + EntityType: "operator", + EntityID: p.ID, + EntityName: displayName, + QuickFixAction: QuickFixActionEnableMyProfile, + }) + continue + } + activeCount := activeCounts[p.UserID] + if p.MaxConcurrentCount <= 0 || activeCount >= p.MaxConcurrentCount { + issues = append(issues, response.SetupIssueItem{ + Code: "operator_at_capacity", + Severity: "warning", + EntityType: "operator", + EntityID: p.ID, + EntityName: displayName, + QuickFixAction: QuickFixActionEnableMyProfile, + }) + continue + } + + readyCount++ + if activeScheduleNowTeamSet[p.TeamID] { + readyOperatorTeams[p.TeamID] = true + } + } + + status := "ok" + summaryCode := "ok" + if len(profiles) == 0 { + status = "error" + summaryCode = "no_operator_profile" + issues = append(issues, response.SetupIssueItem{ + Code: "no_operator_profile", + Severity: "error", + EntityType: "operator", + QuickFixAction: QuickFixActionEnableMyProfile, + }) + quickFixes = append(quickFixes, QuickFixActionEnableMyProfile) + } else if readyCount == 0 { + status = "error" + summaryCode = "no_idle_operator" + quickFixes = append(quickFixes, QuickFixActionEnableMyProfile) + } else if len(issues) > 0 { + status = "warning" + summaryCode = "some_operators_unavailable" + } + + return response.SetupStepItem{ + Key: "operators", + StepNumber: 3, + Status: status, + SummaryCode: summaryCode, + TotalCount: len(profiles), + ReadyCount: readyCount, + Issues: issues, + QuickFixActions: quickFixes, + ManagePath: "/dashboard/agents", + }, readyOperatorTeams +} + +func (s *setupReadinessService) evaluateAIAgentsStep( + aiAgents []models.AIAgent, + activeTeamMap map[int64]models.AgentTeam, + activeScheduleNowTeamSet map[int64]bool, + readyOperatorTeams map[int64]bool, +) (response.SetupStepItem, map[int64]bool) { + issues := make([]response.SetupIssueItem, 0) + quickFixes := make([]string, 0) + readyCount := 0 + readyAIAgentMap := make(map[int64]bool) + + activeAgentCount := 0 + for _, agent := range aiAgents { + if agent.Status != enums.StatusOk { + continue + } + activeAgentCount++ + if agent.ServiceMode == enums.IMConversationServiceModeAIOnly { + issues = append(issues, response.SetupIssueItem{ + Code: "agent_ai_only_mode", + Severity: "warning", + EntityType: "ai_agent", + EntityID: agent.ID, + EntityName: agent.Name, + QuickFixAction: QuickFixActionFixAIAgentHandoff, + }) + continue + } + teamIDs := utils.SplitInt64s(agent.TeamIDs) + validTeamIDs := make([]int64, 0, len(teamIDs)) + for _, tid := range teamIDs { + if _, ok := activeTeamMap[tid]; ok { + validTeamIDs = append(validTeamIDs, tid) + } + } + if len(validTeamIDs) == 0 { + issues = append(issues, response.SetupIssueItem{ + Code: "agent_no_teams", + Severity: "error", + EntityType: "ai_agent", + EntityID: agent.ID, + EntityName: agent.Name, + QuickFixAction: QuickFixActionFixAIAgentHandoff, + }) + continue + } + if agent.ServiceMode == enums.IMConversationServiceModeAIFirst && !agent.AutoHandoffOnFailure { + issues = append(issues, response.SetupIssueItem{ + Code: "agent_auto_handoff_disabled", + Severity: "warning", + EntityType: "ai_agent", + EntityID: agent.ID, + EntityName: agent.Name, + QuickFixAction: QuickFixActionFixAIAgentHandoff, + }) + } + + readyCount++ + for _, tid := range validTeamIDs { + if activeScheduleNowTeamSet[tid] && readyOperatorTeams[tid] { + readyAIAgentMap[agent.ID] = true + break + } + } + } + + status := "ok" + summaryCode := "ok" + if activeAgentCount == 0 { + status = "error" + summaryCode = "no_active_ai_agent" + issues = append(issues, response.SetupIssueItem{ + Code: "no_active_ai_agent", + Severity: "error", + EntityType: "ai_agent", + QuickFixAction: QuickFixActionFixAIAgentHandoff, + }) + quickFixes = append(quickFixes, QuickFixActionFixAIAgentHandoff) + } else if readyCount == 0 { + status = "error" + summaryCode = "no_handoff_ready_agent" + quickFixes = append(quickFixes, QuickFixActionFixAIAgentHandoff) + } else if len(issues) > 0 { + status = "warning" + summaryCode = "agent_has_warnings" + quickFixes = append(quickFixes, QuickFixActionFixAIAgentHandoff) + } + + return response.SetupStepItem{ + Key: "ai_agents", + StepNumber: 4, + Status: status, + SummaryCode: summaryCode, + TotalCount: len(aiAgents), + ReadyCount: readyCount, + Issues: issues, + QuickFixActions: quickFixes, + ManagePath: "/dashboard/ai-agents", + }, readyAIAgentMap +} + +func (s *setupReadinessService) evaluateChannelsStep( + channels []models.Channel, + aiAgents []models.AIAgent, +) (response.SetupStepItem, []response.SetupChannelBrief) { + issues := make([]response.SetupIssueItem, 0) + quickFixes := make([]string, 0) + readyCount := 0 + + agentMap := make(map[int64]models.AIAgent, len(aiAgents)) + for _, a := range aiAgents { + if a.Status == enums.StatusOk { + agentMap[a.ID] = a + } + } + + briefs := make([]response.SetupChannelBrief, 0, len(channels)) + activeChannelCount := 0 + for _, ch := range channels { + briefs = append(briefs, response.SetupChannelBrief{ + ID: ch.ID, + Name: ch.Name, + ChannelType: ch.ChannelType, + ChannelID: ch.ChannelID, + AIAgentID: ch.AIAgentID, + RolloutPercent: ch.AIAgentRolloutPercent, + Status: int(ch.Status), + }) + if ch.Status != enums.StatusOk { + continue + } + activeChannelCount++ + if ch.AIAgentID <= 0 { + issues = append(issues, response.SetupIssueItem{ + Code: "channel_no_agent", + Severity: "error", + EntityType: "channel", + EntityID: ch.ID, + EntityName: ch.Name, + QuickFixAction: QuickFixActionEnsureDefaultChannel, + }) + continue + } + if _, ok := agentMap[ch.AIAgentID]; !ok { + issues = append(issues, response.SetupIssueItem{ + Code: "channel_agent_disabled", + Severity: "error", + EntityType: "channel", + EntityID: ch.ID, + EntityName: ch.Name, + QuickFixAction: QuickFixActionEnsureDefaultChannel, + }) + continue + } + if ch.AIAgentRolloutPercent <= 0 { + issues = append(issues, response.SetupIssueItem{ + Code: "channel_zero_rollout", + Severity: "error", + EntityType: "channel", + EntityID: ch.ID, + EntityName: ch.Name, + QuickFixAction: QuickFixActionEnsureDefaultChannel, + }) + continue + } + readyCount++ + } + + status := "ok" + summaryCode := "ok" + if activeChannelCount == 0 { + status = "error" + summaryCode = "no_active_channel" + issues = append(issues, response.SetupIssueItem{ + Code: "no_active_channel", + Severity: "error", + EntityType: "channel", + QuickFixAction: QuickFixActionEnsureDefaultChannel, + }) + quickFixes = append(quickFixes, QuickFixActionEnsureDefaultChannel) + } else if readyCount == 0 { + status = "error" + summaryCode = "no_ready_channel" + quickFixes = append(quickFixes, QuickFixActionEnsureDefaultChannel) + } else if len(issues) > 0 { + status = "warning" + summaryCode = "some_channels_misconfigured" + quickFixes = append(quickFixes, QuickFixActionEnsureDefaultChannel) + } + + return response.SetupStepItem{ + Key: "channels", + StepNumber: 5, + Status: status, + SummaryCode: summaryCode, + TotalCount: len(channels), + ReadyCount: readyCount, + Issues: issues, + QuickFixActions: quickFixes, + ManagePath: "/dashboard/channels", + }, briefs +} + +func (s *setupReadinessService) evaluateEndToEndStep( + channels []models.Channel, + aiAgents []models.AIAgent, + readyAIAgentMap map[int64]bool, + activeTeamMap map[int64]models.AgentTeam, + activeScheduleNowTeamSet map[int64]bool, + readyOperatorTeams map[int64]bool, +) (response.SetupStepItem, bool) { + issues := make([]response.SetupIssueItem, 0) + quickFixes := make([]string, 0) + readyChannelCount := 0 + activeChannelCount := 0 + + for _, ch := range channels { + if ch.Status != enums.StatusOk { + continue + } + activeChannelCount++ + if ch.AIAgentRolloutPercent > 0 && readyAIAgentMap[ch.AIAgentID] { + readyChannelCount++ + } + } + + canReceiveNow := readyChannelCount > 0 + status := "ok" + summaryCode := "ok" + + if !canReceiveNow { + status = "error" + summaryCode = "no_end_to_end_path" + issues = append(issues, response.SetupIssueItem{ + Code: "no_end_to_end_path", + Severity: "error", + EntityType: "system", + QuickFixAction: QuickFixActionQuickSetupAll, + }) + quickFixes = append(quickFixes, QuickFixActionQuickSetupAll) + } + + _ = aiAgents + _ = activeTeamMap + _ = activeScheduleNowTeamSet + _ = readyOperatorTeams + + return response.SetupStepItem{ + Key: "end_to_end", + StepNumber: 6, + Status: status, + SummaryCode: summaryCode, + TotalCount: max(activeChannelCount, 1), + ReadyCount: readyChannelCount, + Issues: issues, + QuickFixActions: quickFixes, + ManagePath: "/dashboard/setup", + }, canReceiveNow +} + +func (s *setupReadinessService) evaluateCurrentOperator( + operator *dto.AuthPrincipal, + profiles []models.AgentProfile, + activeTeamMap map[int64]models.AgentTeam, + activeScheduleNowTeamSet map[int64]bool, + activeCounts map[int64]int, +) response.CurrentOperatorDiagnostic { + if operator == nil || operator.UserID <= 0 { + return response.CurrentOperatorDiagnostic{ + BlockerCodes: []string{"not_authenticated"}, + } + } + + diag := response.CurrentOperatorDiagnostic{ + UserID: operator.UserID, + Username: operator.Username, + Nickname: operator.Nickname, + BlockerCodes: make([]string, 0), + } + + var matched *models.AgentProfile + for i := range profiles { + if profiles[i].UserID == operator.UserID { + matched = &profiles[i] + break + } + } + + if matched == nil { + diag.BlockerCodes = append(diag.BlockerCodes, "missing_profile") + return diag + } + + diag.HasProfile = true + diag.ProfileStatusOk = matched.Status == enums.StatusOk + diag.TeamID = matched.TeamID + diag.ServiceStatus = int(matched.ServiceStatus) + diag.AutoAssignEnabled = matched.AutoAssignEnabled + diag.ActiveConversationCount = int64(activeCounts[operator.UserID]) + diag.MaxConcurrentCount = matched.MaxConcurrentCount + + if !diag.ProfileStatusOk { + diag.BlockerCodes = append(diag.BlockerCodes, "profile_disabled") + } + + if team, ok := activeTeamMap[matched.TeamID]; ok { + diag.TeamName = team.Name + diag.TeamActive = true + diag.TeamHasScheduleNow = activeScheduleNowTeamSet[team.ID] + if !diag.TeamHasScheduleNow { + diag.BlockerCodes = append(diag.BlockerCodes, "team_off_hours") + } + } else { + diag.BlockerCodes = append(diag.BlockerCodes, "no_team") + } + + if !matched.AutoAssignEnabled { + diag.BlockerCodes = append(diag.BlockerCodes, "auto_assign_disabled") + } + if matched.ServiceStatus != enums.ServiceStatusIdle { + diag.BlockerCodes = append(diag.BlockerCodes, "status_busy") + } + if matched.MaxConcurrentCount <= 0 || int(diag.ActiveConversationCount) >= matched.MaxConcurrentCount { + diag.BlockerCodes = append(diag.BlockerCodes, "at_capacity") + } + + diag.CanReceiveNow = len(diag.BlockerCodes) == 0 + return diag +} + +func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequest, operator *dto.AuthPrincipal) (*response.SetupSimulateRouteResponse, error) { + now := time.Now() + db := sqls.DB() + fixIfAllowed := func(action string) string { + if s.canRunQuickFixAction(operator, action) { + return action + } + return "" + } + + var channel models.Channel + if req.ChannelID > 0 { + if err := db.First(&channel, req.ChannelID).Error; err != nil { + return &response.SetupSimulateRouteResponse{ + WillReachOperator: false, + FinalOutcomeCode: "blocked_no_channel", + Hops: []response.SimulatedRouteHop{ + { + HopKey: "channel", + Status: "error", + ReasonCode: "blocked_no_channel", + QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel), + }, + }, + }, nil + } + } else { + if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").First(&channel).Error; err != nil { + if err2 := db.Order("id ASC").First(&channel).Error; err2 != nil { + return &response.SetupSimulateRouteResponse{ + WillReachOperator: false, + FinalOutcomeCode: "blocked_no_channel", + Hops: []response.SimulatedRouteHop{ + { + HopKey: "channel", + Status: "error", + ReasonCode: "blocked_no_channel", + QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel), + }, + }, + }, nil + } + } + } + + resp := &response.SetupSimulateRouteResponse{ + ChannelID: channel.ID, + ChannelName: channel.Name, + ChannelType: channel.ChannelType, + Hops: make([]response.SimulatedRouteHop, 0, 5), + CandidateOperators: make([]response.SimulatedOperatorCandidate, 0), + } + + // Hop 1: Channel + if channel.Status != enums.StatusOk { + resp.FinalOutcomeCode = "blocked_channel_disabled" + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ + HopKey: "channel", + Status: "error", + ReasonCode: "blocked_channel_disabled", + EntityID: channel.ID, + EntityName: channel.Name, + QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel), + }) + return resp, nil + } + if channel.AIAgentRolloutPercent <= 0 { + resp.FinalOutcomeCode = "blocked_zero_rollout" + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ + HopKey: "channel", + Status: "error", + ReasonCode: "blocked_zero_rollout", + EntityID: channel.ID, + EntityName: channel.Name, + QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel), + }) + return resp, nil + } + if channel.AIAgentID <= 0 { + resp.FinalOutcomeCode = "blocked_missing_ai_agent" + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ + HopKey: "channel", + Status: "error", + ReasonCode: "blocked_missing_ai_agent", + EntityID: channel.ID, + EntityName: channel.Name, + QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel), + }) + return resp, nil + } + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ + HopKey: "channel", + Status: "ok", + ReasonCode: "ok", + EntityID: channel.ID, + EntityName: channel.Name, + }) + + // Hop 2: AI Agent + var aiAgent models.AIAgent + if err := db.First(&aiAgent, channel.AIAgentID).Error; err != nil || aiAgent.Status != enums.StatusOk { + resp.FinalOutcomeCode = "blocked_missing_ai_agent" + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ + HopKey: "ai_agent", + Status: "error", + ReasonCode: "blocked_missing_ai_agent", + EntityID: channel.AIAgentID, + QuickFixAction: fixIfAllowed(QuickFixActionFixAIAgentHandoff), + }) + return resp, nil + } + if aiAgent.ServiceMode == enums.IMConversationServiceModeAIOnly { + resp.FinalOutcomeCode = "blocked_ai_only_mode" + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ + HopKey: "ai_agent", + Status: "error", + ReasonCode: "blocked_ai_only_mode", + EntityID: aiAgent.ID, + EntityName: aiAgent.Name, + QuickFixAction: fixIfAllowed(QuickFixActionFixAIAgentHandoff), + }) + return resp, nil + } + agentHopStatus := "ok" + agentReason := "ok_human_only" + if aiAgent.ServiceMode == enums.IMConversationServiceModeAIFirst { + agentReason = "ok_ai_first_handoff" + if !aiAgent.AutoHandoffOnFailure { + agentHopStatus = "warning" + agentReason = "warning_auto_handoff_off" + } + } + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ + HopKey: "ai_agent", + Status: agentHopStatus, + ReasonCode: agentReason, + EntityID: aiAgent.ID, + EntityName: aiAgent.Name, + QuickFixAction: fixIfAllowed(QuickFixActionFixAIAgentHandoff), + }) + + // Hop 3: Target Teams + requestedTeamIDs := utils.SplitInt64s(aiAgent.TeamIDs) + var teams []models.AgentTeam + if len(requestedTeamIDs) > 0 { + db.Where("id IN ? AND status = ?", requestedTeamIDs, enums.StatusOk).Find(&teams) + } + if len(teams) == 0 { + resp.FinalOutcomeCode = "blocked_agent_no_teams" + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ + HopKey: "teams", + Status: "error", + ReasonCode: "blocked_agent_no_teams", + EntityID: aiAgent.ID, + EntityName: aiAgent.Name, + QuickFixAction: fixIfAllowed(QuickFixActionFixAIAgentHandoff), + }) + return resp, nil + } + + teamNames := make([]string, 0, len(teams)) + enabledTeamIDSet := make(map[int64]models.AgentTeam, len(teams)) + enabledTeamIDs := make([]int64, 0, len(teams)) + for _, t := range teams { + teamNames = append(teamNames, t.Name) + enabledTeamIDSet[t.ID] = t + enabledTeamIDs = append(enabledTeamIDs, t.ID) + } + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ + HopKey: "teams", + Status: "ok", + ReasonCode: "ok", + EntityName: strings.Join(teamNames, ", "), + }) + + // Hop 4: Shift Schedules (Active Now) + activeScheduleTeamIDs := ConversationDispatchService.findActiveScheduleTeamIDs(enabledTeamIDs, now) + activeScheduleTeamSet := make(map[int64]bool, len(activeScheduleTeamIDs)) + activeScheduleNames := make([]string, 0, len(activeScheduleTeamIDs)) + for _, tid := range activeScheduleTeamIDs { + activeScheduleTeamSet[tid] = true + if t, ok := enabledTeamIDSet[tid]; ok { + activeScheduleNames = append(activeScheduleNames, t.Name) + } + } + + if len(activeScheduleTeamIDs) == 0 { + resp.FinalOutcomeCode = "queued_off_hours_no_schedule" + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ + HopKey: "schedules", + Status: "error", + ReasonCode: "queued_off_hours_no_schedule", + EntityName: strings.Join(teamNames, ", "), + QuickFixAction: fixIfAllowed(QuickFixActionEnsureTeamSchedule), + }) + resp.CandidateOperators = s.buildCandidateListForTeams(enabledTeamIDSet, activeScheduleTeamSet) + return resp, nil + } + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ + HopKey: "schedules", + Status: "ok", + ReasonCode: "ok", + EntityName: strings.Join(activeScheduleNames, ", "), + }) + + // Hop 5: Operator Candidate Selection + candidates := s.buildCandidateListForTeams(enabledTeamIDSet, activeScheduleTeamSet) + resp.CandidateOperators = candidates + + eligibleCandidates, _, err := ConversationDispatchService.pickDispatchCandidates(enabledTeamIDs, now) + if err != nil { + return nil, err + } + if len(eligibleCandidates) == 0 { + resp.FinalOutcomeCode = "queued_no_eligible_operator" + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ + HopKey: "operators", + Status: "error", + ReasonCode: "queued_no_eligible_operator", + QuickFixAction: fixIfAllowed(QuickFixActionEnableMyProfile), + }) + return resp, nil + } + + winner := eligibleCandidates[0] + winnerTeamName := "" + if t, ok := enabledTeamIDSet[winner.profile.TeamID]; ok { + winnerTeamName = t.Name + } + winnerDisplayName := firstNonEmpty(winner.profile.DisplayName, fmt.Sprintf("#%d", winner.profile.UserID)) + matched := &response.SimulatedOperatorCandidate{ + UserID: winner.profile.UserID, + DisplayName: winnerDisplayName, + TeamID: winner.profile.TeamID, + TeamName: winnerTeamName, + Eligible: true, + ActiveCount: int64(winner.activeCount), + MaxConcurrentCount: winner.profile.MaxConcurrentCount, + ServiceStatus: int(winner.profile.ServiceStatus), + AutoAssignEnabled: winner.profile.AutoAssignEnabled, + } + resp.MatchedOperator = matched + resp.WillReachOperator = true + resp.FinalOutcomeCode = "assigned_to_operator" + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ + HopKey: "operators", + Status: "ok", + ReasonCode: "assigned_to_operator", + EntityID: winner.profile.UserID, + EntityName: winnerDisplayName, + }) + + return resp, nil +} + +func (s *setupReadinessService) buildCandidateListForTeams( + enabledTeamIDSet map[int64]models.AgentTeam, + activeScheduleTeamSet map[int64]bool, +) []response.SimulatedOperatorCandidate { + db := sqls.DB() + var allProfiles []models.AgentProfile + db.Order("id ASC").Find(&allProfiles) + + var allTeams []models.AgentTeam + db.Find(&allTeams) + teamNameMap := make(map[int64]string, len(allTeams)) + for _, t := range allTeams { + teamNameMap[t.ID] = t.Name + } + + var users []models.User + db.Find(&users) + userMap := make(map[int64]models.User, len(users)) + for _, u := range users { + userMap[u.ID] = u + } + + activeCounts, _ := ConversationDispatchService.findActiveConversationCountMap(extractProfileUserIDs(allProfiles)) + result := make([]response.SimulatedOperatorCandidate, 0, len(allProfiles)) + + for _, p := range allProfiles { + u, hasUser := userMap[p.UserID] + displayName := strings.TrimSpace(p.DisplayName) + if displayName == "" && hasUser { + displayName = firstNonEmpty(u.Nickname, u.Username) + } + if displayName == "" { + displayName = fmt.Sprintf("#%d", p.UserID) + } + + skipReason := "" + switch { + case p.Status != enums.StatusOk: + skipReason = "profile_disabled" + case !hasUser || u.Status != enums.StatusOk || u.DeletedAt != nil: + skipReason = "user_disabled" + case enabledTeamIDSet[p.TeamID].ID == 0: + skipReason = "team_not_in_agent" + case !activeScheduleTeamSet[p.TeamID]: + skipReason = "team_off_hours" + case !p.AutoAssignEnabled: + skipReason = "auto_assign_off" + case p.ServiceStatus != enums.ServiceStatusIdle: + skipReason = "status_busy" + case p.MaxConcurrentCount <= 0 || activeCounts[p.UserID] >= p.MaxConcurrentCount: + skipReason = "at_capacity" + } + + result = append(result, response.SimulatedOperatorCandidate{ + UserID: p.UserID, + DisplayName: displayName, + TeamID: p.TeamID, + TeamName: teamNameMap[p.TeamID], + Eligible: skipReason == "", + SkipReasonCode: skipReason, + ActiveCount: int64(activeCounts[p.UserID]), + MaxConcurrentCount: p.MaxConcurrentCount, + ServiceStatus: int(p.ServiceStatus), + AutoAssignEnabled: p.AutoAssignEnabled, + }) + } + return result +} + +func (s *setupReadinessService) hasAnyPermission(operator *dto.AuthPrincipal, codes ...string) bool { + if operator == nil || operator.UserID <= 0 { + return false + } + if slices.Contains(operator.Roles, constants.RoleCodeSuperAdmin) || slices.Contains(operator.Roles, constants.RoleCodeAdmin) { + return true + } + for _, code := range codes { + if slices.Contains(operator.Permissions, code) { + return true + } + } + return false +} + +func (s *setupReadinessService) canManageTeamsAndSchedules(operator *dto.AuthPrincipal) bool { + return s.hasAnyPermission(operator, constants.PermissionAgentTeamCreate.Code, constants.PermissionAgentTeamUpdate.Code) && + s.hasAnyPermission(operator, constants.PermissionAgentTeamScheduleCreate.Code, constants.PermissionAgentTeamScheduleUpdate.Code, constants.PermissionAgentTeamScheduleBatchGenerate.Code) +} + +func (s *setupReadinessService) canManageOperatorProfile(operator *dto.AuthPrincipal) bool { + return s.hasAnyPermission( + operator, + constants.PermissionAgentCreate.Code, + constants.PermissionAgentUpdate.Code, + constants.PermissionAgentConfig.Code, + constants.PermissionAgentUpdateStatus.Code, + ) +} + +func (s *setupReadinessService) canManageAIAgents(operator *dto.AuthPrincipal) bool { + return s.hasAnyPermission(operator, constants.PermissionAIAgentCreate.Code, constants.PermissionAIAgentUpdate.Code) +} + +func (s *setupReadinessService) canManageChannels(operator *dto.AuthPrincipal) bool { + return s.hasAnyPermission(operator, constants.PermissionChannelCreate.Code, constants.PermissionChannelUpdate.Code) +} + +func (s *setupReadinessService) canRunQuickFixAction(operator *dto.AuthPrincipal, action string) bool { + switch action { + case QuickFixActionEnsureTeamSchedule: + return s.canManageTeamsAndSchedules(operator) + case QuickFixActionEnableMyProfile: + return s.canManageOperatorProfile(operator) + case QuickFixActionFixAIAgentHandoff: + return s.canManageAIAgents(operator) + case QuickFixActionEnsureDefaultChannel: + return s.canManageChannels(operator) + case QuickFixActionQuickSetupAll: + return s.canManageTeamsAndSchedules(operator) && + s.canManageOperatorProfile(operator) && + s.canManageAIAgents(operator) && + s.canManageChannels(operator) + default: + return false + } +} + +func (s *setupReadinessService) filterStepQuickFixes(step response.SetupStepItem, operator *dto.AuthPrincipal) response.SetupStepItem { + filteredActions := make([]string, 0, len(step.QuickFixActions)) + for _, action := range step.QuickFixActions { + if s.canRunQuickFixAction(operator, action) { + filteredActions = append(filteredActions, action) + } + } + step.QuickFixActions = filteredActions + + for i := range step.Issues { + if step.Issues[i].QuickFixAction != "" && !s.canRunQuickFixAction(operator, step.Issues[i].QuickFixAction) { + step.Issues[i].QuickFixAction = "" + } + } + return step +} + +func (s *setupReadinessService) ApplyQuickFix(req request.SetupQuickFixRequest, operator *dto.AuthPrincipal) (*response.SetupQuickFixResponse, error) { + if operator == nil || operator.UserID <= 0 { + return nil, errorsx.UnauthorizedI18n("error.setup.authRequired") + } + + action := strings.TrimSpace(req.Action) + applied := make([]string, 0, 4) + + switch action { + case QuickFixActionQuickSetupAll: + if !s.canRunQuickFixAction(operator, QuickFixActionQuickSetupAll) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + a1, err := s.ensureTeamAndSchedule(operator) + if err != nil { + return nil, err + } + applied = append(applied, a1...) + + a2, err := s.enableOperatorProfile(operator) + if err != nil { + return nil, err + } + applied = append(applied, a2...) + + a3, err := s.fixAIAgentHandoff(req.EntityID, operator) + if err != nil { + return nil, err + } + applied = append(applied, a3...) + + a4, err := s.ensureDefaultChannel(req.EntityID, operator) + if err != nil { + return nil, err + } + applied = append(applied, a4...) + + case QuickFixActionEnsureTeamSchedule: + if !s.canRunQuickFixAction(operator, QuickFixActionEnsureTeamSchedule) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + a1, err := s.ensureTeamAndSchedule(operator) + if err != nil { + return nil, err + } + applied = append(applied, a1...) + + case QuickFixActionEnableMyProfile: + if !s.canRunQuickFixAction(operator, QuickFixActionEnableMyProfile) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + if s.canManageTeamsAndSchedules(operator) { + a1, err := s.ensureTeamAndSchedule(operator) + if err != nil { + return nil, err + } + applied = append(applied, a1...) + } + + a2, err := s.enableOperatorProfile(operator) + if err != nil { + return nil, err + } + applied = append(applied, a2...) + + case QuickFixActionFixAIAgentHandoff: + if !s.canRunQuickFixAction(operator, QuickFixActionFixAIAgentHandoff) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + if s.canManageTeamsAndSchedules(operator) { + a1, err := s.ensureTeamAndSchedule(operator) + if err != nil { + return nil, err + } + applied = append(applied, a1...) + } + + a2, err := s.fixAIAgentHandoff(req.EntityID, operator) + if err != nil { + return nil, err + } + applied = append(applied, a2...) + + case QuickFixActionEnsureDefaultChannel: + if !s.canRunQuickFixAction(operator, QuickFixActionEnsureDefaultChannel) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + if s.canManageTeamsAndSchedules(operator) { + a1, err := s.ensureTeamAndSchedule(operator) + if err != nil { + return nil, err + } + applied = append(applied, a1...) + } + if s.canManageAIAgents(operator) { + a2, err := s.fixAIAgentHandoff(0, operator) + if err != nil { + return nil, err + } + applied = append(applied, a2...) + } + + a3, err := s.ensureDefaultChannel(req.EntityID, operator) + if err != nil { + return nil, err + } + applied = append(applied, a3...) + + default: + return nil, errorsx.InvalidParamI18n("error.setup.invalidQuickFixAction", action) + } + + // Trigger dispatch of any pending conversations now that routing is fixed + _, _ = ConversationDispatchService.DispatchPendingConversations(0) + + readiness, err := s.GetReadiness(operator) + if err != nil { + return nil, err + } + + return &response.SetupQuickFixResponse{ + AppliedActions: applied, + Readiness: *readiness, + }, nil +} + +func (s *setupReadinessService) ensureTeamAndSchedule(operator *dto.AuthPrincipal) ([]string, error) { + now := time.Now() + db := sqls.DB() + applied := make([]string, 0, 2) + + var activeTeams []models.AgentTeam + if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").Find(&activeTeams).Error; err != nil { + return nil, err + } + + if len(activeTeams) == 0 { + var anyTeam models.AgentTeam + if err := db.Order("id ASC").First(&anyTeam).Error; err == nil && anyTeam.ID > 0 { + if !s.hasAnyPermission(operator, constants.PermissionAgentTeamUpdate.Code) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + anyTeam.Status = enums.StatusOk + anyTeam.UpdatedAt = now + anyTeam.UpdateUserID = operator.UserID + anyTeam.UpdateUserName = operator.Username + if err := db.Save(&anyTeam).Error; err != nil { + return nil, err + } + activeTeams = append(activeTeams, anyTeam) + applied = append(applied, "enabled_team") + } else { + if !s.hasAnyPermission(operator, constants.PermissionAgentTeamCreate.Code) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + newTeam := models.AgentTeam{ + Name: "Default Support Team", + LeaderUserID: operator.UserID, + Status: enums.StatusOk, + Description: "Auto-created support team", + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: operator.UserID, + CreateUserName: operator.Username, + UpdatedAt: now, + UpdateUserID: operator.UserID, + UpdateUserName: operator.Username, + }, + } + if err := db.Create(&newTeam).Error; err != nil { + return nil, err + } + activeTeams = append(activeTeams, newTeam) + applied = append(applied, "created_default_team") + } + } + + for _, team := range activeTeams { + var count int64 + if err := db.Model(&models.AgentTeamSchedule{}). + Where("team_id = ? AND status = ? AND start_at <= ? AND end_at > ?", team.ID, enums.StatusOk, now, now). + Count(&count).Error; err != nil { + return nil, err + } + if count == 0 { + if !s.hasAnyPermission(operator, constants.PermissionAgentTeamScheduleCreate.Code, constants.PermissionAgentTeamScheduleBatchGenerate.Code) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + schedule := models.AgentTeamSchedule{ + TeamID: team.ID, + StartAt: now.Add(-1 * time.Hour), + EndAt: now.AddDate(1, 0, 0), + Remark: "24/7 Continuous Shift (Auto-Setup)", + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: operator.UserID, + CreateUserName: operator.Username, + UpdatedAt: now, + UpdateUserID: operator.UserID, + UpdateUserName: operator.Username, + }, + } + if err := db.Create(&schedule).Error; err != nil { + return nil, err + } + applied = append(applied, "created_active_schedule") + } + } + + return applied, nil +} + +func (s *setupReadinessService) enableOperatorProfile(operator *dto.AuthPrincipal) ([]string, error) { + now := time.Now() + db := sqls.DB() + applied := make([]string, 0, 1) + + var firstTeam models.AgentTeam + if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").First(&firstTeam).Error; err != nil { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + + var profile models.AgentProfile + err := db.Where("user_id = ?", operator.UserID).First(&profile).Error + if err != nil { + if !s.hasAnyPermission(operator, constants.PermissionAgentCreate.Code) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + displayName := firstNonEmpty(operator.Nickname, operator.Username, fmt.Sprintf("Agent-%d", operator.UserID)) + newProfile := models.AgentProfile{ + UserID: operator.UserID, + TeamID: firstTeam.ID, + AgentCode: fmt.Sprintf("AG%04d", operator.UserID), + DisplayName: displayName, + ServiceStatus: enums.ServiceStatusIdle, + MaxConcurrentCount: 10, + PriorityLevel: 1, + AutoAssignEnabled: true, + ReceiveOfflineMessage: true, + LastOnlineAt: &now, + LastStatusAt: &now, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: operator.UserID, + CreateUserName: operator.Username, + UpdatedAt: now, + UpdateUserID: operator.UserID, + UpdateUserName: operator.Username, + }, + } + if err := db.Create(&newProfile).Error; err != nil { + return nil, err + } + applied = append(applied, "created_operator_profile") + return applied, nil + } + + targetTeamID := profile.TeamID + var teamCheck models.AgentTeam + needsTeamRebind := targetTeamID <= 0 || db.Where("id = ? AND status = ?", targetTeamID, enums.StatusOk).First(&teamCheck).Error != nil + if needsTeamRebind { + targetTeamID = firstTeam.ID + } + + maxConcurrent := profile.MaxConcurrentCount + needsConfigChange := !profile.AutoAssignEnabled || maxConcurrent <= 0 + if maxConcurrent <= 0 { + maxConcurrent = 10 + } + needsProfileEnable := profile.Status != enums.StatusOk + + if (needsTeamRebind || needsProfileEnable) && !s.hasAnyPermission(operator, constants.PermissionAgentUpdate.Code) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + if needsConfigChange && !s.hasAnyPermission(operator, constants.PermissionAgentUpdate.Code, constants.PermissionAgentConfig.Code) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + if !s.hasAnyPermission(operator, constants.PermissionAgentUpdate.Code, constants.PermissionAgentConfig.Code, constants.PermissionAgentUpdateStatus.Code) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + + updates := map[string]any{ + "team_id": targetTeamID, + "service_status": enums.ServiceStatusIdle, + "auto_assign_enabled": true, + "max_concurrent_count": maxConcurrent, + "status": enums.StatusOk, + "last_online_at": now, + "last_status_at": now, + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + } + if err := db.Model(&models.AgentProfile{}).Where("id = ?", profile.ID).Updates(updates).Error; err != nil { + return nil, err + } + applied = append(applied, "updated_operator_profile_online") + return applied, nil +} + +func (s *setupReadinessService) fixAIAgentHandoff(targetAgentID int64, operator *dto.AuthPrincipal) ([]string, error) { + now := time.Now() + db := sqls.DB() + applied := make([]string, 0, 2) + + var activeTeams []models.AgentTeam + if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").Find(&activeTeams).Error; err != nil { + return nil, err + } + teamIDStrs := make([]string, 0, len(activeTeams)) + activeTeamSet := make(map[int64]bool, len(activeTeams)) + for _, t := range activeTeams { + teamIDStrs = append(teamIDStrs, strconv.FormatInt(t.ID, 10)) + activeTeamSet[t.ID] = true + } + defaultTeamIDsCSV := strings.Join(teamIDStrs, ",") + + var agents []models.AIAgent + if targetAgentID > 0 { + db.Where("id = ?", targetAgentID).Find(&agents) + } else { + db.Order("id ASC").Find(&agents) + } + + if len(agents) == 0 { + if !s.hasAnyPermission(operator, constants.PermissionAIAgentCreate.Code) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + newAgent := models.AIAgent{ + Name: "Default Reception Agent", + Description: "Routes customer inquiries to human operators", + Status: enums.StatusOk, + ServiceMode: enums.IMConversationServiceModeHumanOnly, + RolloutPercent: 100, + TeamIDs: defaultTeamIDsCSV, + HandoffMode: enums.AIAgentHandoffModeDefaultTeamPool, + FallbackMode: enums.AIAgentFallbackModeHandoff, + AutoHandoffOnFailure: true, + ReplyTimeoutSeconds: 180, + MaxSteps: 6, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: operator.UserID, + CreateUserName: operator.Username, + UpdatedAt: now, + UpdateUserID: operator.UserID, + UpdateUserName: operator.Username, + }, + } + if err := db.Create(&newAgent).Error; err != nil { + return nil, err + } + applied = append(applied, "created_default_ai_agent") + return applied, nil + } + + if !s.hasAnyPermission(operator, constants.PermissionAIAgentUpdate.Code) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + + for _, agent := range agents { + existingTeamIDs := utils.SplitInt64s(agent.TeamIDs) + validTeamIDs := make([]string, 0, len(existingTeamIDs)) + for _, tid := range existingTeamIDs { + if activeTeamSet[tid] { + validTeamIDs = append(validTeamIDs, strconv.FormatInt(tid, 10)) + } + } + newTeamCSV := strings.Join(validTeamIDs, ",") + if newTeamCSV == "" { + newTeamCSV = defaultTeamIDsCSV + } + + hasValidAIConfig := false + if agent.AIConfigID > 0 { + var aiCfg models.AIConfig + if err := db.Where("id = ? AND status = ?", agent.AIConfigID, enums.StatusOk).First(&aiCfg).Error; err == nil && aiCfg.ID > 0 { + hasValidAIConfig = true + } + } + + newServiceMode := agent.ServiceMode + if !hasValidAIConfig { + newServiceMode = enums.IMConversationServiceModeHumanOnly + } else if newServiceMode == enums.IMConversationServiceModeAIOnly || newServiceMode == 0 { + newServiceMode = enums.IMConversationServiceModeAIFirst + } + + rollout := agent.RolloutPercent + if rollout <= 0 { + rollout = 100 + } + + updates := map[string]any{ + "status": enums.StatusOk, + "team_ids": newTeamCSV, + "service_mode": newServiceMode, + "auto_handoff_on_failure": true, + "rollout_percent": rollout, + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + } + if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Updates(updates).Error; err != nil { + return nil, err + } + applied = append(applied, "updated_ai_agent_handoff") + } + + return applied, nil +} + +func (s *setupReadinessService) ensureDefaultChannel(targetChannelID int64, operator *dto.AuthPrincipal) ([]string, error) { + now := time.Now() + db := sqls.DB() + applied := make([]string, 0, 1) + + var firstAgent models.AIAgent + if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").First(&firstAgent).Error; err != nil { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + + var channels []models.Channel + if targetChannelID > 0 { + db.Where("id = ?", targetChannelID).Find(&channels) + } else { + db.Order("id ASC").Find(&channels) + } + + if len(channels) == 0 { + if !s.hasAnyPermission(operator, constants.PermissionChannelCreate.Code) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + newChannel := models.Channel{ + Name: "Default Web Support Channel", + ChannelType: string(enums.ChannelTypeWeb), + ChannelID: fmt.Sprintf("web_default_%d", now.Unix()), + AIAgentID: firstAgent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: `{"position":"right"}`, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: operator.UserID, + CreateUserName: operator.Username, + UpdatedAt: now, + UpdateUserID: operator.UserID, + UpdateUserName: operator.Username, + }, + } + if err := db.Create(&newChannel).Error; err != nil { + return nil, err + } + applied = append(applied, "created_default_channel") + return applied, nil + } + + if !s.hasAnyPermission(operator, constants.PermissionChannelUpdate.Code) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + + for _, ch := range channels { + agentID := ch.AIAgentID + var checkAgent models.AIAgent + if agentID <= 0 || db.Where("id = ? AND status = ?", agentID, enums.StatusOk).First(&checkAgent).Error != nil { + agentID = firstAgent.ID + } + rollout := ch.AIAgentRolloutPercent + if rollout <= 0 { + rollout = 100 + } + updates := map[string]any{ + "status": enums.StatusOk, + "ai_agent_id": agentID, + "ai_agent_rollout_percent": rollout, + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + } + if err := db.Model(&models.Channel{}).Where("id = ?", ch.ID).Updates(updates).Error; err != nil { + return nil, err + } + applied = append(applied, "updated_channel_routing") + } + + return applied, nil +} + +func extractProfileUserIDs(profiles []models.AgentProfile) []int64 { + ids := make([]int64, 0, len(profiles)) + seen := make(map[int64]bool, len(profiles)) + for _, p := range profiles { + if p.UserID > 0 && !seen[p.UserID] { + seen[p.UserID] = true + ids = append(ids, p.UserID) + } + } + return ids +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + trimmed := strings.TrimSpace(v) + if trimmed != "" { + return trimmed + } + } + return "" +} + +var ( + _ = math.Max + _ = slices.SortFunc[[]int, int] +) diff --git a/internal/services/setup_readiness_service_test.go b/internal/services/setup_readiness_service_test.go new file mode 100644 index 0000000..8ecc130 --- /dev/null +++ b/internal/services/setup_readiness_service_test.go @@ -0,0 +1,436 @@ +package services_test + +import ( + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/services" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupSetupReadinessTestDB(t *testing.T) *gorm.DB { + t.Helper() + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate( + &models.User{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &models.AIAgent{}, + &models.AgentTeam{}, + &models.AgentTeamSchedule{}, + &models.AgentProfile{}, + &models.Channel{}, + &models.Conversation{}, + &models.ConversationParticipant{}, + &models.ConversationAssignment{}, + &models.ConversationEventLog{}, + &models.ConversationReadState{}, + &models.Message{}, + &models.ChannelMessageOutbox{}, + ); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + return db +} + +func TestSetupReadiness_EmptyDBAndQuickSetupAll(t *testing.T) { + db := setupSetupReadinessTestDB(t) + + admin := models.User{ + Username: "admin", + Nickname: "Admin Operator", + Status: enums.StatusOk, + } + if err := db.Create(&admin).Error; err != nil { + t.Fatalf("create admin error = %v", err) + } + principal := &dto.AuthPrincipal{ + UserID: admin.ID, + Username: admin.Username, + Nickname: admin.Nickname, + Roles: []string{constants.RoleCodeSuperAdmin}, + } + + readiness, err := services.SetupReadinessService.GetReadiness(principal) + if err != nil { + t.Fatalf("GetReadiness error = %v", err) + } + if readiness.CanReceiveRequestsNow { + t.Fatalf("expected CanReceiveRequestsNow = false on empty DB") + } + if readiness.ReadySteps != 0 { + t.Fatalf("expected ReadySteps = 0 on empty DB, got %d", readiness.ReadySteps) + } + if readiness.TotalSteps != 6 { + t.Fatalf("expected TotalSteps = 6, got %d", readiness.TotalSteps) + } + if readiness.CurrentOperator.CanReceiveNow { + t.Fatalf("expected CurrentOperator.CanReceiveNow = false on empty DB") + } + + fixResp, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ + Action: "quick_setup_all", + }, principal) + if err != nil { + t.Fatalf("ApplyQuickFix(quick_setup_all) error = %v", err) + } + if !fixResp.Readiness.CanReceiveRequestsNow { + t.Fatalf("expected CanReceiveRequestsNow = true after quick_setup_all, steps = %+v", fixResp.Readiness.Steps) + } + if fixResp.Readiness.ReadySteps != 6 { + t.Fatalf("expected ReadySteps = 6 after quick_setup_all, got %d", fixResp.Readiness.ReadySteps) + } + if !fixResp.Readiness.CurrentOperator.CanReceiveNow { + t.Fatalf("expected CurrentOperator.CanReceiveNow = true after quick_setup_all, blockers = %v", fixResp.Readiness.CurrentOperator.BlockerCodes) + } + + // Second call must be idempotent + fixResp2, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ + Action: "quick_setup_all", + }, principal) + if err != nil { + t.Fatalf("second ApplyQuickFix(quick_setup_all) error = %v", err) + } + if !fixResp2.Readiness.CanReceiveRequestsNow { + t.Fatalf("expected CanReceiveRequestsNow = true after second quick_setup_all") + } + + var teamCount int64 + db.Model(&models.AgentTeam{}).Count(&teamCount) + if teamCount != 1 { + t.Fatalf("expected idempotent 1 team, got %d", teamCount) + } +} + +func TestSetupReadiness_OffHoursAndBusyOperator(t *testing.T) { + db := setupSetupReadinessTestDB(t) + + now := time.Now() + opUser := models.User{ + Username: "operator1", + Nickname: "Operator One", + Status: enums.StatusOk, + } + if err := db.Create(&opUser).Error; err != nil { + t.Fatalf("create user error = %v", err) + } + team := models.AgentTeam{ + Name: "Support Team", + Status: enums.StatusOk, + } + if err := db.Create(&team).Error; err != nil { + t.Fatalf("create team error = %v", err) + } + // Schedule in the past (off-hours now) + pastSchedule := models.AgentTeamSchedule{ + TeamID: team.ID, + StartAt: now.Add(-48 * time.Hour), + EndAt: now.Add(-24 * time.Hour), + Status: enums.StatusOk, + } + if err := db.Create(&pastSchedule).Error; err != nil { + t.Fatalf("create past schedule error = %v", err) + } + // Operator is Busy and AutoAssignEnabled = false + profile := models.AgentProfile{ + UserID: opUser.ID, + TeamID: team.ID, + AgentCode: "OP-001", + DisplayName: "Operator One", + ServiceStatus: enums.ServiceStatusBusy, + MaxConcurrentCount: 5, + AutoAssignEnabled: false, + Status: enums.StatusOk, + } + if err := db.Create(&profile).Error; err != nil { + t.Fatalf("create profile error = %v", err) + } + if err := db.Model(&profile).Update("auto_assign_enabled", false).Error; err != nil { + t.Fatalf("update auto_assign_enabled error = %v", err) + } + + principal := &dto.AuthPrincipal{ + UserID: opUser.ID, + Username: opUser.Username, + Nickname: opUser.Nickname, + Roles: []string{constants.RoleCodeAdmin}, + } + + readiness, err := services.SetupReadinessService.GetReadiness(principal) + if err != nil { + t.Fatalf("GetReadiness error = %v", err) + } + if readiness.CurrentOperator.TeamHasScheduleNow { + t.Fatalf("expected TeamHasScheduleNow = false for past schedule") + } + if readiness.CurrentOperator.CanReceiveNow { + t.Fatalf("expected CurrentOperator.CanReceiveNow = false when busy and off-hours") + } + + // Fix operator profile and schedule + _, err = services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ + Action: "enable_my_operator_profile", + }, principal) + if err != nil { + t.Fatalf("enable_my_operator_profile error = %v", err) + } + readinessAfter, err := services.SetupReadinessService.GetReadiness(principal) + if err != nil { + t.Fatalf("GetReadiness after fix error = %v", err) + } + if !readinessAfter.CurrentOperator.CanReceiveNow { + t.Fatalf("expected CurrentOperator.CanReceiveNow = true after enable_my_operator_profile, blockers = %v", readinessAfter.CurrentOperator.BlockerCodes) + } +} + +func TestSetupReadiness_SimulateRoute_AIOnlyAndMissingTeams(t *testing.T) { + db := setupSetupReadinessTestDB(t) + + opUser := models.User{ + Username: "op2", + Nickname: "Operator Two", + Status: enums.StatusOk, + } + db.Create(&opUser) + principal := &dto.AuthPrincipal{ + UserID: opUser.ID, + Username: opUser.Username, + Nickname: opUser.Nickname, + Roles: []string{constants.RoleCodeAdmin}, + } + + // Setup team + active schedule + idle operator + _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ + Action: "enable_my_operator_profile", + }, principal) + if err != nil { + t.Fatalf("enable_my_operator_profile error = %v", err) + } + + // Create AI Agent in AIOnly mode and empty TeamIDs + agent := models.AIAgent{ + Name: "AI Bot", + Status: enums.StatusOk, + ServiceMode: enums.IMConversationServiceModeAIOnly, + RolloutPercent: 100, + TeamIDs: "", + } + db.Create(&agent) + + channel := models.Channel{ + Name: "Web Widget", + ChannelType: "web", + ChannelID: "web-test-1", + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + Status: enums.StatusOk, + } + db.Create(&channel) + + sim1, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ + ChannelID: channel.ID, + BypassAI: true, + }, principal) + if err != nil { + t.Fatalf("SimulateRoute 1 error = %v", err) + } + if sim1.WillReachOperator { + t.Fatalf("expected WillReachOperator = false when AIOnly") + } + if sim1.FinalOutcomeCode != "blocked_ai_only_mode" { + t.Fatalf("expected blocked_ai_only_mode, got %s", sim1.FinalOutcomeCode) + } + + // Switch to HumanOnly but still empty TeamIDs + db.Model(&agent).Update("service_mode", enums.IMConversationServiceModeHumanOnly) + sim2, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ + ChannelID: channel.ID, + BypassAI: true, + }, principal) + if err != nil { + t.Fatalf("SimulateRoute 2 error = %v", err) + } + if sim2.WillReachOperator { + t.Fatalf("expected WillReachOperator = false when TeamIDs is empty") + } + if sim2.FinalOutcomeCode != "blocked_agent_no_teams" { + t.Fatalf("expected blocked_agent_no_teams, got %s", sim2.FinalOutcomeCode) + } + + // Fix AI Agent handoff via quick fix + _, err = services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ + Action: "fix_ai_agent_handoff", + EntityID: agent.ID, + }, principal) + if err != nil { + t.Fatalf("fix_ai_agent_handoff error = %v", err) + } + + sim3, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ + ChannelID: channel.ID, + BypassAI: true, + }, principal) + if err != nil { + t.Fatalf("SimulateRoute 3 error = %v", err) + } + if !sim3.WillReachOperator { + t.Fatalf("expected WillReachOperator = true after fix_ai_agent_handoff, got outcome %s", sim3.FinalOutcomeCode) + } + if sim3.FinalOutcomeCode != "assigned_to_operator" { + t.Fatalf("expected assigned_to_operator, got %s", sim3.FinalOutcomeCode) + } + if sim3.MatchedOperator == nil || sim3.MatchedOperator.UserID != opUser.ID { + t.Fatalf("expected MatchedOperator UserID = %d, got %+v", opUser.ID, sim3.MatchedOperator) + } +} + +func TestSetupReadiness_QuickFixPermissionEnforcement(t *testing.T) { + db := setupSetupReadinessTestDB(t) + + csUser := models.User{ + Username: "cs_agent", + Nickname: "Support Agent", + Status: enums.StatusOk, + } + if err := db.Create(&csUser).Error; err != nil { + t.Fatalf("create csUser error = %v", err) + } + + // Regular cs_user only has view permissions + csPrincipal := &dto.AuthPrincipal{ + UserID: csUser.ID, + Username: csUser.Username, + Nickname: csUser.Nickname, + Roles: []string{constants.RoleCodeCsUser}, + Permissions: []string{ + constants.PermissionAgentView.Code, + constants.PermissionAgentTeamView.Code, + constants.PermissionAgentTeamScheduleView.Code, + constants.PermissionAIAgentView.Code, + constants.PermissionChannelView.Code, + }, + } + + // 1. GetReadiness for cs_user without mutation permissions must not expose quick-fix actions + readiness, err := services.SetupReadinessService.GetReadiness(csPrincipal) + if err != nil { + t.Fatalf("GetReadiness error = %v", err) + } + for _, step := range readiness.Steps { + if len(step.QuickFixActions) != 0 { + t.Fatalf("expected step %s QuickFixActions to be empty for cs_user without write permissions, got %v", step.Key, step.QuickFixActions) + } + for _, issue := range step.Issues { + if issue.QuickFixAction != "" { + t.Fatalf("expected issue %s QuickFixAction to be empty for cs_user without write permissions, got %s", issue.Code, issue.QuickFixAction) + } + } + } + + // 2. Direct API calls to ApplyQuickFix must fail with Forbidden (error.e0225) and leave DB untouched + for _, action := range []string{ + services.QuickFixActionQuickSetupAll, + services.QuickFixActionEnsureTeamSchedule, + services.QuickFixActionEnableMyProfile, + services.QuickFixActionFixAIAgentHandoff, + services.QuickFixActionEnsureDefaultChannel, + } { + _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ + Action: action, + }, csPrincipal) + if err == nil { + t.Fatalf("expected Forbidden error when cs_user executes %s without permissions, got nil", action) + } + } + + var teamCount, agentCount, channelCount, profileCount int64 + db.Model(&models.AgentTeam{}).Count(&teamCount) + db.Model(&models.AIAgent{}).Count(&agentCount) + db.Model(&models.Channel{}).Count(&channelCount) + db.Model(&models.AgentProfile{}).Count(&profileCount) + if teamCount != 0 || agentCount != 0 || channelCount != 0 || profileCount != 0 { + t.Fatalf("expected zero entities created after forbidden quick fixes, got teams=%d aiAgents=%d channels=%d profiles=%d", + teamCount, agentCount, channelCount, profileCount) + } + + // 3. Seed an active team + active schedule + busy profile for csUser + now := time.Now() + team := models.AgentTeam{Name: "Main Team", Status: enums.StatusOk} + db.Create(&team) + db.Create(&models.AgentTeamSchedule{ + TeamID: team.ID, + StartAt: now.Add(-1 * time.Hour), + EndAt: now.Add(24 * time.Hour), + Status: enums.StatusOk, + }) + db.Create(&models.AgentProfile{ + UserID: csUser.ID, + TeamID: team.ID, + AgentCode: "AG-CS1", + DisplayName: "Support Agent", + ServiceStatus: enums.ServiceStatusBusy, + MaxConcurrentCount: 5, + AutoAssignEnabled: true, + Status: enums.StatusOk, + }) + + // Grant only agent.updateStatus to csPrincipal: can switch own existing profile to Idle, + // but still cannot modify AI agents or channels! + statusOnlyPrincipal := &dto.AuthPrincipal{ + UserID: csUser.ID, + Username: csUser.Username, + Nickname: csUser.Nickname, + Roles: []string{constants.RoleCodeCsUser}, + Permissions: []string{ + constants.PermissionAgentView.Code, + constants.PermissionAgentUpdateStatus.Code, + }, + } + + if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ + Action: services.QuickFixActionFixAIAgentHandoff, + }, statusOnlyPrincipal); err == nil { + t.Fatalf("expected Forbidden when statusOnlyPrincipal calls fix_ai_agent_handoff") + } + if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ + Action: services.QuickFixActionEnsureDefaultChannel, + }, statusOnlyPrincipal); err == nil { + t.Fatalf("expected Forbidden when statusOnlyPrincipal calls ensure_default_channel") + } + + fixProfileResp, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ + Action: services.QuickFixActionEnableMyProfile, + }, statusOnlyPrincipal) + if err != nil { + t.Fatalf("expected statusOnlyPrincipal to succeed on enable_my_operator_profile when team+schedule+profile exist, got %v", err) + } + if !fixProfileResp.Readiness.CurrentOperator.CanReceiveNow { + t.Fatalf("expected CurrentOperator.CanReceiveNow = true after status update, blockers = %v", fixProfileResp.Readiness.CurrentOperator.BlockerCodes) + } +} diff --git a/internal/services/skill_definition_service.go b/internal/services/skill_definition_service.go new file mode 100644 index 0000000..e0b1299 --- /dev/null +++ b/internal/services/skill_definition_service.go @@ -0,0 +1,202 @@ +package services + +import ( + "encoding/json" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/toolx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var SkillDefinitionService = newSkillDefinitionService() + +func newSkillDefinitionService() *skillDefinitionService { + return &skillDefinitionService{} +} + +type skillDefinitionService struct { +} + +func (s *skillDefinitionService) Get(id int64) *models.SkillDefinition { + return repositories.SkillDefinitionRepository.Get(sqls.DB(), id) +} + +func (s *skillDefinitionService) Take(where ...interface{}) *models.SkillDefinition { + return repositories.SkillDefinitionRepository.Take(sqls.DB(), where...) +} + +func (s *skillDefinitionService) Find(cnd *sqls.Cnd) []models.SkillDefinition { + return repositories.SkillDefinitionRepository.Find(sqls.DB(), cnd) +} + +func (s *skillDefinitionService) FindOne(cnd *sqls.Cnd) *models.SkillDefinition { + return repositories.SkillDefinitionRepository.FindOne(sqls.DB(), cnd) +} + +func (s *skillDefinitionService) FindPageByParams(params *params.QueryParams) (list []models.SkillDefinition, paging *sqls.Paging) { + return repositories.SkillDefinitionRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *skillDefinitionService) FindPageByCnd(cnd *sqls.Cnd) (list []models.SkillDefinition, paging *sqls.Paging) { + return repositories.SkillDefinitionRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *skillDefinitionService) Count(cnd *sqls.Cnd) int64 { + return repositories.SkillDefinitionRepository.Count(sqls.DB(), cnd) +} + +func (s *skillDefinitionService) Create(t *models.SkillDefinition) error { + return repositories.SkillDefinitionRepository.Create(sqls.DB(), t) +} + +func (s *skillDefinitionService) Update(t *models.SkillDefinition) error { + return repositories.SkillDefinitionRepository.Update(sqls.DB(), t) +} + +func (s *skillDefinitionService) Updates(id int64, columns map[string]interface{}) error { + return repositories.SkillDefinitionRepository.Updates(sqls.DB(), id, columns) +} + +func (s *skillDefinitionService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.SkillDefinitionRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *skillDefinitionService) Delete(id int64) { + repositories.SkillDefinitionRepository.Delete(sqls.DB(), id) +} + +func (s *skillDefinitionService) GetByIDs(ids []int64) map[int64]models.SkillDefinition { + return repositories.SkillDefinitionRepository.GetByIDs(sqls.DB(), ids) +} + +func (s *skillDefinitionService) CreateSkillDefinition(req request.CreateSkillDefinitionRequest, operator *dto.AuthPrincipal) (*models.SkillDefinition, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + normalized, err := s.normalizeSkillDefinitionRequest(req) + if err != nil { + return nil, err + } + item := &models.SkillDefinition{ + Name: normalized.Name, + Description: normalized.Description, + Instruction: normalized.Instruction, + Examples: mustMarshalSkillStringArray(normalized.Examples), + ToolWhitelist: mustMarshalSkillStringArray(normalized.ToolWhitelist), + Status: enums.StatusOk, + Remark: normalized.Remark, + AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.SkillDefinitionRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return item, nil +} + +func (s *skillDefinitionService) UpdateSkillDefinition(req request.UpdateSkillDefinitionRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + if req.ID <= 0 { + return errorsx.InvalidParamI18n("error.e0052") + } + current := s.Get(req.ID) + if current == nil { + return errorsx.InvalidParamI18n("error.e0053") + } + normalized, err := s.normalizeSkillDefinitionRequest(req.CreateSkillDefinitionRequest) + if err != nil { + return err + } + return repositories.SkillDefinitionRepository.Updates(sqls.DB(), req.ID, map[string]any{ + "name": normalized.Name, + "description": normalized.Description, + "instruction": normalized.Instruction, + "examples": mustMarshalSkillStringArray(normalized.Examples), + "tool_whitelist": mustMarshalSkillStringArray(normalized.ToolWhitelist), + "remark": normalized.Remark, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) +} + +func (s *skillDefinitionService) normalizeSkillDefinitionRequest(req request.CreateSkillDefinitionRequest) (*request.CreateSkillDefinitionRequest, error) { + normalized := &request.CreateSkillDefinitionRequest{ + Name: strings.TrimSpace(req.Name), + Description: strings.TrimSpace(req.Description), + Instruction: strings.TrimSpace(req.Instruction), + Remark: strings.TrimSpace(req.Remark), + } + if normalized.Name == "" { + return nil, errorsx.InvalidParamI18n("error.e0055") + } + if normalized.Instruction == "" { + return nil, errorsx.InvalidParamI18n("error.e0207") + } + examples, err := normalizeSkillStringArray(req.Examples) + if err != nil { + return nil, err + } + toolWhitelist, err := normalizeSkillStringArray(req.ToolWhitelist) + if err != nil { + return nil, err + } + for _, toolCode := range toolWhitelist { + if err := ToolCatalogService.ValidateMCPToolCode(toolCode); err != nil { + return nil, err + } + } + normalized.Examples = examples + normalized.ToolWhitelist = toolWhitelist + return normalized, nil +} + +func normalizeSkillStringArray(input []string) ([]string, error) { + buf, err := json.Marshal(input) + if err != nil { + return nil, errorsx.InvalidParamI18n("error.e0031") + } + var ret []string + if err := json.Unmarshal(buf, &ret); err != nil { + return nil, errorsx.InvalidParamI18n("error.e0031") + } + normalized := make([]string, 0, len(ret)) + seen := make(map[string]struct{}, len(ret)) + for _, item := range ret { + item = strings.TrimSpace(item) + item = toolx.NormalizeToolCodeAlias(item) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + normalized = append(normalized, item) + } + return normalized, nil +} + +func mustMarshalSkillStringArray(input []string) string { + items, err := normalizeSkillStringArray(input) + if err != nil || len(items) == 0 { + return "[]" + } + buf, err := json.Marshal(items) + if err != nil { + return "[]" + } + return string(buf) +} diff --git a/internal/services/skill_runtime_service.go b/internal/services/skill_runtime_service.go new file mode 100644 index 0000000..8c914ac --- /dev/null +++ b/internal/services/skill_runtime_service.go @@ -0,0 +1,53 @@ +package services + +import ( + "context" + "fmt" + "strings" + + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/errorsx" +) + +var SkillRuntimeService = newSkillRuntimeService() +var SkillDebugRunHook func(ctx context.Context, req request.SkillDebugRunRequest) (*response.SkillDebugRunResponse, error) +var SkillDebugResumeHook func(ctx context.Context, req request.SkillDebugResumeRequest) (*response.SkillDebugRunResponse, error) + +func newSkillRuntimeService() *skillRuntimeService { + return &skillRuntimeService{} +} + +type skillRuntimeService struct{} + +func (s *skillRuntimeService) DebugRun(ctx context.Context, req request.SkillDebugRunRequest) (*response.SkillDebugRunResponse, error) { + if req.AIAgentID <= 0 { + return nil, errorsx.InvalidParamI18n("error.e0061") + } + if req.SkillDefinitionID <= 0 { + return nil, errorsx.InvalidParamI18n("error.e0071") + } + if strings.TrimSpace(req.UserMessage) == "" { + return nil, errorsx.InvalidParamI18n("error.e0078") + } + if SkillDebugRunHook == nil { + return nil, fmt.Errorf("skill debug runner is not initialized") + } + return SkillDebugRunHook(ctx, req) +} + +func (s *skillRuntimeService) DebugResume(ctx context.Context, req request.SkillDebugResumeRequest) (*response.SkillDebugRunResponse, error) { + if req.AIAgentID <= 0 { + return nil, errorsx.InvalidParamI18n("error.e0061") + } + if strings.TrimSpace(req.CheckPointID) == "" { + return nil, errorsx.InvalidParamI18n("error.e0063") + } + if strings.TrimSpace(req.UserMessage) == "" { + return nil, errorsx.InvalidParamI18n("error.e0078") + } + if SkillDebugResumeHook == nil { + return nil, fmt.Errorf("skill debug resume runner is not initialized") + } + return SkillDebugResumeHook(ctx, req) +} diff --git a/internal/services/slack_inbound_service.go b/internal/services/slack_inbound_service.go new file mode 100644 index 0000000..892ee3b --- /dev/null +++ b/internal/services/slack_inbound_service.go @@ -0,0 +1,180 @@ +package services + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "strconv" + "strings" + "time" + + "agent-desk/internal/pkg/config" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/slack" +) + +var SlackInboundService = newSlackInboundService() + +func newSlackInboundService() *slackInboundService { + return &slackInboundService{} +} + +type slackInboundService struct{} + +// HandleWebhook processes an incoming Events API event from Slack. +func (s *slackInboundService) HandleWebhook(ctx context.Context, channelID string, timestampHeader, signatureHeader string, rawPayload []byte) (*string, error) { + var event slack.EventCallback + if err := json.Unmarshal(rawPayload, &event); err != nil { + return nil, fmt.Errorf("unmarshal slack event failed: %w", err) + } + + // 1. URL Verification Challenge + if event.Type == "url_verification" { + return &event.Challenge, nil + } + + if event.Type != "event_callback" || event.Event == nil { + return nil, nil // Ignore non-message callbacks + } + + teamID := strings.TrimSpace(event.TeamID) + ev := event.Event + + if ev.BotID != "" || ev.Subtype == "bot_message" || strings.TrimSpace(ev.User) == "" { + return nil, nil // Ignore bot loops + } + + text := strings.TrimSpace(ev.Text) + if text == "" { + return nil, nil + } + + var channel *models.Channel + channelID = strings.TrimSpace(channelID) + if channelID != "" { + channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeSlack, enums.StatusOk) + } + if channel == nil && teamID != "" { + channel = ChannelService.Take("channel_type = ? AND status = ? AND (channel_id = ? OR config_json LIKE ?)", + enums.ChannelTypeSlack, enums.StatusOk, teamID, "%"+teamID+"%") + } + if channel == nil { + channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeSlack, enums.StatusOk) + } + if channel == nil { + return nil, errorsx.InvalidParam("slack channel not found or disabled") + } + + cfg, err := ChannelService.ParseSlackChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil { + return nil, errorsx.InvalidParam("slack channel config invalid") + } + + // Verify the Slack signing secret whenever one resolves for this channel. A + // delivery with no signature headers is rejected rather than waved through: + // Slack always signs once a signing secret exists, so a missing header means + // the sender is not Slack. + slackCfg := config.ResolveSlack(cfg.BotToken, cfg.SigningSecret) + if slackCfg.SigningSecret != "" { + if strings.TrimSpace(signatureHeader) == "" || strings.TrimSpace(timestampHeader) == "" { + slog.Warn("slack webhook rejected: missing signature headers", + "channel_id", channelID, "secret_source", slackSecretSource(cfg.SigningSecret, slackCfg)) + return nil, errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + if !verifySlackSignature(slackCfg.SigningSecret, timestampHeader, signatureHeader, rawPayload) { + slog.Warn("slack webhook rejected: signature verification failed", + "channel_id", channelID, "secret_source", slackSecretSource(cfg.SigningSecret, slackCfg)) + return nil, errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + } + + // 1. Resolve customer identity + senderID := strings.TrimSpace(ev.User) + externalUser := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceSlack, + ExternalID: senderID, + ExternalName: fmt.Sprintf("Slack User %s", senderID), + } + + // 2. Create or match Conversation + conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID) + if err != nil { + return nil, fmt.Errorf("create slack conversation failed: %w", err) + } + + // 3. Send message through MessageService + msgTS := strings.TrimSpace(ev.TS) + threadTS := strings.TrimSpace(ev.ThreadTS) + if threadTS == "" { + threadTS = msgTS + } + clientMsgID := fmt.Sprintf("slack_%s_%s", ev.Channel, msgTS) + payloadMap := map[string]any{ + "slack_channel": ev.Channel, + "slack_ts": msgTS, + "slack_thread_ts": threadTS, + "slack_user": senderID, + "slack_team": teamID, + } + payloadBytes, _ := json.Marshal(payloadMap) + + _, err = MessageService.SendCustomerMessage( + conversation.ID, + clientMsgID, + enums.IMMessageTypeText, + text, + string(payloadBytes), + externalUser, + ) + if err != nil { + return nil, fmt.Errorf("send customer message failed: %w", err) + } + + return nil, nil +} + +// slackSecretSource names where the verifying secret came from. A channel +// bound to a different Slack app than the deployment-wide one starts failing +// the moment the fallback secret appears, and this log field is the only way +// an operator can tell that apart from a spoofed delivery. +func slackSecretSource(channelSigningSecret string, resolved config.SlackConfig) string { + if strings.TrimSpace(channelSigningSecret) != "" { + return "channel" + } + if resolved.SigningSecret != "" { + return "deployment_fallback" + } + return "none" +} + +// slackTimestampTolerance is how far a request timestamp may drift from now. +// +// Slack's own verification guide requires rejecting anything older than five +// minutes. Without the check a captured request replays indefinitely: the +// signature covers the timestamp and the body, but nothing in it expires. +const slackTimestampTolerance = 5 * time.Minute + +func verifySlackSignature(signingSecret, timestampHeader, signatureHeader string, payload []byte) bool { + timestampHeader = strings.TrimSpace(timestampHeader) + timestamp, err := strconv.ParseInt(timestampHeader, 10, 64) + if err != nil || timestamp <= 0 { + return false + } + if drift := time.Since(time.Unix(timestamp, 0)); drift > slackTimestampTolerance || drift < -slackTimestampTolerance { + return false + } + + sigBasestring := fmt.Sprintf("v0:%s:%s", timestampHeader, string(payload)) + mac := hmac.New(sha256.New, []byte(signingSecret)) + mac.Write([]byte(sigBasestring)) + expectedSig := "v0=" + hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(strings.TrimSpace(signatureHeader)), []byte(expectedSig)) +} diff --git a/internal/services/slack_inbound_service_test.go b/internal/services/slack_inbound_service_test.go new file mode 100644 index 0000000..dc5e30c --- /dev/null +++ b/internal/services/slack_inbound_service_test.go @@ -0,0 +1,368 @@ +package services + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strconv" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +// slackTestSigningSecret is the signing secret the test channel is configured with. +const slackTestSigningSecret = "test_signing_secret_999" + +// signSlackPayload builds the X-Slack-Request-Timestamp and X-Slack-Signature +// headers Slack would send for this body right now. +func signSlackPayload(t *testing.T, secret string, payload []byte) (string, string) { + t.Helper() + return signSlackPayloadAt(t, secret, payload, time.Now()) +} + +func signSlackPayloadAt(t *testing.T, secret string, payload []byte, at time.Time) (string, string) { + t.Helper() + timestamp := strconv.FormatInt(at.Unix(), 10) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte("v0:" + timestamp + ":" + string(payload))) + return timestamp, "v0=" + hex.EncodeToString(mac.Sum(nil)) +} + +func setupSlackTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate( + &models.Channel{}, + &models.ChannelMessageOutbox{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &models.CustomerContact{}, + &models.Conversation{}, + &models.ConversationParticipant{}, + &models.ConversationReadState{}, + &models.ConversationInterrupt{}, + &models.ConversationEventLog{}, + &models.Message{}, + &models.AIAgent{}, + &models.User{}, + &models.Role{}, + &models.UserRole{}, + &models.Permission{}, + &models.RolePermission{}, + &models.UserPermission{}, + ); err != nil { + t.Fatalf("migrate slack test tables: %v", err) + } + sqls.SetDB(db) + return db +} + +func TestSlackInboundAndOutbound(t *testing.T) { + db := setupSlackTestDB(t) + + now := time.Now() + aiAgent := &models.AIAgent{ + Name: "Slack Bot Agent", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(aiAgent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + + slackConfig := dto.SlackChannelConfig{ + BotToken: "xoxb-test-bot-token-12345", + SigningSecret: slackTestSigningSecret, + TeamID: "T0123456789", + TeamName: "Acme Corp", + DefaultChannel: "C9876543210", + } + cfgBytes, _ := json.Marshal(slackConfig) + + channel := &models.Channel{ + ChannelType: enums.ChannelTypeSlack, + ChannelID: "T0123456789", + AIAgentID: aiAgent.ID, + AIAgentRolloutPercent: 100, + Name: "Slack Support Channel", + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create slack channel: %v", err) + } + + payload := `{ + "token": "verification_token", + "team_id": "T0123456789", + "api_app_id": "A01234567", + "type": "event_callback", + "event": { + "type": "message", + "user": "U12345678", + "text": "Help with API key generation", + "ts": "1725260000.000200", + "channel": "C9876543210", + "channel_type": "channel" + } + }` + + ctx := context.Background() + timestamp, signature := signSlackPayload(t, slackTestSigningSecret, []byte(payload)) + _, err := SlackInboundService.HandleWebhook(ctx, "", timestamp, signature, []byte(payload)) + if err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + + // Verify customer identity + identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceSlack). + Eq("external_id", "U12345678")) + if identity == nil { + t.Fatalf("expected customer identity for U12345678") + } + + // Verify conversation + conv := repositories.ConversationRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", identity.CustomerID). + Eq("channel_id", channel.ID)) + if conv == nil { + t.Fatalf("expected conversation to be created") + } + + // Verify message + msg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conv.ID). + Eq("sender_type", enums.IMSenderTypeCustomer)) + if msg == nil { + t.Fatalf("expected message to be created") + } + if msg.Content != "Help with API key generation" { + t.Fatalf("expected message content 'Help with API key generation', got %s", msg.Content) + } + + operator := &dto.AuthPrincipal{UserID: 1, Nickname: "Agent Joy"} + + // Test Outbound enqueue + replyMsg, err := MessageService.SendAIMessage(conv.ID, aiAgent.ID, "ai_slack_reply_1", enums.IMMessageTypeText, "You can generate your API key under Settings > API Keys.", "", operator) + if err != nil { + t.Fatalf("MessageService.SendAIMessage failed: %v", err) + } + + outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeSlack, replyMsg.ID) + if outbox == nil { + t.Fatalf("expected outbox entry for slack message") + } + if outbox.ChannelType != enums.ChannelTypeSlack { + t.Fatalf("expected outbox channel type 'slack', got %s", outbox.ChannelType) + } +} + +func seedSlackChannel(t *testing.T, db *gorm.DB, signingSecret string) *models.Channel { + t.Helper() + now := time.Now() + aiAgent := &models.AIAgent{ + Name: "Support AI", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(aiAgent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + cfgBytes, err := json.Marshal(dto.SlackChannelConfig{ + BotToken: "xoxb-test-bot-token-12345", + SigningSecret: signingSecret, + TeamID: "T0123456789", + }) + if err != nil { + t.Fatalf("marshal slack config: %v", err) + } + channel := &models.Channel{ + ChannelType: enums.ChannelTypeSlack, + ChannelID: "slack_sig_channel", + AIAgentID: aiAgent.ID, + AIAgentRolloutPercent: 100, + Name: "Slack Support", + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create slack channel: %v", err) + } + return channel +} + +func slackEventPayload(messageID string) []byte { + return []byte(`{ + "team_id": "T0123456789", + "type": "event_callback", + "event": { + "type": "message", + "user": "U_SIG_TEST", + "text": "signature probe", + "ts": "` + messageID + `", + "channel": "C9876543210", + "channel_type": "channel" + } + }`) +} + +func countSlackMessages(t *testing.T, db *gorm.DB, messageTS string) int64 { + t.Helper() + var count int64 + if err := db.Table("t_message").Where("client_msg_id = ?", "slack_C9876543210_"+messageTS).Count(&count).Error; err != nil { + t.Fatalf("count messages: %v", err) + } + return count +} + +// A channel with a signing secret configured must reject anything Slack did not +// sign. Accepting an unsigned delivery would let anyone who learns the webhook +// URL write into a workspace conversation and trigger paid AI replies. +func TestSlackInboundRejectsUnauthenticatedDelivery(t *testing.T) { + db := setupSlackTestDB(t) + channel := seedSlackChannel(t, db, slackTestSigningSecret) + + cases := []struct { + name string + messageTS string + timestamp string + signature string + }{ + {"no signature headers at all", "1725260000.000001", "", ""}, + {"signature without a timestamp", "1725260000.000002", "", "v0=deadbeef"}, + {"timestamp without a signature", "1725260000.000003", "1725260000", ""}, + {"wrong signature", "1725260000.000004", "1725260000", "v0=deadbeef"}, + {"non-numeric timestamp", "1725260000.000005", "not-a-timestamp", "v0=deadbeef"}, + } + + for _, tc := range cases { + payload := slackEventPayload(tc.messageTS) + _, err := SlackInboundService.HandleWebhook(context.Background(), channel.ChannelID, tc.timestamp, tc.signature, payload) + if err == nil { + t.Errorf("%s: expected the delivery to be rejected", tc.name) + continue + } + if countSlackMessages(t, db, tc.messageTS) != 0 { + t.Errorf("%s: a rejected delivery stored a message", tc.name) + } + } +} + +// Slack signs the timestamp and the body but nothing in the signature expires, so +// a captured request replays forever unless the timestamp is checked. Slack's own +// guide requires rejecting anything older than five minutes. +func TestSlackInboundRejectsReplayedTimestamp(t *testing.T) { + db := setupSlackTestDB(t) + channel := seedSlackChannel(t, db, slackTestSigningSecret) + + cases := []struct { + name string + age time.Duration + }{ + {"ten minutes old", 10 * time.Minute}, + {"one hour old", time.Hour}, + {"ten minutes in the future", -10 * time.Minute}, + } + + for i, tc := range cases { + messageTS := "1725260000.0000" + strconv.Itoa(10+i) + payload := slackEventPayload(messageTS) + // Correctly signed for its own timestamp, which is exactly what a replayed + // capture looks like on the wire. + timestamp, signature := signSlackPayloadAt(t, slackTestSigningSecret, payload, time.Now().Add(-tc.age)) + + if _, err := SlackInboundService.HandleWebhook(context.Background(), channel.ChannelID, timestamp, signature, payload); err == nil { + t.Errorf("%s: expected a stale timestamp to be rejected", tc.name) + } + if countSlackMessages(t, db, messageTS) != 0 { + t.Errorf("%s: a replayed delivery stored a message", tc.name) + } + } +} + +// A correctly signed, fresh delivery is still accepted, and one signed just +// inside the tolerance window is not rejected for clock drift. +func TestSlackInboundAcceptsFreshValidSignature(t *testing.T) { + db := setupSlackTestDB(t) + channel := seedSlackChannel(t, db, slackTestSigningSecret) + + cases := []struct { + name string + age time.Duration + }{ + {"signed now", 0}, + {"signed four minutes ago", 4 * time.Minute}, + } + + for i, tc := range cases { + messageTS := "1725260000.0000" + strconv.Itoa(20+i) + payload := slackEventPayload(messageTS) + timestamp, signature := signSlackPayloadAt(t, slackTestSigningSecret, payload, time.Now().Add(-tc.age)) + + if _, err := SlackInboundService.HandleWebhook(context.Background(), channel.ChannelID, timestamp, signature, payload); err != nil { + t.Fatalf("%s: HandleWebhook failed: %v", tc.name, err) + } + if countSlackMessages(t, db, messageTS) != 1 { + t.Errorf("%s: expected the signed message to be stored", tc.name) + } + } +} + +// The url_verification handshake has to be answered before any channel lookup or +// signature check, because Slack sends it once while the endpoint is being +// configured and will not retry. +func TestSlackInboundAnswersURLVerificationChallenge(t *testing.T) { + setupSlackTestDB(t) + + payload := []byte(`{"type":"url_verification","challenge":"challenge_token_abc","token":"verification_token"}`) + challenge, err := SlackInboundService.HandleWebhook(context.Background(), "", "", "", payload) + if err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + if challenge == nil || *challenge != "challenge_token_abc" { + t.Fatalf("challenge = %v, want challenge_token_abc", challenge) + } +} + +// A channel without its own signing secret inherits the deployment-wide one, +// so a single shared Slack app can serve every channel the desk supports. +func TestSlackInboundFallsBackToDeploymentSigningSecret(t *testing.T) { + db := setupSlackTestDB(t) + channel := seedSlackChannel(t, db, "") + config.SetCurrent(&config.Config{Slack: config.SlackConfig{SigningSecret: slackTestSigningSecret}}) + defer config.SetCurrent(nil) + + payload := slackEventPayload("1725260000.000300") + timestamp, signature := signSlackPayload(t, slackTestSigningSecret, payload) + if _, err := SlackInboundService.HandleWebhook(context.Background(), channel.ChannelID, timestamp, signature, payload); err != nil { + t.Fatalf("expected the deployment-secret-signed delivery to be accepted: %v", err) + } + if countSlackMessages(t, db, "1725260000.000300") != 1 { + t.Fatal("expected the delivery to be stored") + } +} diff --git a/internal/services/slack_outbound_service.go b/internal/services/slack_outbound_service.go new file mode 100644 index 0000000..036eb7b --- /dev/null +++ b/internal/services/slack_outbound_service.go @@ -0,0 +1,187 @@ +package services + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/services/storage" + "agent-desk/internal/slack" + + "github.com/mlogclub/simple/sqls" +) + +const ( + slackOutboxBatchSize = 20 + slackOutboxMaxRetry = 5 +) + +var SlackOutboundService = newSlackOutboundService() + +func newSlackOutboundService() *slackOutboundService { + return &slackOutboundService{} +} + +type slackOutboundService struct{} + +func (s *slackOutboundService) DispatchPendingOutbox() int { + return s.doDispatchPendingOutbox(slackOutboxBatchSize) +} + +func (s *slackOutboundService) doDispatchPendingOutbox(limit int) int { + if limit <= 0 { + limit = slackOutboxBatchSize + } + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeSlack, limit) + if len(items) == 0 { + return 0 + } + + successCount := 0 + for i := range items { + if err := s.processOutbox(items[i].ID); err != nil { + slog.Warn("process slack outbox failed", + "outbox_id", items[i].ID, + "error", err, + ) + continue + } + successCount++ + } + return successCount +} + +func (s *slackOutboundService) processOutbox(outboxID int64) error { + outbox := ChannelMessageOutboxService.Get(outboxID) + if outbox == nil { + return nil + } + if outbox.ChannelType != enums.ChannelTypeSlack { + return nil + } + if outbox.SendStatus == string(enums.ChannelMessageOutboxStatusSent) { + return nil + } + if outbox.NextRetryAt != nil && outbox.NextRetryAt.After(time.Now()) { + return nil + } + + if err := ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSending), + "updated_at": time.Now(), + }); err != nil { + return err + } + + message := MessageService.Get(outbox.MessageID) + if message == nil { + return s.markOutboxFailed(outbox, "message not found") + } + conversation := ConversationService.Get(outbox.ConversationID) + if conversation == nil { + return s.markOutboxFailed(outbox, "conversation not found") + } + + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.Status != enums.StatusOk { + return s.markOutboxFailed(outbox, "slack channel not found or disabled") + } + cfg, err := ChannelService.ParseSlackChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil { + return s.markOutboxFailed(outbox, "invalid slack channel config") + } + + // The channel's own bot token wins; the deployment-wide SLACK_BOT_TOKEN is + // the fallback for a single shared Slack app. + botToken := config.ResolveSlack(cfg.BotToken, cfg.SigningSecret).BotToken + if botToken == "" { + return s.markOutboxFailed(outbox, "slack bot token not configured") + } + + // Resolve target Slack Channel ID and Thread TS + var targetChannel string + var threadTS string + + lastCustomerMsg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conversation.ID). + Eq("sender_type", enums.IMSenderTypeCustomer). + Desc("id")) + if lastCustomerMsg != nil && lastCustomerMsg.Payload != "" { + var payloadMap map[string]any + if err := json.Unmarshal([]byte(lastCustomerMsg.Payload), &payloadMap); err == nil { + if ch, ok := payloadMap["slack_channel"].(string); ok && ch != "" { + targetChannel = ch + } + if ts, ok := payloadMap["slack_thread_ts"].(string); ok && ts != "" { + threadTS = ts + } + } + } + + if targetChannel == "" { + targetChannel = cfg.DefaultChannel + } + if targetChannel == "" { + return s.markOutboxFailed(outbox, "unable to resolve target slack channel") + } + + client := slack.NewClient(botToken) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + textToSend := message.Content + if message.MessageType == enums.IMMessageTypeImage || message.MessageType == enums.IMMessageTypeAttachment { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + fileURL := provider.GetSignedURL(assetPayload.StorageKey) + if fileURL != "" { + if textToSend != "" { + textToSend += "\n" + fileURL + } else { + textToSend = fileURL + } + } + } + } + } + } + + _, sendErr := client.PostMessage(ctx, targetChannel, textToSend, threadTS) + if sendErr != nil { + return s.markOutboxFailed(outbox, sendErr.Error()) + } + + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSent), + "sent_at": time.Now(), + "updated_at": time.Now(), + }) +} + +func (s *slackOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error { + if outbox == nil { + return nil + } + retryCount := outbox.RetryCount + 1 + status := string(enums.ChannelMessageOutboxStatusFailed) + if retryCount >= slackOutboxMaxRetry { + status = string(enums.ChannelMessageOutboxStatusIgnored) + } + nextRetryAt := time.Now().Add(time.Duration(retryCount*30) * time.Second) + + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": status, + "retry_count": retryCount, + "next_retry_at": &nextRetryAt, + "last_error": errMsg, + "updated_at": time.Now(), + }) +} diff --git a/internal/services/storage/dto.go b/internal/services/storage/dto.go new file mode 100644 index 0000000..9269ffc --- /dev/null +++ b/internal/services/storage/dto.go @@ -0,0 +1,23 @@ +package storage + +import ( + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" +) + +type UploadInfo struct { + Prefix string + Filename string + FileSize int64 + MimeType string + Principal *dto.AuthPrincipal +} + +type StoredFile struct { + Provider enums.AssetProvider + StorageKey string + URL string + Filename string + FileSize int64 + MimeType string +} diff --git a/internal/services/storage/local.go b/internal/services/storage/local.go new file mode 100644 index 0000000..d9f3cbd --- /dev/null +++ b/internal/services/storage/local.go @@ -0,0 +1,70 @@ +package storage + +import ( + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "io" + "os" + "path/filepath" + "strings" +) + +type LocalStorage struct { + cfg config.LocalStorageConfig +} + +func NewLocalStorage(cfg config.LocalStorageConfig) *LocalStorage { + return &LocalStorage{cfg: cfg} +} + +func (s *LocalStorage) ProviderType() enums.AssetProvider { + return enums.AssetProviderLocal +} + +func (s *LocalStorage) Upload(reader io.Reader, key string, info UploadInfo) (*StoredFile, error) { + fullPath := filepath.Join(s.cfg.Root, filepath.FromSlash(key)) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + return nil, err + } + + dst, err := os.Create(fullPath) + if err != nil { + return nil, err + } + defer dst.Close() + + if _, err := io.Copy(dst, reader); err != nil { + return nil, err + } + + return &StoredFile{ + Provider: enums.AssetProviderLocal, + StorageKey: key, + URL: strings.TrimRight(s.cfg.BaseURL, "/") + "/" + strings.TrimLeft(key, "/"), + Filename: info.Filename, + FileSize: info.FileSize, + MimeType: info.MimeType, + }, nil +} + +func (s *LocalStorage) GetURL(key string) string { + baseURL := strings.TrimRight(strings.TrimSpace(s.cfg.BaseURL), "/") + return strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(key, "/") +} + +func (s *LocalStorage) GetSignedURL(key string) string { + return s.GetURL(key) +} + +func (s *LocalStorage) Delete(key string) error { + fullPath := filepath.Join(s.cfg.Root, filepath.FromSlash(key)) + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + return nil + } + return os.Remove(fullPath) +} + +func (s *LocalStorage) Read(key string) (io.ReadCloser, error) { + fullPath := filepath.Join(s.cfg.Root, filepath.FromSlash(strings.TrimSpace(key))) + return os.Open(fullPath) +} diff --git a/internal/services/storage/oss.go b/internal/services/storage/oss.go new file mode 100644 index 0000000..e8daff9 --- /dev/null +++ b/internal/services/storage/oss.go @@ -0,0 +1,177 @@ +package storage + +import ( + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "fmt" + "io" + "net/url" + "strings" + + "github.com/aliyun/aliyun-oss-go-sdk/oss" + "github.com/mlogclub/simple/common/strs" +) + +type OSSStorage struct { + cfg config.OSSStorageConfig +} + +func NewOSSStorage(cfg config.OSSStorageConfig) *OSSStorage { + return &OSSStorage{cfg: cfg} +} + +func (s *OSSStorage) ProviderType() enums.AssetProvider { + return enums.AssetProviderOSS +} + +func (s *OSSStorage) Upload(reader io.Reader, key string, info UploadInfo) (*StoredFile, error) { + bucket, err := s.getBucket() + if err != nil { + return nil, err + } + + key = normalizeOSSKey(key) + options := []oss.Option{} + if mimeType := strings.TrimSpace(info.MimeType); mimeType != "" { + options = append(options, oss.ContentType(mimeType)) + } + if info.FileSize > 0 { + options = append(options, oss.ContentLength(info.FileSize)) + } + + if err := bucket.PutObject(key, reader, options...); err != nil { + return nil, err + } + + return &StoredFile{ + Provider: enums.AssetProviderOSS, + StorageKey: key, + URL: s.GetURL(key), + Filename: info.Filename, + FileSize: info.FileSize, + MimeType: info.MimeType, + }, nil +} + +func (s *OSSStorage) GetURL(key string) string { + key = normalizeOSSKey(key) + if key == "" { + return "" + } + + if baseURL := strings.TrimRight(strings.TrimSpace(s.cfg.BaseURL), "/"); baseURL != "" { + return baseURL + "/" + key + } + + if s.cfg.Private { + bucket, err := s.getBucket() + if err != nil { + return "" + } + signedURL, err := bucket.SignURL(key, oss.HTTPGet, s.signedURLExpire()) + if err == nil { + return signedURL + } + } + + return s.objectURL(key) +} + +func (s *OSSStorage) GetSignedURL(key string) string { + key = normalizeOSSKey(key) + if strs.IsBlank(key) { + return "" + } + if !s.cfg.Private { + return s.GetURL(key) + } + + bucket, err := s.getBucket() + if err != nil { + return "" + } + signedURL, err := bucket.SignURL(key, oss.HTTPGet, s.signedURLExpire()) + if err != nil { + return "" + } + return signedURL +} + +func (s *OSSStorage) Delete(key string) error { + bucket, err := s.getBucket() + if err != nil { + return err + } + return bucket.DeleteObject(normalizeOSSKey(key)) +} + +func (s *OSSStorage) Read(key string) (io.ReadCloser, error) { + bucket, err := s.getBucket() + if err != nil { + return nil, err + } + return bucket.GetObject(normalizeOSSKey(key)) +} + +func (s *OSSStorage) getBucket() (*oss.Bucket, error) { + if err := s.validate(); err != nil { + return nil, err + } + + client, err := oss.New( + s.endpoint(), + strings.TrimSpace(s.cfg.AccessKeyID), + strings.TrimSpace(s.cfg.AccessKeySecret), + ) + if err != nil { + return nil, err + } + return client.Bucket(strings.TrimSpace(s.cfg.Bucket)) +} + +func (s *OSSStorage) validate() error { + if strings.TrimSpace(s.cfg.Endpoint) == "" { + return errorsx.InvalidParamI18n("error.e0051") + } + if strings.TrimSpace(s.cfg.Bucket) == "" { + return errorsx.InvalidParamI18n("error.e0050") + } + if strings.TrimSpace(s.cfg.AccessKeyID) == "" { + return errorsx.InvalidParamI18n("error.e0048") + } + if strings.TrimSpace(s.cfg.AccessKeySecret) == "" { + return errorsx.InvalidParamI18n("error.e0049") + } + return nil +} + +func (s *OSSStorage) endpoint() string { + endpoint := strings.TrimSpace(s.cfg.Endpoint) + if endpoint == "" { + return "" + } + if strings.Contains(endpoint, "://") { + return endpoint + } + return "https://" + endpoint +} + +func (s *OSSStorage) objectURL(key string) string { + u, err := url.Parse(s.endpoint()) + if err != nil || u.Host == "" { + return "" + } + return fmt.Sprintf("%s://%s.%s/%s", u.Scheme, strings.TrimSpace(s.cfg.Bucket), u.Host, key) +} + +func (s *OSSStorage) signedURLExpire() int64 { + if s.cfg.SignedURLExpire > 0 { + return int64(s.cfg.SignedURLExpire) + } + return 600 +} + +func normalizeOSSKey(key string) string { + return strings.TrimLeft(strings.TrimSpace(key), "/") +} diff --git a/internal/services/storage/provider.go b/internal/services/storage/provider.go new file mode 100644 index 0000000..b52907c --- /dev/null +++ b/internal/services/storage/provider.go @@ -0,0 +1,50 @@ +package storage + +import ( + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "io" +) + +var providers = make(map[enums.AssetProvider]FileStorageProvider) + +type FileStorageProvider interface { + ProviderType() enums.AssetProvider + Upload(reader io.Reader, key string, info UploadInfo) (*StoredFile, error) + GetURL(key string) string + GetSignedURL(key string) string + Delete(key string) error + Read(key string) (io.ReadCloser, error) +} + +func GetDefault() (FileStorageProvider, error) { + return NewProvider(config.Current().Storage.Default) +} + +func GetProvider(providerType enums.AssetProvider) (FileStorageProvider, error) { + if provider, exists := providers[providerType]; exists { + return provider, nil + } + provider, err := NewProvider(providerType) + if err != nil { + return nil, err + } + + providers[providerType] = provider + + return provider, nil +} + +func NewProvider(provider enums.AssetProvider) (FileStorageProvider, error) { + cfg := config.Current().Storage + + switch provider { + case "", enums.AssetProviderLocal: + return NewLocalStorage(cfg.Local), nil + case enums.AssetProviderOSS: + return NewOSSStorage(cfg.OSS), nil + default: + return nil, errorsx.InvalidParamI18n("error.e0082") + } +} diff --git a/internal/services/storage/safety.go b/internal/services/storage/safety.go new file mode 100644 index 0000000..7b9cc34 --- /dev/null +++ b/internal/services/storage/safety.go @@ -0,0 +1,218 @@ +package storage + +import ( + "io" + "mime" + "net/http" + "path" + "strings" + "unicode/utf8" + + "agent-desk/internal/pkg/errorsx" +) + +const ( + // sniffLimit is the number of leading bytes net/http.DetectContentType inspects. + sniffLimit = 512 + // maxFilenameLength keeps a stored filename inside the column that holds it. + maxFilenameLength = 255 +) + +// previewableExtensions are the only stored file types a browser may render +// inline. Every other extension is forced to download. +// +// Assets are served from this application's own origin, so an inline response is +// same-origin content: a browser that navigates to it runs whatever the bytes +// ask for, in the same security context as the dashboard and the public support +// widget. Forcing a download for anything we have not vetted keeps that true +// only for media, which cannot touch the DOM. +var previewableExtensions = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, + ".bmp": true, ".ico": true, ".avif": true, ".tif": true, ".tiff": true, + ".heic": true, ".heif": true, + ".mp4": true, ".m4v": true, ".webm": true, ".mov": true, ".ogv": true, + ".mp3": true, ".m4a": true, ".wav": true, ".ogg": true, ".oga": true, + ".aac": true, ".flac": true, + ".pdf": true, +} + +// blockedExtensions are rejected at upload time. Each one names a document a +// browser executes or lays out instead of displaying, so accepting it would let +// an unauthenticated visitor plant a script that runs against this origin the +// moment a staff member opens the attachment link. +var blockedExtensions = map[string]bool{ + ".html": true, ".htm": true, ".shtml": true, ".xhtml": true, ".xht": true, + ".svg": true, ".svgz": true, + ".xml": true, ".xsl": true, ".xslt": true, ".xsd": true, ".wsdl": true, + ".js": true, ".mjs": true, ".cjs": true, + ".swf": true, + ".php": true, ".phtml": true, ".php3": true, ".php4": true, ".php5": true, + ".asp": true, ".aspx": true, ".jsp": true, ".jspx": true, ".cfm": true, + ".hta": true, ".htc": true, ".htaccess": true, +} + +// blockedMediaTypes are the payloads a browser renders as an active document. +// They are rejected regardless of the extension the client chose, so renaming a +// page to .png does not smuggle it past the extension check. +var blockedMediaTypes = map[string]bool{ + "text/html": true, + "application/xhtml+xml": true, + "image/svg+xml": true, + "text/xml": true, + "application/xml": true, + "application/xslt+xml": true, + "text/javascript": true, + "application/javascript": true, + "application/x-javascript": true, +} + +// blockedUploadI18nKey is returned to the uploader whenever the file-safety +// policy rejects a payload. It deliberately does not name the offending type. +const blockedUploadI18nKey = "error.e0348" + +// mediaTypeExtensions maps a MIME type to the extension it should be stored +// under. mime.ExtensionsByType cannot be used here: on Windows it consults the +// registry, so the same type resolves to a different extension than in +// production, and it happily hands back an extension that describes an active +// document. Types that must never become a servable document are absent, which +// leaves the caller with the inert .bin fallback. +var mediaTypeExtensions = map[string]string{ + // images + "image/jpeg": ".jpg", "image/jfif": ".jpg", "image/pjpeg": ".jpg", + "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", + "image/bmp": ".bmp", "image/tiff": ".tiff", "image/avif": ".avif", + "image/heic": ".heic", "image/heif": ".heif", "image/x-icon": ".ico", + "image/vnd.microsoft.icon": ".ico", + // documents + "application/pdf": ".pdf", "text/plain": ".txt", "text/csv": ".csv", + "text/markdown": ".md", "application/json": ".json", "application/rtf": ".rtf", + "application/msword": ".doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/vnd.ms-excel": ".xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "application/vnd.ms-powerpoint": ".ppt", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", + "application/vnd.oasis.opendocument.text": ".odt", + "application/vnd.oasis.opendocument.spreadsheet": ".ods", + "application/vnd.oasis.opendocument.presentation": ".odp", + // archives + "application/zip": ".zip", "application/x-zip-compressed": ".zip", + "application/x-rar-compressed": ".rar", "application/vnd.rar": ".rar", + "application/x-7z-compressed": ".7z", "application/x-tar": ".tar", + "application/gzip": ".gz", "application/x-bzip2": ".bz2", "application/x-xz": ".xz", + // audio + "audio/mpeg": ".mp3", "audio/mp4": ".m4a", "audio/x-m4a": ".m4a", + "audio/wav": ".wav", "audio/x-wav": ".wav", "audio/ogg": ".ogg", + "audio/aac": ".aac", "audio/flac": ".flac", "audio/webm": ".webm", + // video + "video/mp4": ".mp4", "video/webm": ".webm", "video/quicktime": ".mov", + "video/x-msvideo": ".avi", "video/x-matroska": ".mkv", "video/ogg": ".ogv", + "video/mpeg": ".mpeg", +} + +// safeExtensionForMediaType returns the extension a payload of this type should +// be stored under, or "" when the type is not one this origin is willing to +// serve as a document. +func safeExtensionForMediaType(mediaType string) string { + return mediaTypeExtensions[strings.ToLower(strings.TrimSpace(mediaType))] +} + +// IsPreviewableExtension reports whether a stored file may be rendered inline. +func IsPreviewableExtension(ext string) bool { + return previewableExtensions[normalizeExt(ext)] +} + +// IsBlockedExtension reports whether an extension names a browser-active document. +func IsBlockedExtension(ext string) bool { + return blockedExtensions[normalizeExt(ext)] +} + +// IsBlockedMediaType reports whether a MIME type describes a browser-active document. +func IsBlockedMediaType(mediaType string) bool { + parsed, _, err := mime.ParseMediaType(strings.TrimSpace(mediaType)) + if err != nil || parsed == "" { + return false + } + return blockedMediaTypes[strings.ToLower(parsed)] +} + +// SanitizeFilename reduces a client-supplied name to a bare basename. Uploads +// arrive from browsers, mobile SDKs and channel webhooks, all of which are free +// to send a full path, control characters or nothing at all; the result is stored +// on the asset and echoed back into message payloads and download links. +func SanitizeFilename(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + name = path.Base(strings.ReplaceAll(name, "\\", "/")) + name = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, name) + name = strings.TrimSpace(strings.TrimRight(name, ". ")) + if len(name) > maxFilenameLength { + ext := path.Ext(name) + if len(ext) > maxFilenameLength { + ext = "" + } + stem := strings.TrimSuffix(name, ext) + // Cut on a rune boundary: a partial multibyte character would not survive + // the round trip through a utf8mb4 column. + limit := maxFilenameLength - len(ext) + for limit > 0 && !utf8.RuneStart(stem[limit]) { + limit-- + } + name = stem[:limit] + ext + } + return name +} + +// SniffContentType reports what the leading bytes of a seekable payload actually +// are, then rewinds so the caller can still stream the whole file to storage. +// The Content-Type a client declares is a claim, not evidence. +func SniffContentType(src io.ReadSeeker) (string, error) { + head := make([]byte, sniffLimit) + read, err := io.ReadFull(src, head) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return "", err + } + if _, err := src.Seek(0, io.SeekStart); err != nil { + return "", err + } + return http.DetectContentType(head[:read]), nil +} + +// ValidateUpload applies the file-safety policy to an upload and returns the MIME +// type that should be recorded on the asset. +// +// sniffed comes from SniffContentType and wins whenever it identified something +// specific; declared is what the client claimed and is only trusted for the +// formats net/http cannot recognise, such as HEIC and most Office documents. +func ValidateUpload(filename, declared, sniffed string) (string, error) { + if IsBlockedExtension(path.Ext(filename)) { + return "", errorsx.InvalidParamI18n(blockedUploadI18nKey) + } + if IsBlockedMediaType(sniffed) || IsBlockedMediaType(declared) { + return "", errorsx.InvalidParamI18n(blockedUploadI18nKey) + } + + mediaType, _, _ := mime.ParseMediaType(sniffed) + if mediaType != "" && mediaType != "application/octet-stream" && !strings.HasPrefix(mediaType, "text/") { + return sniffed, nil + } + if declared != "" { + return declared, nil + } + return sniffed, nil +} + +func normalizeExt(ext string) string { + ext = strings.ToLower(strings.TrimSpace(ext)) + if ext != "" && !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + return ext +} diff --git a/internal/services/storage/safety_test.go b/internal/services/storage/safety_test.go new file mode 100644 index 0000000..78fafbe --- /dev/null +++ b/internal/services/storage/safety_test.go @@ -0,0 +1,268 @@ +package storage + +import ( + "bytes" + "io" + "net/http" + "strings" + "testing" + "unicode/utf8" +) + +var pngSignature = []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR") + +func TestValidateUploadBlocksBrowserActiveExtensions(t *testing.T) { + names := []string{ + "session-stealer.html", "page.HTM", "vector.svg", "sheet.xml", + "transform.xsl", "bundle.min.js", "app.mjs", "shell.php", + "report.asp", "index.jsp", "legacy.swf", "widget.htc", + } + for _, name := range names { + if _, err := ValidateUpload(name, "application/octet-stream", "application/octet-stream"); err == nil { + t.Errorf("ValidateUpload(%q) accepted a browser-active extension", name) + } + } +} + +func TestValidateUploadBlocksStoredXSSPayload(t *testing.T) { + payload := []byte(``) + sniffed, err := SniffContentType(bytes.NewReader(payload)) + if err != nil { + t.Fatalf("SniffContentType() error = %v", err) + } + if !IsBlockedMediaType(sniffed) { + t.Fatalf("expected the payload to sniff as a blocked media type, got %q", sniffed) + } + + if _, err := ValidateUpload("proof.html", "text/html", sniffed); err == nil { + t.Error("expected an honestly named HTML upload to be rejected") + } + // Renaming the payload is the actual attack: the extension looks like an + // image and the declared type agrees, so only the bytes can catch it. + if _, err := ValidateUpload("profile.png", "image/png", sniffed); err == nil { + t.Error("expected an HTML payload disguised as a PNG to be rejected") + } +} + +func TestValidateUploadBlocksSVGRegardlessOfDeclaredType(t *testing.T) { + payload := []byte(``) + sniffed, err := SniffContentType(bytes.NewReader(payload)) + if err != nil { + t.Fatalf("SniffContentType() error = %v", err) + } + if _, err := ValidateUpload("logo.svg", "image/svg+xml", sniffed); err == nil { + t.Error("expected an SVG upload to be rejected") + } + // An SVG that arrives without the .svg extension is still refused, because + // the declared type alone is enough to identify it as an active document. + if _, err := ValidateUpload("logo.png", "image/svg+xml", sniffed); err == nil { + t.Error("expected an SVG declared as an image to be rejected") + } +} + +func TestValidateUploadAcceptsOrdinarySupportFiles(t *testing.T) { + cases := []struct { + filename string + declared string + sniffed string + wantMime string + }{ + {"screenshot.png", "image/png", http.DetectContentType(pngSignature), "image/png"}, + // The client declared a generic type; the payload identified itself. + {"photo.jpg", "application/octet-stream", "image/jpeg", "image/jpeg"}, + // net/http cannot recognise HEIC, so the declared type has to stand in. + {"IMG_0001.heic", "image/heic", "application/octet-stream", "image/heic"}, + {"contract.pdf", "application/pdf", "application/pdf", "application/pdf"}, + {"export.csv", "text/csv", "text/plain; charset=utf-8", "text/csv"}, + {"notes.txt", "text/plain", "text/plain; charset=utf-8", "text/plain"}, + {"logs.zip", "application/zip", "application/zip", "application/zip"}, + {"build.apk", "application/vnd.android.package-archive", "application/octet-stream", "application/vnd.android.package-archive"}, + {"data.json", "application/json", "application/json", "application/json"}, + } + for _, tc := range cases { + got, err := ValidateUpload(tc.filename, tc.declared, tc.sniffed) + if err != nil { + t.Errorf("ValidateUpload(%q) error = %v", tc.filename, err) + continue + } + if got != tc.wantMime { + t.Errorf("ValidateUpload(%q) mime = %q want %q", tc.filename, got, tc.wantMime) + } + } +} + +func TestSanitizeFilename(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"report.pdf", "report.pdf"}, + {"../../etc/passwd", "passwd"}, + {`C:\Users\victim\Desktop\evil.html`, "evil.html"}, + {"/absolute/path/notes.txt", "notes.txt"}, + {"tab\tand\nnewline.log", "tabandnewline.log"}, + {"....", ""}, + {"", ""}, + {" spaced name.pdf ", "spaced name.pdf"}, + } + for _, tc := range cases { + if got := SanitizeFilename(tc.in); got != tc.want { + t.Errorf("SanitizeFilename(%q) = %q want %q", tc.in, got, tc.want) + } + } + + long := strings.Repeat("a", 300) + ".pdf" + got := SanitizeFilename(long) + if len(got) > 255 { + t.Errorf("SanitizeFilename() len = %d want <= 255", len(got)) + } + if !strings.HasSuffix(got, ".pdf") { + t.Errorf("SanitizeFilename() = %q, expected the extension to survive truncation", got) + } + + // A multibyte stem must not be cut in the middle of a rune. + wide := strings.Repeat("附件", 100) + ".pdf" + got = SanitizeFilename(wide) + if len(got) > 255 { + t.Errorf("SanitizeFilename() len = %d want <= 255", len(got)) + } + if !utf8.ValidString(got) { + t.Errorf("SanitizeFilename() = %q is not valid UTF-8", got) + } + if !strings.HasSuffix(got, ".pdf") { + t.Errorf("SanitizeFilename() = %q, expected the extension to survive truncation", got) + } + + // An extension longer than the whole budget cannot be preserved. + got = SanitizeFilename("a." + strings.Repeat("b", 300)) + if len(got) > 255 { + t.Errorf("SanitizeFilename() len = %d want <= 255", len(got)) + } + if !utf8.ValidString(got) { + t.Errorf("SanitizeFilename() = %q is not valid UTF-8", got) + } + + // A name made entirely of dots leaves nothing worth keeping. + if got = SanitizeFilename(strings.Repeat(".", 300)); got != "" { + t.Errorf("SanitizeFilename() of a dot-only name = %q want empty", got) + } +} + +func TestSniffContentTypeRewindsTheReader(t *testing.T) { + src := bytes.NewReader(pngSignature) + got, err := SniffContentType(src) + if err != nil { + t.Fatalf("SniffContentType() error = %v", err) + } + if got != "image/png" { + t.Fatalf("SniffContentType() = %q want image/png", got) + } + + // The caller still streams the whole payload to storage afterwards, so the + // reader must be back at the start. + rest, err := io.ReadAll(src) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + if !bytes.Equal(rest, pngSignature) { + t.Fatalf("reader was not rewound: got %d bytes want %d", len(rest), len(pngSignature)) + } +} + +func TestSniffContentTypeHandlesShortAndEmptyPayloads(t *testing.T) { + short := bytes.NewReader([]byte("hi")) + got, err := SniffContentType(short) + if err != nil { + t.Fatalf("SniffContentType() error = %v", err) + } + if !strings.HasPrefix(got, "text/plain") { + t.Fatalf("SniffContentType() = %q want a text/plain result", got) + } + rest, err := io.ReadAll(short) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + if string(rest) != "hi" { + t.Fatalf("reader was not rewound: got %q", rest) + } + + empty := bytes.NewReader(nil) + if _, err := SniffContentType(empty); err != nil { + t.Fatalf("SniffContentType() on an empty payload error = %v", err) + } +} + +func TestGenerateStorageKeyNeverUsesBlockedExtension(t *testing.T) { + cases := []struct { + info UploadInfo + want string + }{ + {UploadInfo{Filename: "evil.html", MimeType: "text/html"}, ".bin"}, + {UploadInfo{Filename: "evil.svg", MimeType: "image/svg+xml"}, ".bin"}, + // The extension is gone but the MIME type still names an active + // document, so the derived extension must be refused too. + {UploadInfo{Filename: "noext", MimeType: "text/html"}, ".bin"}, + {UploadInfo{Filename: "notes.txt", MimeType: "text/plain"}, ".txt"}, + {UploadInfo{Filename: "photo.png", MimeType: "image/png"}, ".png"}, + {UploadInfo{Filename: "unknown", MimeType: "application/octet-stream"}, ".bin"}, + } + for _, tc := range cases { + _, key := GenerateStorageKey(tc.info) + if !strings.HasSuffix(key, tc.want) { + t.Errorf("GenerateStorageKey(%+v) = %q, expected it to end in %q", tc.info, key, tc.want) + } + if strings.Contains(key, "..") { + t.Errorf("GenerateStorageKey(%+v) = %q contains a traversal segment", tc.info, key) + } + } +} + +func TestGetExtByMimeTypeIsPlatformIndependent(t *testing.T) { + // mime.ExtensionsByType reads the Windows registry, where text/html resolved + // to ".ehtml" and slipped past the block list. The mapping has to come from + // our own table so a storage key is the same on every platform, and so a + // media type that describes an active document maps to nothing at all. + cases := map[string]string{ + "text/html": "", + "application/xhtml+xml": "", + "image/svg+xml": "", + "text/xml": "", + "application/xml": "", + "application/javascript": "", + "text/javascript": "", + "image/jpeg": ".jpg", + "image/jfif": ".jpg", + "image/pjpeg": ".jpg", + "image/png": ".png", + "application/pdf": ".pdf", + "text/csv": ".csv", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + } + for mediaType, want := range cases { + if got := getExtByMimeType(mediaType); got != want { + t.Errorf("getExtByMimeType(%q) = %q want %q", mediaType, got, want) + } + } + + for mediaType := range blockedMediaTypes { + if got := getExtByMimeType(mediaType); got != "" { + t.Errorf("getExtByMimeType(%q) = %q, a blocked media type must not yield an extension", mediaType, got) + } + } +} + +func TestIsPreviewableExtension(t *testing.T) { + inline := []string{".png", ".JPG", ".jpeg", ".gif", ".webp", ".avif", ".mp4", ".webm", ".mp3", ".pdf"} + for _, ext := range inline { + if !IsPreviewableExtension(ext) { + t.Errorf("IsPreviewableExtension(%q) = false want true", ext) + } + } + + download := []string{".html", ".svg", ".xml", ".js", ".zip", ".txt", ".csv", ".docx", ".apk", ""} + for _, ext := range download { + if IsPreviewableExtension(ext) { + t.Errorf("IsPreviewableExtension(%q) = true want false", ext) + } + } +} diff --git a/internal/services/storage/utils.go b/internal/services/storage/utils.go new file mode 100644 index 0000000..16b7699 --- /dev/null +++ b/internal/services/storage/utils.go @@ -0,0 +1,78 @@ +package storage + +import ( + "mime" + "os" + "path/filepath" + "strings" + "time" + + "github.com/mlogclub/simple/common/strs" +) + +func GenerateStorageKey(info UploadInfo) (assetID string, storageKey string) { + assetID = strs.UUID() + var ( + env = currentAssetEnv() + prefix = normalizeAssetPrefix(info.Prefix) + datePath = time.Now().Format("2006/01/02") + ext = getExt(info) + ) + + storageKey = filepath.Join( + env, + prefix, + datePath, + assetID+ext, + ) + storageKey = strings.TrimLeft(filepath.ToSlash(storageKey), "/") + return +} + +func getExt(info UploadInfo) string { + ext := normalizeExt(filepath.Ext(strings.TrimSpace(info.Filename))) + if ext == "" || IsBlockedExtension(ext) { + ext = normalizeExt(getExtByMimeType(info.MimeType)) + } + if ext == "" || IsBlockedExtension(ext) { + // The stored extension decides the Content-Type this origin serves. A + // browser-active extension must never reach the key, and no extension at + // all would leave the server to sniff the payload and announce whatever + // it finds, so both cases settle on an inert binary type. + return ".bin" + } + return ext +} + +func getExtByMimeType(mimeType string) string { + if strs.IsBlank(mimeType) { + return "" + } + + mediaType, _, _ := mime.ParseMediaType(mimeType) + if mediaType == "" { + return "" + } + + return safeExtensionForMediaType(mediaType) +} + +func normalizeAssetPrefix(prefix string) string { + prefix = strings.TrimSpace(prefix) + prefix = strings.Trim(prefix, "/") + prefix = strings.ReplaceAll(prefix, "..", "") + prefix = filepath.ToSlash(prefix) + for strings.Contains(prefix, "//") { + prefix = strings.ReplaceAll(prefix, "//", "/") + } + return strings.Trim(prefix, "/") +} + +func currentAssetEnv() string { + for _, key := range []string{"APP_ENV", "GO_ENV"} { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + } + return "" +} diff --git a/internal/services/support_service.go b/internal/services/support_service.go new file mode 100644 index 0000000..170495d --- /dev/null +++ b/internal/services/support_service.go @@ -0,0 +1,740 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/repositories" + "encoding/json" + "regexp" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/mlogclub/simple/sqls" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +var supportSlugPattern = regexp.MustCompile(`^[a-z0-9-]+$`) + +const ( + supportUserContextKey = "supportUser" +) + +var SupportService = &supportService{} + +type supportService struct{} + +type CommentListResult struct { + Comments []models.Comment + Replies map[int64][]models.Comment + Paging *sqls.Paging +} + +func (s *supportService) RegisterUser(req request.SupportCustomerRegisterRequest, authCfg config.AuthConfig, clientIP, userAgent string) (*response.LoginResponse, error) { + name := strings.TrimSpace(req.Name) + email := normalizeSupportEmail(req.Email) + password := strings.TrimSpace(req.Password) + if name == "" || email == "" || len(password) < 8 { + return nil, errorsx.InvalidParam("name, email and at least 8 characters password are required") + } + if repositories.UserRepository.GetByUsername(sqls.DB(), email) != nil || repositories.UserRepository.GetByEmail(sqls.DB(), email) != nil { + return nil, errorsx.InvalidParam("email is already registered") + } + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + now := time.Now() + user := &models.User{ + Username: email, + Nickname: name, + Email: &email, + Password: string(passwordHash), + PasswordSalt: "", + UserType: enums.UserTypeUser, + Status: enums.StatusOk, + AuditFields: supportAuditFields(0, name, now), + } + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.UserRepository.Create(ctx.Tx, user); err != nil { + return err + } + return repositories.CustomerRepository.Create(ctx.Tx, &models.Customer{ + UserID: user.ID, + Name: name, + PrimaryEmail: email, + Status: enums.StatusOk, + AuditFields: supportAuditFields(user.ID, name, now), + }) + }); err != nil { + return nil, err + } + return AuthService.Login(request.LoginRequest{Username: email, Password: password}, authCfg, clientIP, userAgent) +} + +func (s *supportService) RequireSupportUser(ctx *gin.Context) (*dto.AuthPrincipal, error) { + if principal := s.GetSupportUser(ctx); principal != nil { + return principal, nil + } + principal, err := AuthService.Authenticate(ctx) + if err != nil { + return nil, err + } + ctx.Set(supportUserContextKey, principal) + return principal, nil +} + +func (s *supportService) GetSupportUser(ctx *gin.Context) *dto.AuthPrincipal { + if ctx == nil { + return nil + } + value, _ := ctx.Get(supportUserContextKey) + if principal, ok := value.(*dto.AuthPrincipal); ok { + return principal + } + return nil +} + +func (s *supportService) FindDocPages(cnd *sqls.Cnd) []models.DocPage { + return repositories.DocPageRepository.Find(sqls.DB(), cnd) +} + +func (s *supportService) FindPublicDocNavigation() []models.DocPage { + return repositories.DocPageRepository.FindNavigationItems(sqls.DB(), sqls.NewCnd().Eq("status", enums.DocPageStatusPublished).Asc("sort_no").Asc("id")) +} + +func (s *supportService) FindDocPageByID(id int64) *models.DocPage { + return repositories.DocPageRepository.Get(sqls.DB(), id) +} + +func (s *supportService) FindDocPagePage(cnd *sqls.Cnd) ([]models.DocPage, *sqls.Paging) { + return repositories.DocPageRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *supportService) SortDocPages(req request.SortDocPagesRequest) error { + pages := repositories.DocPageRepository.Find( + sqls.DB(), + sqls.NewCnd().Eq("parent_id", req.ParentID).Asc("sort_no").Asc("id"), + ) + if len(req.IDs) != len(pages) { + return errorsx.InvalidParamI18n("error.docPage.sortScopeMismatch") + } + existing := make(map[int64]struct{}, len(pages)) + for _, page := range pages { + existing[page.ID] = struct{}{} + } + seen := make(map[int64]struct{}, len(req.IDs)) + for _, id := range req.IDs { + if _, ok := existing[id]; !ok { + return errorsx.InvalidParamI18n("error.docPage.sortScopeMismatch") + } + if _, ok := seen[id]; ok { + return errorsx.InvalidParamI18n("error.docPage.sortDuplicate") + } + seen[id] = struct{}{} + } + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + for sortNo, id := range req.IDs { + if err := repositories.DocPageRepository.UpdateSort(ctx.Tx, id, sortNo); err != nil { + return err + } + } + return nil + }) +} + +func (s *supportService) validateDocPageParent(id, parentID int64) error { + if parentID == 0 { + return nil + } + if id > 0 && id == parentID { + return errorsx.InvalidParam("page cannot be its own parent") + } + parent := repositories.DocPageRepository.Get(sqls.DB(), parentID) + if parent == nil { + return errorsx.InvalidParam("parent page not found") + } + depth := 2 + visited := map[int64]bool{parentID: true} + for parent.ParentID > 0 { + if parent.ParentID == id || visited[parent.ParentID] { + return errorsx.InvalidParam("page hierarchy contains a cycle") + } + visited[parent.ParentID] = true + parent = repositories.DocPageRepository.Get(sqls.DB(), parent.ParentID) + if parent == nil { + return errorsx.InvalidParam("parent page not found") + } + depth++ + if depth > 4 { + return errorsx.InvalidParam("page hierarchy supports at most four levels") + } + } + return nil +} + +func (s *supportService) SaveDocPage(req request.SaveDocPageRequest, operator *dto.AuthPrincipal) (*models.DocPage, error) { + title, slug := strings.TrimSpace(req.Title), normalizeSupportSlug(req.Slug) + if title == "" || slug == "" { + return nil, errorsx.InvalidParam("title and slug are required") + } + if err := validateSupportSlug(slug); err != nil { + return nil, err + } + if err := s.validateDocPageParent(req.ID, req.ParentID); err != nil { + return nil, err + } + if existing := repositories.DocPageRepository.GetByParentIDAndSlug(sqls.DB(), req.ParentID, slug); existing != nil && existing.ID != req.ID { + return nil, errorsx.InvalidParam("page slug already exists") + } + status := normalizeDocPageStatus(req.Status) + var current *models.DocPage + if req.ID > 0 { + current = repositories.DocPageRepository.Get(sqls.DB(), req.ID) + if current == nil { + return nil, errorsx.InvalidParam("page not found") + } + // Publication status is changed only by ChangeDocPageStatus so saving + // content cannot accidentally publish or withdraw a public document. + status = current.Status + } + if status == enums.DocPageStatusPublished && req.ParentID > 0 { + parent := repositories.DocPageRepository.Get(sqls.DB(), req.ParentID) + if parent == nil || parent.Status != enums.DocPageStatusPublished { + return nil, errorsx.InvalidParam("publish the parent page first") + } + } + if req.ID > 0 && status != enums.DocPageStatusPublished { + publishedChildren := repositories.DocPageRepository.Find(sqls.DB(), sqls.NewCnd().Eq("parent_id", req.ID).Eq("status", enums.DocPageStatusPublished).Page(1, 1)) + if len(publishedChildren) > 0 { + return nil, errorsx.InvalidParam("unpublish child pages first") + } + } + tags, _ := json.Marshal(normalizeTags(req.Tags)) + now := time.Now() + publishedAt := (*time.Time)(nil) + if status == enums.DocPageStatusPublished && req.ID == 0 { + publishedAt = &now + } + columns := map[string]any{"parent_id": req.ParentID, "title": title, "slug": slug, "summary": strings.TrimSpace(req.Summary), "content_type": normalizeContentType(req.ContentType), "content": req.Content, "cover_url": strings.TrimSpace(req.CoverURL), "tags_json": string(tags), "status": status, "sort_no": req.SortNo, "remark": strings.TrimSpace(req.Remark), "updated_at": now, "update_user_id": operator.UserID, "update_user_name": operator.Username} + if publishedAt != nil { + columns["published_at"] = publishedAt + } + if req.ID > 0 { + if err := repositories.DocPageRepository.Updates(sqls.DB(), req.ID, columns); err != nil { + return nil, err + } + return repositories.DocPageRepository.Get(sqls.DB(), req.ID), nil + } + item := &models.DocPage{ParentID: req.ParentID, Title: title, Slug: slug, Summary: strings.TrimSpace(req.Summary), ContentType: normalizeContentType(req.ContentType), Content: req.Content, CoverURL: strings.TrimSpace(req.CoverURL), TagsJSON: string(tags), Status: status, SortNo: req.SortNo, PublishedAt: publishedAt, Remark: strings.TrimSpace(req.Remark), AuditFields: auditFieldsFromOperator(operator, now)} + if err := repositories.DocPageRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return item, nil +} + +func (s *supportService) UpdateDocPageSettings(req request.UpdateDocPageSettingsRequest, operator *dto.AuthPrincipal) (*models.DocPage, error) { + item := repositories.DocPageRepository.Get(sqls.DB(), req.ID) + if item == nil { + return nil, errorsx.InvalidParamI18n("error.docPage.notFound") + } + slug := normalizeSupportSlug(req.Slug) + if slug == "" { + return nil, errorsx.InvalidParamI18n("error.docPage.slugRequired") + } + if err := validateSupportSlug(slug); err != nil { + return nil, err + } + if err := s.validateDocPageParent(item.ID, req.ParentID); err != nil { + return nil, err + } + if existing := repositories.DocPageRepository.GetByParentIDAndSlug(sqls.DB(), req.ParentID, slug); existing != nil && existing.ID != item.ID { + return nil, errorsx.InvalidParamI18n("error.docPage.slugDuplicate") + } + if item.Status == enums.DocPageStatusPublished && req.ParentID > 0 { + parent := repositories.DocPageRepository.Get(sqls.DB(), req.ParentID) + if parent == nil || parent.Status != enums.DocPageStatusPublished { + return nil, errorsx.InvalidParamI18n("error.docPage.publishParentFirst") + } + } + sortNo := item.SortNo + if req.ParentID != item.ParentID { + sortNo = len(repositories.DocPageRepository.Find(sqls.DB(), sqls.NewCnd().Eq("parent_id", req.ParentID))) + } + now := time.Now() + columns := map[string]any{ + "parent_id": req.ParentID, + "slug": slug, + "summary": strings.TrimSpace(req.Summary), + "sort_no": sortNo, + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + } + if err := repositories.DocPageRepository.Updates(sqls.DB(), item.ID, columns); err != nil { + return nil, err + } + return repositories.DocPageRepository.Get(sqls.DB(), item.ID), nil +} + +func (s *supportService) ChangeDocPageStatus(req request.ChangeDocPageStatusRequest, operator *dto.AuthPrincipal) (*models.DocPage, error) { + item := repositories.DocPageRepository.Get(sqls.DB(), req.ID) + if item == nil { + return nil, errorsx.InvalidParamI18n("error.docPage.notFound") + } + status := req.Status + if status != enums.DocPageStatusDraft && status != enums.DocPageStatusPublished && status != enums.DocPageStatusHidden { + return nil, errorsx.InvalidParamI18n("error.docPage.invalidStatus") + } + if status == item.Status { + return item, nil + } + if status == enums.DocPageStatusPublished && item.ParentID > 0 { + parent := repositories.DocPageRepository.Get(sqls.DB(), item.ParentID) + if parent == nil || parent.Status != enums.DocPageStatusPublished { + return nil, errorsx.InvalidParamI18n("error.docPage.publishParentFirst") + } + } + if status != enums.DocPageStatusPublished { + publishedChildren := repositories.DocPageRepository.Find(sqls.DB(), sqls.NewCnd().Eq("parent_id", item.ID).Eq("status", enums.DocPageStatusPublished).Page(1, 1)) + if len(publishedChildren) > 0 { + return nil, errorsx.InvalidParamI18n("error.docPage.unpublishChildrenFirst") + } + } + now := time.Now() + columns := map[string]any{ + "status": status, + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + } + if status == enums.DocPageStatusPublished { + columns["published_at"] = now + } + if err := repositories.DocPageRepository.Updates(sqls.DB(), item.ID, columns); err != nil { + return nil, err + } + return repositories.DocPageRepository.Get(sqls.DB(), item.ID), nil +} + +func (s *supportService) DeleteDocPage(id int64) error { + if repositories.DocPageRepository.Get(sqls.DB(), id) == nil { + return errorsx.InvalidParam("page not found") + } + children := repositories.DocPageRepository.Find(sqls.DB(), sqls.NewCnd().Eq("parent_id", id).Page(1, 1)) + if len(children) > 0 { + return errorsx.InvalidParam("page still has child pages") + } + return repositories.DocPageRepository.Delete(sqls.DB(), id) +} + +func (s *supportService) SaveCategory(req request.SaveCategoryRequest, operator *dto.AuthPrincipal) (*models.Category, error) { + name, slug := strings.TrimSpace(req.Name), normalizeSupportSlug(req.Slug) + if name == "" || slug == "" { + return nil, errorsx.InvalidParam("name and slug are required") + } + if err := validateSupportSlug(slug); err != nil { + return nil, err + } + now := time.Now() + if req.ID > 0 { + if repositories.CategoryRepository.Get(sqls.DB(), req.ID) == nil { + return nil, errorsx.InvalidParam("category not found") + } + if err := repositories.CategoryRepository.Updates(sqls.DB(), req.ID, map[string]any{"name": name, "slug": slug, "description": strings.TrimSpace(req.Description), "status": req.Status, "remark": strings.TrimSpace(req.Remark), "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": now}); err != nil { + return nil, err + } + return repositories.CategoryRepository.Get(sqls.DB(), req.ID), nil + } + item := &models.Category{Name: name, Slug: slug, Description: strings.TrimSpace(req.Description), Status: req.Status, Remark: strings.TrimSpace(req.Remark), AuditFields: auditFieldsFromOperator(operator, now)} + if item.Status == 0 { + item.Status = enums.StatusOk + } + if err := repositories.CategoryRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return item, nil +} + +func (s *supportService) UpdateCategorySort(ids []int64) error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + for i, id := range ids { + if err := repositories.CategoryRepository.UpdateColumn(ctx.Tx, id, "sort_no", i); err != nil { + return err + } + } + return nil + }) +} + +func (s *supportService) DeleteCategory(id int64) error { + if repositories.CategoryRepository.Get(sqls.DB(), id) == nil { + return errorsx.InvalidParam("category not found") + } + _, paging := repositories.PostRepository.FindPageByCnd( + sqls.DB(), + sqls.NewCnd().Eq("category_id", id).Page(1, 1), + ) + if paging.Total > 0 { + return errorsx.InvalidParam("category is still used by posts") + } + return repositories.CategoryRepository.Delete(sqls.DB(), id) +} + +func (s *supportService) CreatePost(req request.CreatePostRequest, principal *dto.AuthPrincipal) (*models.Post, error) { + title, content := strings.TrimSpace(req.Title), strings.TrimSpace(req.Content) + if principal == nil || principal.UserID <= 0 { + return nil, errorsx.Unauthorized("login is required") + } + if title == "" || content == "" { + return nil, errorsx.InvalidParam("title and content are required") + } + tags, _ := json.Marshal(normalizeTags(req.Tags)) + now := time.Now() + item := &models.Post{CategoryID: req.CategoryID, UserID: principal.UserID, Title: title, ContentType: normalizeContentType(req.ContentType), Content: content, TagsJSON: string(tags), Status: enums.PostStatusNormal, AuditFields: supportAuditFields(principal.UserID, supportPrincipalName(principal), now)} + if err := repositories.PostRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return item, nil +} + +func (s *supportService) UpdatePost(req request.UpdatePostRequest, principal *dto.AuthPrincipal) error { + item := repositories.PostRepository.Get(sqls.DB(), req.ID) + if item == nil { + return errorsx.InvalidParam("post not found") + } + if principal == nil || item.UserID != principal.UserID { + return errorsx.Forbidden("only the post owner can update it") + } + if item.Status == enums.PostStatusResolved || item.Status == enums.PostStatusClosed { + return errorsx.BusinessError(1, "resolved or closed post cannot be edited") + } + tags, _ := json.Marshal(normalizeTags(req.Tags)) + return repositories.PostRepository.Updates(sqls.DB(), req.ID, map[string]any{"category_id": req.CategoryID, "title": strings.TrimSpace(req.Title), "content_type": normalizeContentType(req.ContentType), "content": strings.TrimSpace(req.Content), "tags_json": string(tags), "update_user_id": principal.UserID, "update_user_name": supportPrincipalName(principal), "updated_at": time.Now()}) +} + +func (s *supportService) CreateCustomerComment(req request.CreateCommentRequest, principal *dto.AuthPrincipal) (*models.Comment, error) { + if principal == nil { + return nil, errorsx.Unauthorized("login is required") + } + return s.createComment(req.PostID, req.ParentID, normalizeContentType(req.ContentType), strings.TrimSpace(req.Content), supportAuthorType(principal), principal.UserID, supportPrincipalName(principal)) +} + +func (s *supportService) CreateUserComment(req request.CreateCommentRequest, operator *dto.AuthPrincipal) (*models.Comment, error) { + if operator == nil { + return nil, errorsx.Unauthorized("login is required") + } + return s.createComment(req.PostID, req.ParentID, normalizeContentType(req.ContentType), strings.TrimSpace(req.Content), supportAuthorType(operator), operator.UserID, supportPrincipalName(operator)) +} + +func (s *supportService) createComment(postID, parentID int64, contentType, content string, authorType enums.CommentAuthorType, authorID int64, authorName string) (*models.Comment, error) { + if content == "" { + return nil, errorsx.InvalidParam("comment content is required") + } + post := repositories.PostRepository.Get(sqls.DB(), postID) + if post == nil || post.Status == enums.PostStatusDeleted || post.Status == enums.PostStatusClosed { + return nil, errorsx.InvalidParam("post is unavailable") + } + var parent *models.Comment + if parentID > 0 { + parent = repositories.CommentRepository.Get(sqls.DB(), parentID) + if parent == nil || parent.PostID != postID || parent.ParentID != 0 || parent.Status != enums.CommentStatusNormal { + return nil, errorsx.InvalidParam("parent comment is unavailable") + } + } + now := time.Now() + comment := &models.Comment{PostID: postID, ParentID: parentID, AuthorType: authorType, AuthorID: authorID, ContentType: contentType, Content: content, Status: enums.CommentStatusNormal, AuditFields: supportAuditFields(authorID, authorName, now)} + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.CommentRepository.Create(ctx.Tx, comment); err != nil { + return err + } + if parent != nil { + if err := repositories.CommentRepository.UpdateColumn(ctx.Tx, parent.ID, "reply_count", gorm.Expr("reply_count + ?", 1)); err != nil { + return err + } + } + return repositories.PostRepository.Updates(ctx.Tx, postID, map[string]any{"comment_count": gorm.Expr("comment_count + ?", 1), "last_commented_at": now, "last_comment_user_type": authorType, "last_comment_user_id": authorID, "updated_at": now}) + }); err != nil { + return nil, err + } + return comment, nil +} + +func (s *supportService) ListPostComments(postID, parentID int64, sort string, page, limit int) (*CommentListResult, error) { + post := repositories.PostRepository.Get(sqls.DB(), postID) + if post == nil || post.Status == enums.PostStatusHidden || post.Status == enums.PostStatusDeleted { + return nil, errorsx.InvalidParam("post not found") + } + if parentID > 0 { + parent := repositories.CommentRepository.Get(sqls.DB(), parentID) + if parent == nil || parent.PostID != postID || parent.Status == enums.CommentStatusHidden { + return nil, errorsx.InvalidParam("parent comment not found") + } + } + if page <= 0 { + page = 1 + } + if limit <= 0 || limit > 50 { + limit = 20 + } + cnd := sqls.NewCnd().Eq("post_id", postID).Eq("parent_id", parentID).Where("status IN ?", []enums.CommentStatus{enums.CommentStatusNormal, enums.CommentStatusDeleted}).Page(page, limit) + if parentID > 0 { + cnd.Asc("id") + } else { + switch sort { + case "latest": + cnd.Desc("id") + case "hot": + cnd.Desc("reaction_count").Desc("reply_count").Desc("id") + default: + cnd.Desc("is_accepted").Asc("id") + } + } + comments, paging := repositories.CommentRepository.FindPageByCnd(sqls.DB(), cnd) + result := &CommentListResult{Comments: comments, Replies: map[int64][]models.Comment{}, Paging: paging} + if parentID > 0 || len(comments) == 0 { + return result, nil + } + for _, comment := range comments { + if comment.ReplyCount <= 0 { + continue + } + result.Replies[comment.ID] = repositories.CommentRepository.Find(sqls.DB(), sqls.NewCnd().Eq("post_id", postID).Eq("parent_id", comment.ID).Where("status IN ?", []enums.CommentStatus{enums.CommentStatusNormal, enums.CommentStatusDeleted}).Asc("id").Page(1, 2)) + } + return result, nil +} + +func (s *supportService) UpdateComment(req request.UpdateCommentRequest, principal *dto.AuthPrincipal) error { + comment := repositories.CommentRepository.Get(sqls.DB(), req.ID) + if comment == nil || comment.Status != enums.CommentStatusNormal { + return errorsx.InvalidParam("comment not found") + } + if principal == nil || comment.AuthorID != principal.UserID { + return errorsx.Forbidden("only the comment author can update it") + } + content := strings.TrimSpace(req.Content) + if content == "" { + return errorsx.InvalidParam("comment content is required") + } + return repositories.CommentRepository.Updates(sqls.DB(), comment.ID, map[string]any{"content_type": normalizeContentType(req.ContentType), "content": content, "update_user_id": principal.UserID, "update_user_name": supportPrincipalName(principal), "updated_at": time.Now()}) +} + +func (s *supportService) DeleteComment(commentID int64, principal *dto.AuthPrincipal) error { + comment := repositories.CommentRepository.Get(sqls.DB(), commentID) + if comment == nil || comment.Status == enums.CommentStatusDeleted { + return errorsx.InvalidParam("comment not found") + } + if principal == nil || comment.AuthorID != principal.UserID { + return errorsx.Forbidden("only the comment author can delete it") + } + now := time.Now() + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.CommentRepository.Updates(ctx.Tx, comment.ID, map[string]any{"status": enums.CommentStatusDeleted, "is_accepted": false, "updated_at": now, "update_user_id": principal.UserID, "update_user_name": supportPrincipalName(principal)}); err != nil { + return err + } + columns := map[string]any{"updated_at": now} + if comment.IsAccepted { + columns["accepted_comment_id"] = int64(0) + columns["status"] = enums.PostStatusNormal + } + return repositories.PostRepository.Updates(ctx.Tx, comment.PostID, columns) + }) +} + +func (s *supportService) ReportComment(req request.ReportCommentRequest, principal *dto.AuthPrincipal) error { + if principal == nil { + return errorsx.Unauthorized("login is required") + } + comment := repositories.CommentRepository.Get(sqls.DB(), req.ID) + if comment == nil || comment.Status != enums.CommentStatusNormal { + return errorsx.InvalidParam("comment not found") + } + if existing := repositories.CommentReportRepository.Get(sqls.DB(), comment.ID, principal.UserID); existing != nil { + return nil + } + now := time.Now() + reason := strings.TrimSpace(req.Reason) + if len([]rune(reason)) > 255 { + reason = string([]rune(reason)[:255]) + } + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.CommentReportRepository.Create(ctx.Tx, &models.CommentReport{CommentID: comment.ID, UserID: principal.UserID, Reason: reason, CreatedAt: now}); err != nil { + return err + } + return repositories.CommentRepository.UpdateColumn(ctx.Tx, comment.ID, "report_count", gorm.Expr("report_count + ?", 1)) + }) +} + +func (s *supportService) AcceptComment(req request.AcceptCommentRequest, principal *dto.AuthPrincipal, operator *dto.AuthPrincipal) error { + post := repositories.PostRepository.Get(sqls.DB(), req.PostID) + comment := repositories.CommentRepository.Get(sqls.DB(), req.CommentID) + if post == nil || comment == nil || comment.PostID != post.ID { + return errorsx.InvalidParam("post or comment not found") + } + if comment.ParentID != 0 || comment.Status != enums.CommentStatusNormal { + return errorsx.InvalidParam("only top-level normal comments can be accepted") + } + if operator == nil { + if principal == nil || post.UserID != principal.UserID { + return errorsx.Forbidden("only owner or admin can accept the best comment") + } + } + now := time.Now() + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := ctx.Tx.Model(&models.Comment{}).Where("post_id = ?", post.ID).Update("is_accepted", false).Error; err != nil { + return err + } + if err := repositories.CommentRepository.Updates(ctx.Tx, comment.ID, map[string]any{"is_accepted": true, "updated_at": now}); err != nil { + return err + } + return repositories.PostRepository.Updates(ctx.Tx, post.ID, map[string]any{"accepted_comment_id": comment.ID, "status": enums.PostStatusResolved, "updated_at": now}) + }) +} + +func (s *supportService) ToggleReaction(targetType enums.ReactionTarget, targetID int64, reactionType enums.ReactionType, principal *dto.AuthPrincipal) error { + if principal == nil { + return errorsx.Unauthorized("login is required") + } + if reactionType == "" { + reactionType = enums.ReactionTypeLike + } + if reactionType != enums.ReactionTypeLike { + return errorsx.InvalidParam("reaction type is unsupported") + } + updateReactionCount := func(db *gorm.DB, delta int) error { + switch targetType { + case enums.ReactionTargetPost: + if repositories.PostRepository.Get(sqls.DB(), targetID) == nil { + return errorsx.InvalidParam("post not found") + } + return repositories.PostRepository.UpdateColumn(db, targetID, "reaction_count", gorm.Expr("reaction_count + ?", delta)) + case enums.ReactionTargetComment: + if repositories.CommentRepository.Get(sqls.DB(), targetID) == nil { + return errorsx.InvalidParam("comment not found") + } + return repositories.CommentRepository.Updates(db, targetID, map[string]any{"reaction_count": gorm.Expr("reaction_count + ?", delta), "updated_at": time.Now()}) + default: + return errorsx.InvalidParam("reaction target type is unsupported") + } + } + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + existing := repositories.ReactionRepository.Get(ctx.Tx, string(targetType), targetID, principal.UserID, string(reactionType)) + delta := 1 + if existing != nil { + delta = -1 + if err := repositories.ReactionRepository.Delete(ctx.Tx, string(targetType), targetID, principal.UserID, string(reactionType)); err != nil { + return err + } + } else { + now := time.Now() + reaction := &models.Reaction{TargetType: targetType, TargetID: targetID, UserID: principal.UserID, ReactionType: reactionType, CreatedAt: now, UpdatedAt: now} + if err := repositories.ReactionRepository.Create(ctx.Tx, reaction); err != nil { + return err + } + } + return updateReactionCount(ctx.Tx, delta) + }) +} + +func (s *supportService) ModeratePost(req request.ModeratePostRequest) error { + if repositories.PostRepository.Get(sqls.DB(), req.ID) == nil { + return errorsx.InvalidParam("post not found") + } + return repositories.PostRepository.Updates(sqls.DB(), req.ID, map[string]any{"status": req.Status, "updated_at": time.Now()}) +} + +func (s *supportService) ModerateComment(req request.ModerateCommentRequest) error { + if repositories.CommentRepository.Get(sqls.DB(), req.ID) == nil { + return errorsx.InvalidParam("comment not found") + } + return repositories.CommentRepository.Updates(sqls.DB(), req.ID, map[string]any{"status": req.Status, "updated_at": time.Now()}) +} + +func (s *supportService) FeedbackDocPage(req request.DocPageFeedbackRequest) error { + column := "unhelpful_count" + if req.Helpful { + column = "helpful_count" + } + return repositories.DocPageRepository.UpdateColumn(sqls.DB(), req.ID, column, gorm.Expr(column+" + ?", 1)) +} + +func normalizeSupportEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +func normalizeSupportSlug(slug string) string { + return strings.ToLower(strings.TrimSpace(slug)) +} + +func validateSupportSlug(slug string) error { + if !supportSlugPattern.MatchString(slug) { + return errorsx.InvalidParamI18n("error.docPage.invalidSlug") + } + return nil +} + +func normalizeContentType(contentType string) string { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + if contentType == "html" { + return "html" + } + return "markdown" +} + +func normalizeDocPageStatus(status enums.DocPageStatus) enums.DocPageStatus { + if status == "" { + return enums.DocPageStatusDraft + } + return status +} + +func normalizeTags(tags []string) []string { + ret := make([]string, 0, len(tags)) + seen := map[string]struct{}{} + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + if _, ok := seen[tag]; ok { + continue + } + seen[tag] = struct{}{} + ret = append(ret, tag) + } + return ret +} + +func auditFieldsFromOperator(operator *dto.AuthPrincipal, now time.Time) models.AuditFields { + if operator == nil { + return supportAuditFields(0, "system", now) + } + return supportAuditFields(operator.UserID, operator.Username, now) +} + +func supportAuditFields(userID int64, username string, now time.Time) models.AuditFields { + return models.AuditFields{CreatedAt: now, CreateUserID: userID, CreateUserName: username, UpdatedAt: now, UpdateUserID: userID, UpdateUserName: username} +} + +func supportPrincipalName(principal *dto.AuthPrincipal) string { + if principal == nil { + return "" + } + if strings.TrimSpace(principal.Nickname) != "" { + return strings.TrimSpace(principal.Nickname) + } + return strings.TrimSpace(principal.Username) +} + +func supportAuthorType(principal *dto.AuthPrincipal) enums.CommentAuthorType { + if principal != nil && principal.UserType == enums.UserTypeEmployee { + return enums.CommentAuthorTypeEmployee + } + return enums.CommentAuthorTypeUser +} diff --git a/internal/services/support_service_test.go b/internal/services/support_service_test.go new file mode 100644 index 0000000..9b777ba --- /dev/null +++ b/internal/services/support_service_test.go @@ -0,0 +1,360 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func TestDocPageHierarchy(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.DocPage{}); err != nil { + t.Fatalf("migrate help page: %v", err) + } + sqls.SetDB(db) + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + + root, err := SupportService.SaveDocPage(request.SaveDocPageRequest{ + Title: "Getting Started", Slug: "getting-started", ContentType: "markdown", Content: "# Getting Started", Status: enums.DocPageStatusPublished, + }, operator) + if err != nil { + t.Fatalf("create root page: %v", err) + } + child, err := SupportService.SaveDocPage(request.SaveDocPageRequest{ + ParentID: root.ID, Title: "Install", Slug: "install", ContentType: "markdown", Content: "# Install", Status: enums.DocPageStatusPublished, + }, operator) + if err != nil { + t.Fatalf("create child page under a content page: %v", err) + } + secondChild, err := SupportService.SaveDocPage(request.SaveDocPageRequest{ + ParentID: root.ID, Title: "Configure", Slug: "configure", ContentType: "markdown", Content: "# Configure", Status: enums.DocPageStatusDraft, + }, operator) + if err != nil { + t.Fatalf("create second child page: %v", err) + } + if err := SupportService.SortDocPages(request.SortDocPagesRequest{ + ParentID: root.ID, + IDs: []int64{secondChild.ID, child.ID}, + }); err != nil { + t.Fatalf("sort sibling pages: %v", err) + } + sorted := SupportService.FindDocPages(sqls.NewCnd().Eq("parent_id", root.ID).Asc("sort_no")) + if len(sorted) != 2 || sorted[0].ID != secondChild.ID || sorted[1].ID != child.ID { + t.Fatalf("unexpected sorted pages: %#v", sorted) + } + if err := SupportService.SortDocPages(request.SortDocPagesRequest{ + ParentID: root.ID, + IDs: []int64{child.ID}, + }); err == nil { + t.Fatal("expected incomplete sibling sort to fail") + } + if _, err := SupportService.ChangeDocPageStatus(request.ChangeDocPageStatusRequest{ID: root.ID, Status: enums.DocPageStatusDraft}, operator); err == nil { + t.Fatal("expected withdrawing a parent with published children to fail") + } + if _, err := SupportService.ChangeDocPageStatus(request.ChangeDocPageStatusRequest{ID: child.ID, Status: enums.DocPageStatusDraft}, operator); err != nil { + t.Fatalf("withdraw child page: %v", err) + } + if _, err := SupportService.ChangeDocPageStatus(request.ChangeDocPageStatusRequest{ID: root.ID, Status: enums.DocPageStatusDraft}, operator); err != nil { + t.Fatalf("withdraw root page: %v", err) + } + if _, err := SupportService.ChangeDocPageStatus(request.ChangeDocPageStatusRequest{ID: child.ID, Status: enums.DocPageStatusPublished}, operator); err == nil { + t.Fatal("expected publishing a child under a draft parent to fail") + } + if _, err := SupportService.ChangeDocPageStatus(request.ChangeDocPageStatusRequest{ID: root.ID, Status: enums.DocPageStatusPublished}, operator); err != nil { + t.Fatalf("publish root page: %v", err) + } + if _, err := SupportService.ChangeDocPageStatus(request.ChangeDocPageStatusRequest{ID: child.ID, Status: enums.DocPageStatusPublished}, operator); err != nil { + t.Fatalf("publish child page: %v", err) + } + + root.ParentID = child.ID + _, err = SupportService.SaveDocPage(request.SaveDocPageRequest{ + ID: root.ID, ParentID: child.ID, Title: root.Title, Slug: root.Slug, ContentType: root.ContentType, Content: root.Content, Status: root.Status, + }, operator) + if err == nil { + t.Fatal("expected cycle validation error") + } + if err := SupportService.DeleteDocPage(root.ID); err == nil { + t.Fatal("expected deleting a page with children to fail") + } + if err := SupportService.DeleteDocPage(child.ID); err != nil { + t.Fatalf("delete leaf page: %v", err) + } + if err := SupportService.DeleteDocPage(secondChild.ID); err != nil { + t.Fatalf("delete second leaf page: %v", err) + } +} + +func TestFindPublicDocNavigationSelectsPublishedMetadataInTreeOrder(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.DocPage{}); err != nil { + t.Fatalf("migrate help page: %v", err) + } + sqls.SetDB(db) + items := []*models.DocPage{ + {ParentID: 0, Title: "Install", Slug: "install", Content: "install content", Status: enums.DocPageStatusPublished, SortNo: 0}, + {ParentID: 0, Title: "Overview", Slug: "overview", Content: "overview content", Status: enums.DocPageStatusPublished, SortNo: 1}, + {ParentID: 2, Title: "Change log", Slug: "changelog", Content: "change log content", Status: enums.DocPageStatusPublished, SortNo: 0}, + {ParentID: 0, Title: "Draft", Slug: "draft", Content: "draft content", Status: enums.DocPageStatusDraft, SortNo: 0}, + } + for _, item := range items { + if err := db.Create(item).Error; err != nil { + t.Fatalf("create help page: %v", err) + } + } + + list := SupportService.FindPublicDocNavigation() + if len(list) != 3 || list[0].Slug != "install" || list[1].Slug != "changelog" || list[2].Slug != "overview" { + t.Fatalf("unexpected navigation rows: %#v", list) + } + if list[0].Content != "" || list[1].Content != "" || list[2].Content != "" { + t.Fatalf("navigation query must not load article content: %#v", list) + } +} + +func TestSupportSlugAllowsLettersNumbersAndHyphens(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.DocPage{}, &models.Category{}); err != nil { + t.Fatalf("migrate support models: %v", err) + } + sqls.SetDB(db) + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + + page, err := SupportService.SaveDocPage(request.SaveDocPageRequest{ + Title: "Release notes", Slug: "release-2026-08", ContentType: "markdown", Status: enums.DocPageStatusDraft, + }, operator) + if err != nil { + t.Fatalf("save slug containing hyphens: %v", err) + } + if page.Slug != "release-2026-08" { + t.Fatalf("unexpected saved slug: %q", page.Slug) + } + category, err := SupportService.SaveCategory(request.SaveCategoryRequest{ + Name: "Release notes", Slug: "release-2026-08", + }, operator) + if err != nil { + t.Fatalf("save category slug containing hyphens: %v", err) + } + if category.Slug != "release-2026-08" { + t.Fatalf("unexpected saved category slug: %q", category.Slug) + } + if _, err := SupportService.SaveDocPage(request.SaveDocPageRequest{ + Title: "Invalid slug", Slug: "release_notes", ContentType: "markdown", Status: enums.DocPageStatusDraft, + }, operator); err == nil { + t.Fatal("expected underscore slug to fail validation") + } + if err := db.Create(&models.DocPage{ParentID: 0, Title: "Root", Slug: "shared-slug"}).Error; err != nil { + t.Fatalf("create first root slug: %v", err) + } + if err := db.Create(&models.DocPage{ParentID: 1, Title: "Child", Slug: "shared-slug"}).Error; err != nil { + t.Fatalf("allow the same slug under another parent: %v", err) + } + if err := db.Create(&models.DocPage{ParentID: 0, Title: "Duplicate root", Slug: "shared-slug"}).Error; err == nil { + t.Fatal("expected duplicate slug under the same parent to fail") + } +} + +func TestUpdateDocPageSettingsOnlyUpdatesSettingsFields(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.DocPage{}); err != nil { + t.Fatalf("migrate help page: %v", err) + } + sqls.SetDB(db) + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + parent, err := SupportService.SaveDocPage(request.SaveDocPageRequest{ + Title: "Parent", Slug: "parent", ContentType: "markdown", Content: "parent content", Status: enums.DocPageStatusDraft, + }, operator) + if err != nil { + t.Fatalf("create parent: %v", err) + } + page, err := SupportService.SaveDocPage(request.SaveDocPageRequest{ + Title: "Original title", Slug: "original", Summary: "original summary", ContentType: "markdown", Content: "original content", Tags: []string{"original"}, Status: enums.DocPageStatusDraft, + }, operator) + if err != nil { + t.Fatalf("create page: %v", err) + } + saved, err := SupportService.UpdateDocPageSettings(request.UpdateDocPageSettingsRequest{ + ID: page.ID, ParentID: parent.ID, Slug: "updated", Summary: "updated summary", + }, operator) + if err != nil { + t.Fatalf("update page settings: %v", err) + } + if saved.ParentID != parent.ID || saved.Slug != "updated" || saved.Summary != "updated summary" { + t.Fatalf("settings were not updated: %#v", saved) + } + if saved.Title != page.Title || saved.Content != page.Content || saved.ContentType != page.ContentType || saved.TagsJSON != page.TagsJSON || saved.Status != page.Status { + t.Fatalf("document fields changed while saving settings: %#v", saved) + } +} + +func TestPostAndCommentContentType(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.Post{}, &models.Comment{}, &models.Category{}); err != nil { + t.Fatalf("migrate support post models: %v", err) + } + sqls.SetDB(db) + principal := &dto.AuthPrincipal{UserID: 1, Username: "customer"} + + post, err := SupportService.CreatePost(request.CreatePostRequest{ + CategoryID: 1, + Title: "Rich text post", + ContentType: "html", + Content: "

Hello

", + }, principal) + if err != nil { + t.Fatalf("create post: %v", err) + } + if post.ContentType != "html" { + t.Fatalf("post content type = %q, want html", post.ContentType) + } + comment, err := SupportService.CreateCustomerComment(request.CreateCommentRequest{ + PostID: post.ID, + ContentType: "markdown", + Content: "**Comment**", + }, principal) + if err != nil { + t.Fatalf("create comment: %v", err) + } + if comment.ContentType != "markdown" { + t.Fatalf("comment content type = %q, want markdown", comment.ContentType) + } + defaultPost, err := SupportService.CreatePost(request.CreatePostRequest{ + CategoryID: 1, + Title: "Default post", + Content: "plain content", + }, principal) + if err != nil { + t.Fatalf("create default post: %v", err) + } + if defaultPost.ContentType != "markdown" { + t.Fatalf("default post content type = %q, want markdown", defaultPost.ContentType) + } +} + +func TestCommentDiscussionWorkflow(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.Post{}, &models.Comment{}, &models.Reaction{}, &models.CommentReport{}); err != nil { + t.Fatalf("migrate support comment models: %v", err) + } + sqls.SetDB(db) + owner := &dto.AuthPrincipal{UserID: 1, Username: "owner"} + commenter := &dto.AuthPrincipal{UserID: 2, Username: "commenter"} + + post, err := SupportService.CreatePost(request.CreatePostRequest{ + CategoryID: 1, + Title: "Discussion post", + Content: "post body", + }, owner) + if err != nil { + t.Fatalf("create post: %v", err) + } + comment, err := SupportService.CreateCustomerComment(request.CreateCommentRequest{ + PostID: post.ID, + Content: "top level comment", + }, commenter) + if err != nil { + t.Fatalf("create comment: %v", err) + } + reply, err := SupportService.CreateCustomerComment(request.CreateCommentRequest{ + PostID: post.ID, + ParentID: comment.ID, + Content: "reply", + }, owner) + if err != nil { + t.Fatalf("create reply: %v", err) + } + if reply.ParentID != comment.ID { + t.Fatalf("reply parent id = %d, want %d", reply.ParentID, comment.ID) + } + list, err := SupportService.ListPostComments(post.ID, 0, "default", 1, 20) + if err != nil { + t.Fatalf("list comments: %v", err) + } + if len(list.Comments) != 1 || len(list.Replies[comment.ID]) != 1 { + t.Fatalf("expected top-level comment with one preview reply, got %#v replies=%#v", list.Comments, list.Replies) + } + if list.Paging.Total != 1 { + t.Fatalf("top-level paging total = %d, want 1", list.Paging.Total) + } + if err := SupportService.UpdateComment(request.UpdateCommentRequest{ID: comment.ID, ContentType: "markdown", Content: "updated"}, commenter); err != nil { + t.Fatalf("update own comment: %v", err) + } + if err := SupportService.ReportComment(request.ReportCommentRequest{ID: comment.ID, Reason: "spam"}, owner); err != nil { + t.Fatalf("report comment: %v", err) + } + if err := SupportService.ReportComment(request.ReportCommentRequest{ID: comment.ID, Reason: "spam again"}, owner); err != nil { + t.Fatalf("report comment twice: %v", err) + } + updated := repositories.CommentRepository.Get(sqls.DB(), comment.ID) + if updated.Content != "updated" || updated.ReportCount != 1 || updated.ReplyCount != 1 { + t.Fatalf("unexpected updated comment: %#v", updated) + } + if err := SupportService.DeleteComment(reply.ID, owner); err != nil { + t.Fatalf("delete reply: %v", err) + } + updated = repositories.CommentRepository.Get(sqls.DB(), comment.ID) + if updated.ReplyCount != 1 { + t.Fatalf("reply count after soft delete = %d, want 1", updated.ReplyCount) + } + replies, err := SupportService.ListPostComments(post.ID, comment.ID, "default", 1, 20) + if err != nil { + t.Fatalf("list replies after delete: %v", err) + } + if len(replies.Comments) != 1 || replies.Comments[0].Status != enums.CommentStatusDeleted { + t.Fatalf("expected deleted reply placeholder, got %#v", replies.Comments) + } +} + +func TestCategorySort(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.Category{}); err != nil { + t.Fatalf("migrate category: %v", err) + } + sqls.SetDB(db) + categories := []*models.Category{ + {Name: "First", Slug: "first", SortNo: 0, Status: enums.StatusOk}, + {Name: "Second", Slug: "second", SortNo: 1, Status: enums.StatusOk}, + {Name: "Third", Slug: "third", SortNo: 2, Status: enums.StatusOk}, + } + for _, category := range categories { + if err := db.Create(category).Error; err != nil { + t.Fatalf("create category: %v", err) + } + } + if err := SupportService.UpdateCategorySort([]int64{categories[2].ID, categories[0].ID, categories[1].ID}); err != nil { + t.Fatalf("sort categories: %v", err) + } + sorted := repositories.CategoryRepository.Find(sqls.DB(), sqls.NewCnd().Asc("sort_no")) + if len(sorted) != 3 || sorted[0].ID != categories[2].ID || sorted[1].ID != categories[0].ID || sorted[2].ID != categories[1].ID { + t.Fatalf("unexpected sorted categories: %#v", sorted) + } +} diff --git a/internal/services/system_config_branding_validator.go b/internal/services/system_config_branding_validator.go new file mode 100644 index 0000000..943c6ea --- /dev/null +++ b/internal/services/system_config_branding_validator.go @@ -0,0 +1,98 @@ +package services + +import ( + "encoding/json" + "strings" + + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/i18nx" +) + +const ( + brandingCompanyNameMaxLength = 120 + brandingCompanyLogoURLMaxLen = 500 + brandingDefaultCompanyNameKey = "app.brand" +) + +// brandingCompanyNameValidator keeps the platform name a plain single-line +// string. It is used as fallback in headers and document titles, so newlines and +// markup would show up literally. +type brandingCompanyNameValidator struct{} + +func (brandingCompanyNameValidator) Validate(raw json.RawMessage) (json.RawMessage, []response.ConfigFieldError, error) { + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return nil, []response.ConfigFieldError{ + configFieldError("companyName", "invalid_type", "error.branding.companyNameInvalid"), + }, nil + } + + normalized := strings.Join(strings.Fields(value), " ") + if len([]rune(normalized)) > brandingCompanyNameMaxLength { + return nil, []response.ConfigFieldError{ + configFieldError("companyName", "too_long", "error.branding.companyNameTooLong"), + }, nil + } + encoded, err := json.Marshal(normalized) + if err != nil { + return nil, nil, err + } + return encoded, nil, nil +} + +// brandingCompanyLogoURLValidator allows an empty value, which means "keep the +// bundled logo". A supplied value has to be a relative path or an http(s) URL; +// anything else could inject a javascript: scheme into every page that renders +// the logo. +type brandingCompanyLogoURLValidator struct{} + +func (brandingCompanyLogoURLValidator) Validate(raw json.RawMessage) (json.RawMessage, []response.ConfigFieldError, error) { + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return nil, []response.ConfigFieldError{ + configFieldError("companyLogoUrl", "invalid_type", "error.branding.companyLogoUrlInvalid"), + }, nil + } + + normalized := strings.TrimSpace(value) + if normalized == "" { + encoded, err := json.Marshal("") + if err != nil { + return nil, nil, err + } + return encoded, nil, nil + } + if len([]rune(normalized)) > brandingCompanyLogoURLMaxLen { + return nil, []response.ConfigFieldError{ + configFieldError("companyLogoUrl", "too_long", "error.branding.companyLogoUrlTooLong"), + }, nil + } + if !isAllowedBrandingLogoURL(normalized) { + return nil, []response.ConfigFieldError{ + configFieldError("companyLogoUrl", "invalid_url", "error.branding.companyLogoUrlInvalid"), + }, nil + } + + encoded, err := json.Marshal(normalized) + if err != nil { + return nil, nil, err + } + return encoded, nil, nil +} + +func isAllowedBrandingLogoURL(value string) bool { + if strings.HasPrefix(value, "/") { + // Protocol-relative "//host" would leave the deployment and is not a local asset. + return !strings.HasPrefix(value, "//") + } + return strings.HasPrefix(value, "http://") || + strings.HasPrefix(value, "https://") || + strings.HasPrefix(value, "data:image/") +} + +// defaultBrandingCompanyName falls back to the translated product name rather +// than to a hardcoded string, so a deployment that never sets a name still reads +// correctly in the visitor's language. +func defaultBrandingCompanyName() string { + return i18nx.Getf(i18nx.DefaultLocale, brandingDefaultCompanyNameKey) +} diff --git a/internal/services/system_config_service.go b/internal/services/system_config_service.go new file mode 100644 index 0000000..0909667 --- /dev/null +++ b/internal/services/system_config_service.go @@ -0,0 +1,307 @@ +package services + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/i18nx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +var SystemConfigService = newSystemConfigService() + +func newSystemConfigService() *systemConfigService { + return &systemConfigService{} +} + +type systemConfigService struct { +} + +const ( + systemConfigGroupSupportCenter = "support" + systemConfigKeySupportNavMenu = "navigationMenu" + + // The branding group is what lets an operator rename a deployment without + // editing configuration and restarting. It is deliberately separate from the + // support group: the name is read on every page load by both the visitor + // portal and the dashboard, while the navigation menu is a portal concern. + systemConfigGroupBranding = "branding" + systemConfigKeyBrandingCompanyName = "companyName" + systemConfigKeyBrandingCompanyLogo = "companyLogoUrl" +) + +type configValidator interface { + Validate(raw json.RawMessage) (json.RawMessage, []response.ConfigFieldError, error) +} + +type systemConfigDefinition struct { + GroupCode string + Key string + TitleKey string + DescriptionKey string + DefaultValue any + Validator configValidator +} + +type SystemConfigValidationError struct { + errors []response.ConfigFieldError +} + +func (e *SystemConfigValidationError) Error() string { + return e.Message(i18nx.DefaultLocale) +} + +func (e *SystemConfigValidationError) Message(locale string) string { + if len(e.errors) == 0 { + return i18nx.Getf(locale, "error.supportConfig.validationFailed") + } + fieldErrorMessage := e.errors[0].Message + if e.errors[0].MessageKey != "" { + fieldErrorMessage = i18nx.Getf(locale, e.errors[0].MessageKey) + } + return fmt.Sprintf("%s: %s", i18nx.Getf(locale, "error.supportConfig.validationFailed"), fieldErrorMessage) +} + +func (e *SystemConfigValidationError) FieldErrors() []response.ConfigFieldError { + if e == nil { + return nil + } + return e.errors +} + +func (e *SystemConfigValidationError) FieldErrorsLocale(locale string) []response.ConfigFieldError { + if e == nil { + return nil + } + return localizeConfigFieldErrors(e.errors, locale) +} + +var systemConfigDefinitions = map[string]map[string]systemConfigDefinition{ + systemConfigGroupSupportCenter: { + systemConfigKeySupportNavMenu: { + GroupCode: systemConfigGroupSupportCenter, + Key: systemConfigKeySupportNavMenu, + TitleKey: "systemConfig.support.navigationMenu.title", + DescriptionKey: "systemConfig.support.navigationMenu.description", + DefaultValue: defaultSupportNavigationMenu(), + Validator: supportNavigationMenuValidator{}, + }, + }, + systemConfigGroupBranding: { + systemConfigKeyBrandingCompanyName: { + GroupCode: systemConfigGroupBranding, + Key: systemConfigKeyBrandingCompanyName, + TitleKey: "systemConfig.branding.companyName.title", + DescriptionKey: "systemConfig.branding.companyName.description", + DefaultValue: "", + Validator: brandingCompanyNameValidator{}, + }, + systemConfigKeyBrandingCompanyLogo: { + GroupCode: systemConfigGroupBranding, + Key: systemConfigKeyBrandingCompanyLogo, + TitleKey: "systemConfig.branding.companyLogoUrl.title", + DescriptionKey: "systemConfig.branding.companyLogoUrl.description", + DefaultValue: "", + Validator: brandingCompanyLogoURLValidator{}, + }, + }, +} + +func (s *systemConfigService) Get(id int64) *models.SystemConfig { + return repositories.SystemConfigRepository.Get(sqls.DB(), id) +} + +func (s *systemConfigService) Find(cnd *sqls.Cnd) []models.SystemConfig { + return repositories.SystemConfigRepository.Find(sqls.DB(), cnd) +} + +func (s *systemConfigService) FindByGroupCode(groupCode string) []models.SystemConfig { + return repositories.SystemConfigRepository.FindByGroupCode(sqls.DB(), groupCode) +} + +func (s *systemConfigService) GetByGroupAndKey(groupCode, key string) *models.SystemConfig { + return repositories.SystemConfigRepository.FindByGroupAndKey(sqls.DB(), groupCode, key) +} + +func (s *systemConfigService) FindOne(cnd *sqls.Cnd) *models.SystemConfig { + return repositories.SystemConfigRepository.FindOne(sqls.DB(), cnd) +} + +func (s *systemConfigService) FindPageByCnd(cnd *sqls.Cnd) (list []models.SystemConfig, paging *sqls.Paging) { + return repositories.SystemConfigRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *systemConfigService) GetPublicSupportConfig() response.PublicSupportConfigResponse { + return response.PublicSupportConfigResponse{ + NavigationMenu: s.enabledSupportNavigationMenu(), + } +} + +func (s *systemConfigService) GetDashboardSupportConfig() response.DashboardSupportConfigResponse { + return response.DashboardSupportConfigResponse{ + NavigationMenu: s.supportNavigationMenu(), + } +} + +func (s *systemConfigService) SaveSupportConfig(payload map[string]json.RawMessage, operator *dto.AuthPrincipal) (response.DashboardSupportConfigResponse, error) { + if err := s.SaveGroupConfig(systemConfigGroupSupportCenter, payload, operator); err != nil { + return response.DashboardSupportConfigResponse{}, err + } + return s.GetDashboardSupportConfig(), nil +} + +func (s *systemConfigService) SaveGroupConfig(groupCode string, payload map[string]json.RawMessage, operator *dto.AuthPrincipal) error { + definitions := systemConfigDefinitions[groupCode] + if len(definitions) == 0 { + return errorsx.InvalidParamI18n("error.supportConfig.groupUnsupported") + } + if len(payload) == 0 { + return errorsx.InvalidParamI18n("error.supportConfig.emptyPayload") + } + + values := make(map[string]json.RawMessage, len(payload)) + for key, raw := range payload { + definition, ok := definitions[key] + if !ok { + return errorsx.InvalidParamI18n("error.supportConfig.keyUnsupported", key) + } + normalized := raw + if definition.Validator != nil { + next, fieldErrors, err := definition.Validator.Validate(raw) + if err != nil { + return err + } + if len(fieldErrors) > 0 { + return &SystemConfigValidationError{errors: fieldErrors} + } + normalized = next + } + values[key] = normalized + } + + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + now := time.Now() + auditFields := utils.BuildAuditFields(operator) + for key, raw := range values { + definition := definitions[key] + existing := repositories.SystemConfigRepository.FindByGroupAndKey(ctx.Tx, groupCode, key) + if existing == nil { + item := &models.SystemConfig{ + ConfigKey: key, + ConfigValue: string(raw), + GroupCode: groupCode, + Title: definition.Title(), + Description: definition.Description(), + Status: enums.StatusOk, + AuditFields: auditFields, + } + if err := repositories.SystemConfigRepository.Create(ctx.Tx, item); err != nil { + return err + } + continue + } + columns := map[string]any{ + "config_value": string(raw), + "group_code": groupCode, + "title": definition.Title(), + "description": definition.Description(), + "status": enums.StatusOk, + "updated_at": now, + "update_user_id": auditFields.UpdateUserID, + "update_user_name": auditFields.UpdateUserName, + } + if err := repositories.SystemConfigRepository.Updates(ctx.Tx, existing.ID, columns); err != nil { + return err + } + } + return nil + }) +} + +func (s *systemConfigService) UpdateSupportNavigationMenu(items []request.SupportNavigationMenuItemRequest, operator *dto.AuthPrincipal) ([]response.SupportNavigationMenuItemResponse, error) { + raw, err := json.Marshal(items) + if err != nil { + return nil, err + } + _, err = s.SaveSupportConfig(map[string]json.RawMessage{ + systemConfigKeySupportNavMenu: raw, + }, operator) + if err != nil { + return nil, err + } + return s.supportNavigationMenu(), nil +} + +func (s *systemConfigService) enabledSupportNavigationMenu() []response.SupportNavigationMenuItemResponse { + items := s.supportNavigationMenu() + enabled := make([]response.SupportNavigationMenuItemResponse, 0, len(items)) + for _, item := range items { + if item.Visible { + item.Children = visibleSupportNavigationChildren(item.Children) + enabled = append(enabled, item) + } + } + if len(enabled) == 0 { + return defaultSupportNavigationMenu() + } + return enabled +} + +func (s *systemConfigService) supportNavigationMenu() []response.SupportNavigationMenuItemResponse { + item := repositories.SystemConfigRepository.FindByGroupAndKey(sqls.DB(), systemConfigGroupSupportCenter, systemConfigKeySupportNavMenu) + if item == nil || strings.TrimSpace(item.ConfigValue) == "" { + return defaultSupportNavigationMenu() + } + var list []response.SupportNavigationMenuItemResponse + if err := json.Unmarshal([]byte(item.ConfigValue), &list); err != nil { + return defaultSupportNavigationMenu() + } + if len(list) == 0 { + return defaultSupportNavigationMenu() + } + return sortSupportNavigationMenu(list) +} + +func sortSupportNavigationMenu(items []response.SupportNavigationMenuItemResponse) []response.SupportNavigationMenuItemResponse { + ret := append([]response.SupportNavigationMenuItemResponse(nil), items...) + for i := 0; i < len(ret)-1; i++ { + for j := i + 1; j < len(ret); j++ { + if ret[j].SortNo < ret[i].SortNo || (ret[j].SortNo == ret[i].SortNo && ret[j].ID < ret[i].ID) { + ret[i], ret[j] = ret[j], ret[i] + } + } + } + return ret +} + +func visibleSupportNavigationChildren(items []response.SupportNavigationMenuItemResponse) []response.SupportNavigationMenuItemResponse { + if len(items) == 0 { + return nil + } + visible := make([]response.SupportNavigationMenuItemResponse, 0, len(items)) + for _, item := range sortSupportNavigationMenu(items) { + if item.Visible { + visible = append(visible, item) + } + } + return visible +} + +func (d systemConfigDefinition) Title() string { + return i18nx.Get(d.TitleKey) +} + +func (d systemConfigDefinition) Description() string { + return i18nx.Get(d.DescriptionKey) +} diff --git a/internal/services/system_config_service_test.go b/internal/services/system_config_service_test.go new file mode 100644 index 0000000..0eddb36 --- /dev/null +++ b/internal/services/system_config_service_test.go @@ -0,0 +1,139 @@ +package services + +import ( + "encoding/json" + "strings" + "testing" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestSupportNavigationMenuDefaultsAndPublicFiltering(t *testing.T) { + setupSystemConfigServiceTestDB(t) + + defaultConfig := SystemConfigService.GetPublicSupportConfig() + if len(defaultConfig.NavigationMenu) != 3 { + t.Fatalf("expected default navigation menu, got %#v", defaultConfig.NavigationMenu) + } + + raw, _ := json.Marshal([]request.SupportNavigationMenuItemRequest{ + {ID: "community", Title: "Community", URL: "/support/community/posts", Visible: boolPtr(true)}, + {ID: "hidden", Title: "Hidden", URL: "/support/hidden", Visible: boolPtr(false)}, + {ID: "docs", Title: "Docs", URL: "https://docs.example.com", OpenInNewWindow: true, Visible: boolPtr(true)}, + }) + config, err := SystemConfigService.SaveSupportConfig(map[string]json.RawMessage{ + systemConfigKeySupportNavMenu: raw, + }, &dto.AuthPrincipal{UserID: 1, Username: "admin"}) + if err != nil { + t.Fatalf("SaveSupportConfig() error = %v", err) + } + items := config.NavigationMenu + if got := []int{items[0].SortNo, items[1].SortNo, items[2].SortNo}; got[0] != 10 || got[1] != 20 || got[2] != 30 { + t.Fatalf("unexpected sort numbers: %#v", got) + } + + publicConfig := SystemConfigService.GetPublicSupportConfig() + if len(publicConfig.NavigationMenu) != 2 { + t.Fatalf("expected only enabled public items, got %#v", publicConfig.NavigationMenu) + } + if publicConfig.NavigationMenu[0].ID != "community" || publicConfig.NavigationMenu[1].ID != "docs" { + t.Fatalf("unexpected public order: %#v", publicConfig.NavigationMenu) + } + if !publicConfig.NavigationMenu[1].OpenInNewWindow { + t.Fatalf("expected docs to open in new window") + } +} + +func TestSaveSupportConfigValidation(t *testing.T) { + setupSystemConfigServiceTestDB(t) + + tests := []struct { + name string + req []request.SupportNavigationMenuItemRequest + }{ + { + name: "empty", + req: nil, + }, + { + name: "blank title", + req: []request.SupportNavigationMenuItemRequest{ + {Title: " ", URL: "/support", Visible: boolPtr(true)}, + }, + }, + { + name: "invalid url", + req: []request.SupportNavigationMenuItemRequest{ + {Title: "Docs", URL: "javascript:alert(1)", Visible: boolPtr(true)}, + }, + }, + { + name: "no visible item", + req: []request.SupportNavigationMenuItemRequest{ + {Title: "Docs", URL: "/support", Visible: boolPtr(false)}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, _ := json.Marshal(tt.req) + err := SystemConfigService.SaveGroupConfig(systemConfigGroupSupportCenter, map[string]json.RawMessage{ + systemConfigKeySupportNavMenu: raw, + }, nil) + if err == nil { + t.Fatal("expected validation error") + } + if _, ok := err.(*SystemConfigValidationError); !ok { + t.Fatalf("expected SystemConfigValidationError, got %T", err) + } + }) + } +} + +func TestSaveSupportConfigRejectsUnsupportedKey(t *testing.T) { + setupSystemConfigServiceTestDB(t) + + err := SystemConfigService.SaveGroupConfig(systemConfigGroupSupportCenter, map[string]json.RawMessage{ + "unknown": json.RawMessage(`true`), + }, nil) + if err == nil { + t.Fatal("expected unsupported key error") + } +} + +func setupSystemConfigServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.SystemConfig{}); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + return db +} + +func boolPtr(value bool) *bool { + return &value +} diff --git a/internal/services/system_config_support_validator.go b/internal/services/system_config_support_validator.go new file mode 100644 index 0000000..79ebc65 --- /dev/null +++ b/internal/services/system_config_support_validator.go @@ -0,0 +1,170 @@ +package services + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/i18nx" + + "github.com/mlogclub/simple/common/strs" +) + +type supportNavigationMenuValidator struct{} + +func (supportNavigationMenuValidator) Validate(raw json.RawMessage) (json.RawMessage, []response.ConfigFieldError, error) { + var input []request.SupportNavigationMenuItemRequest + if err := json.Unmarshal(raw, &input); err != nil { + return nil, []response.ConfigFieldError{configFieldError("", "invalid_json", "error.supportConfig.navigationInvalidJSON")}, nil + } + items, fieldErrors := normalizeSupportNavigationMenu(input) + if len(fieldErrors) > 0 { + return nil, fieldErrors, nil + } + normalized, err := json.Marshal(items) + if err != nil { + return nil, nil, err + } + return normalized, nil, nil +} + +func normalizeSupportNavigationMenu(input []request.SupportNavigationMenuItemRequest) ([]response.SupportNavigationMenuItemResponse, []response.ConfigFieldError) { + if len(input) == 0 { + return nil, []response.ConfigFieldError{configFieldError("navigationMenu", "required", "error.supportConfig.navigationRequired")} + } + if len(input) > 20 { + return nil, []response.ConfigFieldError{configFieldError("navigationMenu", "too_many", "error.supportConfig.navigationTooMany")} + } + seenIDs := make(map[string]int) + items, visibleCount, fieldErrors := normalizeSupportNavigationItems(input, "navigationMenu", 1, seenIDs) + if len(fieldErrors) > 0 { + return nil, fieldErrors + } + if visibleCount == 0 { + return nil, []response.ConfigFieldError{configFieldError("navigationMenu", "visible_required", "error.supportConfig.navigationVisibleRequired")} + } + return items, nil +} + +func normalizeSupportNavigationItems(input []request.SupportNavigationMenuItemRequest, path string, depth int, seenIDs map[string]int) ([]response.SupportNavigationMenuItemResponse, int, []response.ConfigFieldError) { + items := make([]response.SupportNavigationMenuItemResponse, 0, len(input)) + visibleCount := 0 + for idx, raw := range input { + itemPath := fmt.Sprintf("%s[%d]", path, idx) + title := strings.TrimSpace(raw.Title) + if title == "" { + return nil, 0, []response.ConfigFieldError{configFieldError(itemPath+".title", "required", "error.supportConfig.navigationTitleRequired")} + } + if len([]rune(title)) > 64 { + return nil, 0, []response.ConfigFieldError{configFieldError(itemPath+".title", "too_long", "error.supportConfig.navigationTitleTooLong")} + } + link := strings.TrimSpace(raw.URL) + if link == "" { + return nil, 0, []response.ConfigFieldError{configFieldError(itemPath+".url", "required", "error.supportConfig.navigationURLRequired")} + } + if !isAllowedSupportNavigationURL(link) { + return nil, 0, []response.ConfigFieldError{configFieldError(itemPath+".url", "invalid_url", "error.supportConfig.navigationURLInvalid")} + } + id := normalizeSupportNavigationMenuID(raw.ID) + if id == "" { + id = "nav-" + strings.ReplaceAll(strs.UUID(), "-", "")[:12] + } + if count := seenIDs[id]; count > 0 { + id = id + "-" + strings.ReplaceAll(strs.UUID(), "-", "")[:6] + } + seenIDs[id]++ + visible := true + if raw.Visible != nil { + visible = *raw.Visible + } + children := []response.SupportNavigationMenuItemResponse(nil) + if len(raw.Children) > 0 { + if depth >= 2 { + return nil, 0, []response.ConfigFieldError{configFieldError(itemPath+".children", "too_deep", "error.supportConfig.navigationTooDeep")} + } + if len(raw.Children) > 20 { + return nil, 0, []response.ConfigFieldError{configFieldError(itemPath+".children", "too_many", "error.supportConfig.navigationChildrenTooMany")} + } + nextChildren, nextVisibleCount, fieldErrors := normalizeSupportNavigationItems(raw.Children, itemPath+".children", depth+1, seenIDs) + if len(fieldErrors) > 0 { + return nil, 0, fieldErrors + } + children = nextChildren + if nextVisibleCount > 0 && visible { + visibleCount += nextVisibleCount + } + } + if visible { + visibleCount++ + } + items = append(items, response.SupportNavigationMenuItemResponse{ + ID: id, + Title: title, + URL: link, + OpenInNewWindow: raw.OpenInNewWindow, + Visible: visible, + SortNo: (idx + 1) * 10, + Children: children, + }) + } + return items, visibleCount, nil +} + +func isAllowedSupportNavigationURL(value string) bool { + if strings.HasPrefix(value, "/") { + return !strings.HasPrefix(value, "//") + } + parsed, err := url.Parse(value) + if err != nil { + return false + } + return parsed.Scheme == "http" || parsed.Scheme == "https" +} + +func normalizeSupportNavigationMenuID(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return "" + } + var builder strings.Builder + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + builder.WriteRune(r) + } + } + return strings.Trim(builder.String(), "-_") +} + +func configFieldError(path, code, key string) response.ConfigFieldError { + return response.ConfigFieldError{ + Path: path, + Code: code, + MessageKey: key, + Message: i18nx.Get(key), + } +} + +func localizeConfigFieldErrors(errors []response.ConfigFieldError, locale string) []response.ConfigFieldError { + if len(errors) == 0 { + return nil + } + ret := make([]response.ConfigFieldError, 0, len(errors)) + for _, item := range errors { + if item.MessageKey != "" { + item.Message = i18nx.Getf(locale, item.MessageKey) + } + ret = append(ret, item) + } + return ret +} + +func defaultSupportNavigationMenu() []response.SupportNavigationMenuItemResponse { + return []response.SupportNavigationMenuItemResponse{ + {ID: "home", Title: i18nx.Get("systemConfig.support.navigationMenu.default.home"), URL: "/support", SortNo: 10, Visible: true}, + {ID: "docs", Title: i18nx.Get("systemConfig.support.navigationMenu.default.docs"), URL: "/support/docs", SortNo: 20, Visible: true}, + {ID: "community", Title: i18nx.Get("systemConfig.support.navigationMenu.default.community"), URL: "/support/community/posts", SortNo: 30, Visible: true}, + } +} diff --git a/internal/services/system_config_support_validator_test.go b/internal/services/system_config_support_validator_test.go new file mode 100644 index 0000000..b56df30 --- /dev/null +++ b/internal/services/system_config_support_validator_test.go @@ -0,0 +1,30 @@ +package services + +import ( + "encoding/json" + "testing" + + "agent-desk/internal/pkg/i18nx" +) + +func TestSystemConfigValidationErrorLocalizesFieldErrors(t *testing.T) { + _, fieldErrors, err := supportNavigationMenuValidator{}.Validate(json.RawMessage(`not-json`)) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + validationErr := &SystemConfigValidationError{errors: fieldErrors} + + if got := validationErr.Message(i18nx.LocaleEnUS); got != "Config validation failed: Navigation menu config must be a valid JSON array" { + t.Fatalf("Message(en-US) = %q", got) + } + localized := validationErr.FieldErrorsLocale(i18nx.LocaleEnUS) + if len(localized) != 1 { + t.Fatalf("FieldErrorsLocale() length = %d, want 1", len(localized)) + } + if localized[0].Message != "Navigation menu config must be a valid JSON array" { + t.Fatalf("localized message = %q", localized[0].Message) + } + if localized[0].MessageKey != "error.supportConfig.navigationInvalidJSON" { + t.Fatalf("message key = %q", localized[0].MessageKey) + } +} diff --git a/internal/services/tag_service.go b/internal/services/tag_service.go new file mode 100644 index 0000000..f0beda7 --- /dev/null +++ b/internal/services/tag_service.go @@ -0,0 +1,269 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + "strings" + "time" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var TagService = newTagService() + +func newTagService() *tagService { + return &tagService{} +} + +type tagService struct { +} + +func (s *tagService) Get(id int64) *models.Tag { + return repositories.TagRepository.Get(sqls.DB(), id) +} + +func (s *tagService) Take(where ...interface{}) *models.Tag { + return repositories.TagRepository.Take(sqls.DB(), where...) +} + +func (s *tagService) Find(cnd *sqls.Cnd) []models.Tag { + return repositories.TagRepository.Find(sqls.DB(), cnd) +} + +func (s *tagService) FindOne(cnd *sqls.Cnd) *models.Tag { + return repositories.TagRepository.FindOne(sqls.DB(), cnd) +} + +func (s *tagService) FindPageByParams(params *params.QueryParams) (list []models.Tag, paging *sqls.Paging) { + return repositories.TagRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *tagService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Tag, paging *sqls.Paging) { + return repositories.TagRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *tagService) Count(cnd *sqls.Cnd) int64 { + return repositories.TagRepository.Count(sqls.DB(), cnd) +} + +func (s *tagService) Create(t *models.Tag) error { + return repositories.TagRepository.Create(sqls.DB(), t) +} + +func (s *tagService) Update(t *models.Tag) error { + return repositories.TagRepository.Update(sqls.DB(), t) +} + +func (s *tagService) Updates(id int64, columns map[string]interface{}) error { + return repositories.TagRepository.Updates(sqls.DB(), id, columns) +} + +func (s *tagService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.TagRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *tagService) Delete(id int64) { + repositories.TagRepository.Delete(sqls.DB(), id) +} + +func (s *tagService) GetChildren(parentID int64) []models.Tag { + return s.Find(sqls.NewCnd().Eq("parent_id", parentID).Asc("sort_no").Asc("id")) +} + +func (s *tagService) HasChildren(parentID int64) bool { + return s.Count(sqls.NewCnd().Eq("parent_id", parentID)) > 0 +} + +func (s *tagService) FindByNameAndParentID(name string, parentID int64) *models.Tag { + return s.FindOne(sqls.NewCnd().Eq("name", name).Eq("parent_id", parentID)) +} + +func (s *tagService) CreateTag(req request.CreateTagRequest, operator *dto.AuthPrincipal) (*models.Tag, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errorsx.InvalidParamI18n("error.e0239") + } + + if req.ParentID > 0 { + parent := s.Get(req.ParentID) + if parent == nil { + return nil, errorsx.InvalidParamI18n("error.e0251") + } + } + + existing := s.FindByNameAndParentID(name, req.ParentID) + if existing != nil { + return nil, errorsx.InvalidParamI18n("error.e0141") + } + + item := &models.Tag{ + ParentID: req.ParentID, + Name: name, + Remark: strings.TrimSpace(req.Remark), + Status: enums.StatusOk, + AuditFields: utils.BuildAuditFields(operator), + } + + item.SortNo = s.NextSortNo(req.ParentID) + if err := s.Create(item); err != nil { + return nil, err + } + + return item, nil +} + +func (s *tagService) NextSortNo(parentID int64) int { + if temp := s.FindOne(sqls.NewCnd().Eq("parent_id", parentID).Desc("sort_no").Desc("id")); temp != nil { + return temp.SortNo + 1 + } + return 1 +} + +func (s *tagService) UpdateTag(req request.UpdateTagRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + + item := s.Get(req.ID) + if item == nil { + return errorsx.InvalidParamI18n("error.e0238") + } + + name := strings.TrimSpace(req.Name) + if name == "" { + return errorsx.InvalidParamI18n("error.e0239") + } + + if req.ParentID > 0 { + if req.ParentID == req.ID { + return errorsx.InvalidParamI18n("error.e0083") + } + parent := s.Get(req.ParentID) + if parent == nil { + return errorsx.InvalidParamI18n("error.e0251") + } + } + + existing := s.FindByNameAndParentID(name, req.ParentID) + if existing != nil && existing.ID != req.ID { + return errorsx.InvalidParamI18n("error.e0141") + } + + return s.Updates(req.ID, map[string]any{ + "parent_id": req.ParentID, + "name": name, + "remark": strings.TrimSpace(req.Remark), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) +} + +func (s *tagService) UpdateSort(ids []int64) error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + for i, id := range ids { + if err := repositories.TagRepository.UpdateColumn(ctx.Tx, id, "sort_no", i+1); err != nil { + return err + } + } + return nil + }) +} + +func (s *tagService) DeleteTag(id int64) error { + item := s.Get(id) + if item == nil { + return errorsx.InvalidParamI18n("error.e0238") + } + + if s.HasChildren(id) { + return errorsx.InvalidParamI18n("error.e0310") + } + if ConversationTagService.Take("tag_id = ?", id) != nil { + return errorsx.InvalidParamI18n("error.e0311") + } + if TicketTagService.Take("tag_id = ?", id) != nil { + return errorsx.InvalidParamI18n("error.e0312") + } + + s.Delete(id) + return nil +} + +func (s *tagService) FindAll() []models.Tag { + return s.Find(sqls.NewCnd().Asc("sort_no").Asc("id")) +} + +func (s *tagService) GetSelfAndDescendantIDs(tagID int64) []int64 { + if tagID <= 0 { + return nil + } + + allTags := s.FindAll() + if len(allTags) == 0 { + return nil + } + + exists := false + childrenMap := make(map[int64][]int64, len(allTags)) + for _, item := range allTags { + if item.ID == tagID { + exists = true + } + childrenMap[item.ParentID] = append(childrenMap[item.ParentID], item.ID) + } + if !exists { + return nil + } + + result := make([]int64, 0, 8) + visited := make(map[int64]bool, len(allTags)) + var walk func(id int64) + walk = func(id int64) { + if visited[id] { + return + } + visited[id] = true + result = append(result, id) + for _, childID := range childrenMap[id] { + walk(childID) + } + } + walk(tagID) + + return result +} + +func (s *tagService) UpdateStatus(id int64, status int, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + + item := s.Get(id) + if item == nil { + return errorsx.InvalidParamI18n("error.e0238") + } + + if status != int(enums.StatusOk) && status != int(enums.StatusDisabled) { + return errorsx.InvalidParamI18n("error.e0254") + } + + now := time.Now() + return s.Updates(id, map[string]any{ + "status": status, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }) +} diff --git a/internal/services/telegram_inbound_service.go b/internal/services/telegram_inbound_service.go new file mode 100644 index 0000000..7bb0154 --- /dev/null +++ b/internal/services/telegram_inbound_service.go @@ -0,0 +1,113 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/telegram" +) + +var TelegramInboundService = newTelegramInboundService() + +func newTelegramInboundService() *telegramInboundService { + return &telegramInboundService{} +} + +type telegramInboundService struct{} + +// HandleWebhook processes an incoming webhook Update from Telegram. +func (s *telegramInboundService) HandleWebhook(ctx context.Context, channelID string, secretHeader string, rawPayload []byte) error { + channelID = strings.TrimSpace(channelID) + var channel *models.Channel + if channelID != "" { + channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeTelegram, enums.StatusOk) + } + if channel == nil { + channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeTelegram, enums.StatusOk) + } + if channel == nil { + return errorsx.InvalidParam("telegram channel not found or disabled") + } + + cfg, err := ChannelService.ParseTelegramChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil || cfg.BotToken == "" { + return errorsx.InvalidParam("telegram channel config invalid") + } + + if cfg.WebhookSecret != "" && strings.TrimSpace(secretHeader) != cfg.WebhookSecret { + return errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + + var update telegram.Update + if err := json.Unmarshal(rawPayload, &update); err != nil { + return fmt.Errorf("unmarshal telegram update failed: %w", err) + } + + if update.Message == nil { + return nil // Ignore non-message updates (e.g. edits, inline queries) + } + + msg := update.Message + if msg.From == nil || msg.Chat.ID == 0 { + return nil + } + + text := strings.TrimSpace(msg.Text) + if text == "" { + text = strings.TrimSpace(msg.Caption) + } + if text == "" { + return nil // Ignore media without captions for now + } + + // 1. Resolve customer identity + externalID := fmt.Sprintf("%d", msg.Chat.ID) + name := strings.TrimSpace(msg.From.FirstName + " " + msg.From.LastName) + if name == "" { + name = strings.TrimSpace(msg.From.Username) + } + if name == "" { + name = fmt.Sprintf("Telegram User %d", msg.From.ID) + } + + externalUser := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceTelegram, + ExternalID: externalID, + ExternalName: name, + } + + // 2. Create or match Conversation + conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID) + if err != nil { + return fmt.Errorf("create telegram conversation failed: %w", err) + } + + // 3. Send message through MessageService (automatically triggers AI response loop or agent notification) + clientMsgID := fmt.Sprintf("tg_%d_%d", update.UpdateID, msg.MessageID) + payloadMap := map[string]any{ + "telegram_message_id": msg.MessageID, + "telegram_chat_id": msg.Chat.ID, + "telegram_update_id": update.UpdateID, + } + payloadBytes, _ := json.Marshal(payloadMap) + + _, err = MessageService.SendCustomerMessage( + conversation.ID, + clientMsgID, + enums.IMMessageTypeText, + text, + string(payloadBytes), + externalUser, + ) + if err != nil { + return fmt.Errorf("send customer message failed: %w", err) + } + + return nil +} diff --git a/internal/services/telegram_integration_test.go b/internal/services/telegram_integration_test.go new file mode 100644 index 0000000..52bc674 --- /dev/null +++ b/internal/services/telegram_integration_test.go @@ -0,0 +1,179 @@ +package services + +import ( + "context" + "encoding/json" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupTelegramTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate( + &models.Channel{}, + &models.ChannelMessageOutbox{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &models.CustomerContact{}, + &models.Conversation{}, + &models.ConversationParticipant{}, + &models.ConversationReadState{}, + &models.ConversationInterrupt{}, + &models.ConversationEventLog{}, + &models.Message{}, + &models.AIAgent{}, + &models.User{}, + &models.Role{}, + &models.UserRole{}, + &models.Permission{}, + &models.RolePermission{}, + &models.UserPermission{}, + ); err != nil { + t.Fatalf("migrate telegram test tables: %v", err) + } + sqls.SetDB(db) + return db +} + +func TestTelegramInboundAndOutboundFlow(t *testing.T) { + db := setupTelegramTestDB(t) + + now := time.Now() + // 1. Create AI Agent + agent := &models.AIAgent{ + Name: "Support Bot", + ServiceMode: enums.IMConversationServiceModeAIFirst, + PublishedRevisionID: 1, + WelcomeMessage: "Welcome to Crove Desk!", + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + } + _ = db.Create(agent) + + // 2. Create Telegram Channel + tgConfig, _ := json.Marshal(dto.TelegramChannelConfig{ + BotToken: "test-bot-token-123", + BotUsername: "crove_desk_bot", + WebhookSecret: "secret-webhook-token", + WelcomeMessage: "Welcome to Telegram Support!", + }) + + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + Name: "Telegram Support Channel", + ChannelType: enums.ChannelTypeTelegram, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: string(tgConfig), + Status: int(enums.StatusOk), + }, operator) + if err != nil { + t.Fatalf("CreateChannel failed: %v", err) + } + + // 3. Simulate Inbound Telegram Webhook update + updatePayload := []byte(`{ + "update_id": 998877, + "message": { + "message_id": 12345, + "from": { + "id": 888999, + "is_bot": false, + "first_name": "John", + "last_name": "Doe", + "username": "johndoe", + "language_code": "vi" + }, + "chat": { + "id": 888999, + "type": "private", + "first_name": "John" + }, + "date": 1756200000, + "text": "Chào bạn, tôi cần hỗ trợ nâng cấp gói Crove Enterprise!" + } + }`) + + ctx := context.Background() + err = TelegramInboundService.HandleWebhook(ctx, channel.ChannelID, "secret-webhook-token", updatePayload) + if err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + + // Verify Customer created + identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceTelegram). + Eq("external_id", "888999")) + if identity == nil { + t.Fatalf("expected customer identity to be created for Telegram user") + } + + customer := repositories.CustomerRepository.Get(db, identity.CustomerID) + if customer == nil || customer.Name != "John Doe" { + t.Fatalf("unexpected customer: %+v", customer) + } + + // Verify Conversation created + conv := repositories.ConversationRepository.FindOne(db, sqls.NewCnd().Eq("customer_id", customer.ID)) + if conv == nil || conv.ChannelID != channel.ID { + t.Fatalf("unexpected conversation: %+v", conv) + } + + // Verify Customer Message stored + msg := repositories.MessageRepository.FindOne(db, sqls.NewCnd(). + Eq("conversation_id", conv.ID). + Eq("sender_type", enums.IMSenderTypeCustomer)) + if msg == nil || msg.Content != "Chào bạn, tôi cần hỗ trợ nâng cấp gói Crove Enterprise!" { + t.Fatalf("unexpected customer message: %+v", msg) + } + + // 4. Simulate AI / Agent Reply and test Outbox Enqueue & Dispatch + replyMsg, err := MessageService.SendAIMessage(conv.ID, agent.ID, "ai_msg_001", enums.IMMessageTypeText, "Cảm ơn bạn! Đội ngũ Crove sẽ hỗ trợ bạn ngay.", "", operator) + if err != nil { + t.Fatalf("SendAIMessage failed: %v", err) + } + + outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeTelegram, replyMsg.ID) + if outbox == nil { + t.Fatalf("expected telegram outbox entry for AI message") + } + if outbox.SendStatus != string(enums.ChannelMessageOutboxStatusPending) && outbox.SendStatus != string(enums.ChannelMessageOutboxStatusSending) { + t.Logf("Outbox send status: %s", outbox.SendStatus) + } +} + +func stringsContains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > 0 && len(substr) > 0 && indexOf(s, substr) >= 0)) +} + +func indexOf(s, substr string) int { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/internal/services/telegram_outbound_service.go b/internal/services/telegram_outbound_service.go new file mode 100644 index 0000000..c77dba1 --- /dev/null +++ b/internal/services/telegram_outbound_service.go @@ -0,0 +1,150 @@ +package services + +import ( + "context" + "log/slog" + "strconv" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/telegram" + + "github.com/mlogclub/simple/sqls" +) + +const ( + telegramOutboxBatchSize = 20 + telegramOutboxMaxRetry = 5 +) + +var TelegramOutboundService = newTelegramOutboundService() + +func newTelegramOutboundService() *telegramOutboundService { + return &telegramOutboundService{} +} + +type telegramOutboundService struct{} + +func (s *telegramOutboundService) DispatchPendingOutbox() int { + return s.doDispatchPendingOutbox(telegramOutboxBatchSize) +} + +func (s *telegramOutboundService) doDispatchPendingOutbox(limit int) int { + if limit <= 0 { + limit = telegramOutboxBatchSize + } + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeTelegram, limit) + if len(items) == 0 { + return 0 + } + + successCount := 0 + for i := range items { + if err := s.processOutbox(items[i].ID); err != nil { + slog.Warn("process telegram outbox failed", + "outbox_id", items[i].ID, + "error", err, + ) + continue + } + successCount++ + } + return successCount +} + +func (s *telegramOutboundService) processOutbox(outboxID int64) error { + outbox := ChannelMessageOutboxService.Get(outboxID) + if outbox == nil { + return nil + } + if outbox.ChannelType != enums.ChannelTypeTelegram { + return nil + } + if outbox.SendStatus == string(enums.ChannelMessageOutboxStatusSent) { + return nil + } + if outbox.NextRetryAt != nil && outbox.NextRetryAt.After(time.Now()) { + return nil + } + + if err := ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSending), + "updated_at": time.Now(), + }); err != nil { + return err + } + + message := MessageService.Get(outbox.MessageID) + if message == nil { + return s.markOutboxFailed(outbox, "message not found") + } + conversation := ConversationService.Get(outbox.ConversationID) + if conversation == nil { + return s.markOutboxFailed(outbox, "conversation not found") + } + + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.Status != enums.StatusOk { + return s.markOutboxFailed(outbox, "telegram channel not found or disabled") + } + cfg, err := ChannelService.ParseTelegramChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil || cfg.BotToken == "" { + return s.markOutboxFailed(outbox, "telegram bot token not configured") + } + + // Resolve target Telegram ChatID + var chatID int64 + customerIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", conversation.CustomerID). + Eq("external_source", enums.ExternalSourceTelegram)) + if customerIdentity != nil { + if id, err := strconv.ParseInt(customerIdentity.ExternalID, 10, 64); err == nil { + chatID = id + } + } + if chatID == 0 { + return s.markOutboxFailed(outbox, "unable to resolve telegram chat_id") + } + + // Send message via Telegram Client + client := telegram.NewClient(cfg.BotToken) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + _, sendErr := client.SendMessage(ctx, telegram.SendMessageRequest{ + ChatID: chatID, + Text: message.Content, + }) + + if sendErr != nil { + return s.markOutboxFailed(outbox, sendErr.Error()) + } + + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSent), + "sent_at": time.Now(), + "updated_at": time.Now(), + }) +} + +func (s *telegramOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error { + if outbox == nil { + return nil + } + retryCount := outbox.RetryCount + 1 + status := string(enums.ChannelMessageOutboxStatusFailed) + if retryCount >= telegramOutboxMaxRetry { + status = string(enums.ChannelMessageOutboxStatusIgnored) + } + nextRetryAt := time.Now().Add(time.Duration(retryCount*30) * time.Second) + + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": status, + "retry_count": retryCount, + "next_retry_at": &nextRetryAt, + "last_error": errMsg, + "updated_at": time.Now(), + }) +} diff --git a/internal/services/ticket_no_service.go b/internal/services/ticket_no_service.go new file mode 100644 index 0000000..55a0a88 --- /dev/null +++ b/internal/services/ticket_no_service.go @@ -0,0 +1,123 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/repositories" + "fmt" + "strings" + "sync" + "time" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var TicketNoSequenceService = newTicketNoSequenceService() + +func newTicketNoSequenceService() *ticketNoSequenceService { + return &ticketNoSequenceService{} +} + +type ticketNoSequenceService struct { + ticketNoSQLiteMu sync.Mutex +} + +func (s *ticketNoSequenceService) Next(now time.Time) (string, error) { + s.ticketNoSQLiteMu.Lock() + defer s.ticketNoSQLiteMu.Unlock() + + return s.nextWithRetry(sqls.DB(), now) +} + +func (s *ticketNoSequenceService) nextWithRetry(tx *gorm.DB, now time.Time) (string, error) { + dateKey := now.Format("20060102") + for attempt := 0; attempt < 100; attempt++ { + current, err := repositories.TicketNoSequenceRepository.GetByDateKeyForUpdate(tx, dateKey) + if err != nil { + if isRetriableTicketNoError(tx, err) { + sleepTicketNoRetry(attempt) + continue + } + return "", err + } + if current == nil { + item := &models.TicketNoSequence{ + DateKey: dateKey, + NextSeq: 2, + CreatedAt: now, + UpdatedAt: now, + } + err := repositories.TicketNoSequenceRepository.Create(tx, item) + if err == nil { + return formatTicketNo(dateKey, 1), nil + } + if !isRetriableTicketNoError(tx, err) { + return "", err + } + + current, err = repositories.TicketNoSequenceRepository.GetByDateKeyForUpdate(tx, dateKey) + if err != nil { + if isRetriableTicketNoError(tx, err) { + sleepTicketNoRetry(attempt) + continue + } + return "", err + } + if current == nil { + sleepTicketNoRetry(attempt) + continue + } + } + allocated := current.NextSeq + ok, err := repositories.TicketNoSequenceRepository.UpdateNextSeq(tx, current.ID, allocated, allocated+1, now) + if err != nil { + if isRetriableTicketNoError(tx, err) { + sleepTicketNoRetry(attempt) + continue + } + return "", err + } + if ok { + return formatTicketNo(dateKey, allocated), nil + } + sleepTicketNoRetry(attempt) + } + return "", fmt.Errorf("failed to allocate ticket number") +} + +func sleepTicketNoRetry(attempt int) { + delay := time.Duration(attempt+1) * 10 * time.Millisecond + if delay > 200*time.Millisecond { + delay = 200 * time.Millisecond + } + time.Sleep(delay) +} + +func formatTicketNo(dateKey string, seq int64) string { + return fmt.Sprintf("TK%s%05d", dateKey, seq) +} + +func isDuplicateKeyError(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "duplicate") || strings.Contains(message, "unique") || strings.Contains(message, "constraint failed") +} + +func isRetriableTicketNoError(tx *gorm.DB, err error) bool { + if isDuplicateKeyError(err) { + return true + } + return tx != nil && tx.Dialector.Name() == "sqlite" && isSQLiteDatabaseLockedError(err) +} + +func isSQLiteDatabaseLockedError(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "database is locked") || + strings.Contains(message, "database table is locked") || + strings.Contains(message, "database is busy") +} diff --git a/internal/services/ticket_progress_service.go b/internal/services/ticket_progress_service.go new file mode 100644 index 0000000..26e4a65 --- /dev/null +++ b/internal/services/ticket_progress_service.go @@ -0,0 +1,67 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var TicketProgressService = newTicketProgressService() + +func newTicketProgressService() *ticketProgressService { + return &ticketProgressService{} +} + +type ticketProgressService struct { +} + +func (s *ticketProgressService) Get(id int64) *models.TicketProgress { + return repositories.TicketProgressRepository.Get(sqls.DB(), id) +} + +func (s *ticketProgressService) Take(where ...any) *models.TicketProgress { + return repositories.TicketProgressRepository.Take(sqls.DB(), where...) +} + +func (s *ticketProgressService) Find(cnd *sqls.Cnd) []models.TicketProgress { + return repositories.TicketProgressRepository.Find(sqls.DB(), cnd) +} + +func (s *ticketProgressService) FindOne(cnd *sqls.Cnd) *models.TicketProgress { + return repositories.TicketProgressRepository.FindOne(sqls.DB(), cnd) +} + +func (s *ticketProgressService) FindPageByParams(params *params.QueryParams) (list []models.TicketProgress, paging *sqls.Paging) { + return repositories.TicketProgressRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *ticketProgressService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketProgress, paging *sqls.Paging) { + return repositories.TicketProgressRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *ticketProgressService) Count(cnd *sqls.Cnd) int64 { + return repositories.TicketProgressRepository.Count(sqls.DB(), cnd) +} + +func (s *ticketProgressService) Create(t *models.TicketProgress) error { + return repositories.TicketProgressRepository.Create(sqls.DB(), t) +} + +func (s *ticketProgressService) Update(t *models.TicketProgress) error { + return repositories.TicketProgressRepository.Update(sqls.DB(), t) +} + +func (s *ticketProgressService) Updates(id int64, columns map[string]any) error { + return repositories.TicketProgressRepository.Updates(sqls.DB(), id, columns) +} + +func (s *ticketProgressService) UpdateColumn(id int64, name string, value any) error { + return repositories.TicketProgressRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *ticketProgressService) Delete(id int64) { + repositories.TicketProgressRepository.Delete(sqls.DB(), id) +} diff --git a/internal/services/ticket_service.go b/internal/services/ticket_service.go new file mode 100644 index 0000000..7b6a397 --- /dev/null +++ b/internal/services/ticket_service.go @@ -0,0 +1,690 @@ +package services + +import ( + "context" + "strings" + "time" + + "agent-desk/internal/events" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/eventbus" + "agent-desk/internal/pkg/i18nx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var TicketService = newTicketService() + +func newTicketService() *ticketService { + return &ticketService{} +} + +type TicketDetailAggregate struct { + Ticket *models.Ticket + Tags []models.Tag + Customer *models.Customer + Progresses []models.TicketProgress + Users map[int64]*models.User +} + +type TicketSummaryAggregate struct { + All int64 + Pending int64 + InProgress int64 + Done int64 + Unassigned int64 + Mine int64 + Stale int64 +} + +type TicketListAggregate struct { + List []models.Ticket + Paging *sqls.Paging + TagsByTicketID map[int64][]models.Tag + Users map[int64]*models.User + Customers map[int64]*models.Customer +} + +type ticketService struct { +} + +func normalizeTicketStaleHours(staleHours int) int { + switch staleHours { + case 24, 48, 168: + return staleHours + default: + return 24 + } +} + +func buildTicketAssignmentProgressContent(fromUser *models.User, toUser *models.User, reason string) string { + fromName := ticketAssignmentUserDisplayName(fromUser) + if fromName == "" { + fromName = "未分配" + } + toName := ticketAssignmentUserDisplayName(toUser) + if toName == "" && toUser != nil { + toName = toUser.Username + } + content := "指派处理人:" + fromName + " -> " + toName + if trimmedReason := strings.TrimSpace(reason); trimmedReason != "" { + content += ",原因:" + trimmedReason + } + return content +} + +func ticketAssignmentUserDisplayName(user *models.User) string { + if user == nil { + return "" + } + if strings.TrimSpace(user.Nickname) != "" { + return strings.TrimSpace(user.Nickname) + } + return strings.TrimSpace(user.Username) +} + +func (s *ticketService) Get(id int64) *models.Ticket { + return repositories.TicketRepository.Get(sqls.DB(), id) +} + +func (s *ticketService) Take(where ...any) *models.Ticket { + return repositories.TicketRepository.Take(sqls.DB(), where...) +} + +func (s *ticketService) Find(cnd *sqls.Cnd) []models.Ticket { + return repositories.TicketRepository.Find(sqls.DB(), cnd) +} + +func (s *ticketService) FindOne(cnd *sqls.Cnd) *models.Ticket { + return repositories.TicketRepository.FindOne(sqls.DB(), cnd) +} + +func (s *ticketService) FindPageByParams(params *params.QueryParams) (list []models.Ticket, paging *sqls.Paging) { + return repositories.TicketRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *ticketService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Ticket, paging *sqls.Paging) { + return repositories.TicketRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *ticketService) FindPageAggregateByCnd(cnd *sqls.Cnd, _ int64) (*TicketListAggregate, error) { + list, paging := repositories.TicketRepository.FindPageByCnd(sqls.DB(), cnd) + return s.buildTicketListAggregate(sqls.DB(), list, paging), nil +} + +func (s *ticketService) ApplyStaleFilter(cnd *sqls.Cnd, staleHours int) *sqls.Cnd { + if cnd == nil { + cnd = sqls.NewCnd() + } + staleHour := normalizeTicketStaleHours(staleHours) + return cnd. + NotEq("status", enums.TicketStatusDone). + Where("updated_at < ?", time.Now().Add(-time.Duration(staleHour)*time.Hour)) +} + +func (s *ticketService) Count(cnd *sqls.Cnd) int64 { + return repositories.TicketRepository.Count(sqls.DB(), cnd) +} + +func (s *ticketService) Create(t *models.Ticket) error { + return repositories.TicketRepository.Create(sqls.DB(), t) +} + +func (s *ticketService) Update(t *models.Ticket) error { + return repositories.TicketRepository.Update(sqls.DB(), t) +} + +func (s *ticketService) Updates(id int64, columns map[string]any) error { + return repositories.TicketRepository.Updates(sqls.DB(), id, columns) +} + +func (s *ticketService) UpdateColumn(id int64, name string, value any) error { + return repositories.TicketRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *ticketService) Delete(id int64) { + repositories.TicketRepository.Delete(sqls.DB(), id) +} + +func (s *ticketService) GetTags(ticketID int64) []models.Tag { + if ticketID <= 0 { + return nil + } + relations := TicketTagService.Find(sqls.NewCnd().Eq("ticket_id", ticketID).Asc("id")) + if len(relations) == 0 { + return nil + } + tagIDs := make([]int64, 0, len(relations)) + for i := range relations { + tagIDs = append(tagIDs, relations[i].TagID) + } + tags := repositories.TagRepository.Find(sqls.DB(), sqls.NewCnd().In("id", tagIDs)) + if len(tags) <= 1 { + return tags + } + tagMap := make(map[int64]models.Tag, len(tags)) + for i := range tags { + tagMap[tags[i].ID] = tags[i] + } + ordered := make([]models.Tag, 0, len(relations)) + for _, tagID := range tagIDs { + if tag, ok := tagMap[tagID]; ok { + ordered = append(ordered, tag) + } + } + return ordered +} + +func (s *ticketService) CreateTicket(req request.CreateTicketRequest, operator *dto.AuthPrincipal) (*models.Ticket, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + title := strings.TrimSpace(req.Title) + description := strings.TrimSpace(req.Description) + if title == "" { + return nil, errorsx.InvalidParamI18n("error.e0181") + } + if description == "" { + return nil, errorsx.InvalidParamI18n("error.e0179") + } + source := enums.TicketSource(strings.TrimSpace(req.Source)) + if source == "" { + source = enums.TicketSourceManual + } + if !enums.IsValidTicketSource(string(source)) { + return nil, errorsx.InvalidParamI18n("error.e0180") + } + if err := s.validateTicketRefs(req.CustomerID, req.ConversationID, req.CurrentAssigneeID); err != nil { + return nil, err + } + tagIDs, err := TicketTagService.ValidateTagIDs(req.TagIDs) + if err != nil { + return nil, err + } + + ticket := &models.Ticket{ + Title: title, + Description: description, + Source: source, + Channel: strings.TrimSpace(req.Channel), + CustomerID: req.CustomerID, + ConversationID: req.ConversationID, + Status: enums.TicketStatusPending, + CurrentAssigneeID: req.CurrentAssigneeID, + AuditFields: utils.BuildAuditFields(operator), + } + + ticketNo, err := TicketNoSequenceService.Next(ticket.CreatedAt) + if err != nil { + return nil, err + } + + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + ticket.TicketNo = ticketNo + if err := repositories.TicketRepository.Create(ctx.Tx, ticket); err != nil { + return err + } + if err := TicketTagService.ReplaceTicketTags(ctx.Tx, ticket.ID, tagIDs, operator); err != nil { + return err + } + return repositories.TicketProgressRepository.Create(ctx.Tx, &models.TicketProgress{ + TicketID: ticket.ID, + Content: "Created ticket", + AuthorID: operator.UserID, + CreatedAt: time.Now(), + }) + }); err != nil { + return nil, err + } + + eventbus.PublishAsync(context.Background(), events.TicketCreatedEvent{ + TicketID: ticket.ID, + OperatorID: operator.UserID, + }) + return s.Get(ticket.ID), nil +} + +func (s *ticketService) CreateFromConversation(req request.CreateTicketFromConversationRequest, operator *dto.AuthPrincipal) (*models.Ticket, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + conversation := ConversationService.Get(req.ConversationID) + if conversation == nil { + return nil, errorsx.InvalidParamI18n("error.e0116") + } + title := strings.TrimSpace(req.Title) + if title == "" { + title = strings.TrimSpace(ConversationService.BuildConversationSummary(conversation)) + } + if title == "" { + title = i18nx.Get("ticket.defaultConversationTitle") + } + description := strings.TrimSpace(req.Description) + if description == "" { + description = strings.TrimSpace(conversation.LastMessageSummary) + } + if description == "" { + description = title + } + return s.CreateTicket(request.CreateTicketRequest{ + Title: title, + Description: description, + Source: string(enums.TicketSourceConversation), + Channel: s.resolveConversationChannel(conversation), + CustomerID: conversation.CustomerID, + ConversationID: conversation.ID, + TagIDs: req.TagIDs, + CurrentAssigneeID: req.CurrentAssigneeID, + }, operator) +} + +func (s *ticketService) UpdateTicket(req request.UpdateTicketRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + title := strings.TrimSpace(req.Title) + description := strings.TrimSpace(req.Description) + if title == "" { + return errorsx.InvalidParamI18n("error.e0181") + } + if description == "" { + return errorsx.InvalidParamI18n("error.e0179") + } + ticket := s.Get(req.TicketID) + if ticket == nil { + return errorsx.InvalidParamI18n("error.e0178") + } + if err := s.validateAssignee(req.CurrentAssigneeID); err != nil { + return err + } + tagIDs, err := TicketTagService.ValidateTagIDs(req.TagIDs) + if err != nil { + return err + } + now := time.Now() + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.TicketRepository.Updates(ctx.Tx, ticket.ID, map[string]any{ + "title": title, + "description": description, + "current_assignee_id": req.CurrentAssigneeID, + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }); err != nil { + return err + } + return TicketTagService.ReplaceTicketTags(ctx.Tx, ticket.ID, tagIDs, operator) + }) +} + +func (s *ticketService) LinkCustomer(ticketID int64, customerID int64, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + ticket := s.Get(ticketID) + if ticket == nil { + return errorsx.InvalidParamI18n("error.e0178") + } + if customerID <= 0 || CustomerService.Get(customerID) == nil { + return errorsx.InvalidParamI18n("error.e0155") + } + if ticket.ConversationID > 0 { + conversation := ConversationService.Get(ticket.ConversationID) + if conversation == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + if conversation.CustomerID > 0 && conversation.CustomerID != customerID { + return errorsx.InvalidParamI18n("error.e0118") + } + } + now := time.Now() + return repositories.TicketRepository.Updates(sqls.DB(), ticket.ID, map[string]any{ + "customer_id": customerID, + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }) +} + +func (s *ticketService) AssignTicket(req request.AssignTicketRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + var assignedEvent *events.TicketAssignedEvent + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + event, err := s.assignTicketTx(ctx.Tx, req, operator) + if err != nil { + return err + } + assignedEvent = event + return nil + }); err != nil { + return err + } + if assignedEvent != nil { + eventbus.PublishAsync(context.Background(), *assignedEvent) + } + return nil +} + +func (s *ticketService) ChangeStatus(req request.ChangeTicketStatusRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + status := strings.TrimSpace(req.Status) + if !enums.IsValidTicketStatus(status) { + return errorsx.InvalidParamI18n("error.e0182") + } + ticket := s.Get(req.TicketID) + if ticket == nil { + return errorsx.InvalidParamI18n("error.e0178") + } + now := time.Now() + var handledAt *time.Time + if enums.TicketStatus(status) == enums.TicketStatusDone { + handledAt = &now + } + return repositories.TicketRepository.Updates(sqls.DB(), ticket.ID, map[string]any{ + "status": enums.TicketStatus(status), + "handled_at": handledAt, + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }) +} + +func (s *ticketService) AddProgress(req request.CreateTicketProgressRequest, operator *dto.AuthPrincipal) (*models.TicketProgress, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + content := strings.TrimSpace(req.Content) + if content == "" { + return nil, errorsx.InvalidParamI18n("error.e0148") + } + ticket := s.Get(req.TicketID) + if ticket == nil { + return nil, errorsx.InvalidParamI18n("error.e0178") + } + now := time.Now() + progress := &models.TicketProgress{ + TicketID: ticket.ID, + Content: content, + AuthorID: operator.UserID, + CreatedAt: now, + } + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.TicketProgressRepository.Create(ctx.Tx, progress); err != nil { + return err + } + return repositories.TicketRepository.Updates(ctx.Tx, ticket.ID, map[string]any{ + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }) + }); err != nil { + return nil, err + } + return progress, nil +} + +func (s *ticketService) GetDetail(id int64) (*TicketDetailAggregate, error) { + ticket := s.Get(id) + if ticket == nil { + return nil, errorsx.InvalidParamI18n("error.e0178") + } + aggregate := &TicketDetailAggregate{ + Ticket: ticket, + Tags: s.GetTags(id), + Progresses: repositories.TicketProgressRepository.Find(sqls.DB(), sqls.NewCnd().Eq("ticket_id", id).Asc("id")), + Users: make(map[int64]*models.User), + } + if ticket.CustomerID > 0 { + aggregate.Customer = CustomerService.Get(ticket.CustomerID) + } + userIDs := make([]int64, 0) + seen := make(map[int64]struct{}) + addUserID := func(userID int64) { + if userID <= 0 { + return + } + if _, ok := seen[userID]; ok { + return + } + seen[userID] = struct{}{} + userIDs = append(userIDs, userID) + } + addUserID(ticket.CurrentAssigneeID) + for i := range aggregate.Progresses { + addUserID(aggregate.Progresses[i].AuthorID) + } + if len(userIDs) > 0 { + users := repositories.UserRepository.FindByIds(sqls.DB(), userIDs) + for i := range users { + item := users[i] + aggregate.Users[item.ID] = &item + } + } + return aggregate, nil +} + +func (s *ticketService) GetSummary(operator *dto.AuthPrincipal, staleHours ...int) *TicketSummaryAggregate { + staleHour := 0 + if len(staleHours) > 0 { + staleHour = staleHours[0] + } + summary := &TicketSummaryAggregate{ + All: s.Count(sqls.NewCnd()), + Pending: s.Count(sqls.NewCnd().Eq("status", enums.TicketStatusPending)), + InProgress: s.Count(sqls.NewCnd().Eq("status", enums.TicketStatusInProgress)), + Done: s.Count(sqls.NewCnd().Eq("status", enums.TicketStatusDone)), + Unassigned: s.Count(sqls.NewCnd().Eq("current_assignee_id", 0)), + Stale: s.Count(s.ApplyStaleFilter(sqls.NewCnd(), staleHour)), + } + if operator != nil { + summary.Mine = s.Count(sqls.NewCnd().Eq("current_assignee_id", operator.UserID)) + } + return summary +} + +func (s *ticketService) assignTicketTx(tx *gorm.DB, req request.AssignTicketRequest, operator *dto.AuthPrincipal) (*events.TicketAssignedEvent, error) { + ticket := repositories.TicketRepository.Get(tx, req.TicketID) + if ticket == nil { + return nil, errorsx.InvalidParamI18n("error.e0178") + } + if err := s.validateRequiredAssignee(req.ToUserID); err != nil { + return nil, err + } + toUser := repositories.UserRepository.Get(tx, req.ToUserID) + if toUser == nil || toUser.Status != enums.StatusOk { + return nil, errorsx.InvalidParamI18n("error.e0334") + } + var fromUser *models.User + if ticket.CurrentAssigneeID > 0 { + fromUser = repositories.UserRepository.Get(tx, ticket.CurrentAssigneeID) + } + now := time.Now() + if err := repositories.TicketRepository.Updates(tx, ticket.ID, map[string]any{ + "current_assignee_id": req.ToUserID, + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }); err != nil { + return nil, err + } + if err := repositories.TicketProgressRepository.Create(tx, &models.TicketProgress{ + TicketID: ticket.ID, + Content: buildTicketAssignmentProgressContent(fromUser, toUser, req.Reason), + AuthorID: operator.UserID, + CreatedAt: now, + }); err != nil { + return nil, err + } + return &events.TicketAssignedEvent{ + TicketID: ticket.ID, + FromUserID: ticket.CurrentAssigneeID, + ToUserID: req.ToUserID, + OperatorID: operator.UserID, + Reason: strings.TrimSpace(req.Reason), + }, nil +} + +func (s *ticketService) buildTicketListAggregate(db *gorm.DB, list []models.Ticket, paging *sqls.Paging) *TicketListAggregate { + aggregate := &TicketListAggregate{ + List: list, + Paging: paging, + TagsByTicketID: make(map[int64][]models.Tag), + Users: make(map[int64]*models.User), + Customers: make(map[int64]*models.Customer), + } + if len(list) == 0 { + return aggregate + } + ticketIDs := make([]int64, 0, len(list)) + customerIDs := make([]int64, 0) + userIDs := make([]int64, 0) + ticketSeen := make(map[int64]struct{}) + customerSeen := make(map[int64]struct{}) + userSeen := make(map[int64]struct{}) + for i := range list { + item := &list[i] + if _, ok := ticketSeen[item.ID]; !ok { + ticketSeen[item.ID] = struct{}{} + ticketIDs = append(ticketIDs, item.ID) + } + if item.CustomerID > 0 { + if _, ok := customerSeen[item.CustomerID]; !ok { + customerSeen[item.CustomerID] = struct{}{} + customerIDs = append(customerIDs, item.CustomerID) + } + } + if item.CurrentAssigneeID > 0 { + if _, ok := userSeen[item.CurrentAssigneeID]; !ok { + userSeen[item.CurrentAssigneeID] = struct{}{} + userIDs = append(userIDs, item.CurrentAssigneeID) + } + } + } + s.enrichTicketTags(db, aggregate, ticketIDs) + if len(userIDs) > 0 { + users := repositories.UserRepository.FindByIds(db, userIDs) + for i := range users { + item := users[i] + aggregate.Users[item.ID] = &item + } + } + if len(customerIDs) > 0 { + customers := repositories.CustomerRepository.Find(db, sqls.NewCnd().In("id", customerIDs)) + for i := range customers { + item := customers[i] + aggregate.Customers[item.ID] = &item + } + } + return aggregate +} + +func (s *ticketService) enrichTicketTags(db *gorm.DB, aggregate *TicketListAggregate, ticketIDs []int64) { + if len(ticketIDs) == 0 { + return + } + ticketTags := repositories.TicketTagRepository.Find(db, sqls.NewCnd().In("ticket_id", ticketIDs).Asc("id")) + if len(ticketTags) == 0 { + return + } + tagIDs := make([]int64, 0) + tagSeen := make(map[int64]struct{}) + ticketTagMap := make(map[int64][]int64, len(ticketIDs)) + for i := range ticketTags { + relation := ticketTags[i] + ticketTagMap[relation.TicketID] = append(ticketTagMap[relation.TicketID], relation.TagID) + if _, ok := tagSeen[relation.TagID]; !ok { + tagSeen[relation.TagID] = struct{}{} + tagIDs = append(tagIDs, relation.TagID) + } + } + tags := repositories.TagRepository.Find(db, sqls.NewCnd().In("id", tagIDs)) + tagMap := make(map[int64]models.Tag, len(tags)) + for i := range tags { + tagMap[tags[i].ID] = tags[i] + } + for ticketID, orderedTagIDs := range ticketTagMap { + orderedTags := make([]models.Tag, 0, len(orderedTagIDs)) + for _, tagID := range orderedTagIDs { + if tag, ok := tagMap[tagID]; ok { + orderedTags = append(orderedTags, tag) + } + } + aggregate.TagsByTicketID[ticketID] = orderedTags + } +} + +func (s *ticketService) validateTicketRefs(customerID, conversationID, assigneeID int64) error { + if customerID > 0 && CustomerService.Get(customerID) == nil { + return errorsx.InvalidParamI18n("error.e0155") + } + if conversationID > 0 { + conversation := ConversationService.Get(conversationID) + if conversation == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + if customerID > 0 && conversation.CustomerID != customerID { + return errorsx.InvalidParamI18n("error.e0118") + } + } + return s.validateAssignee(assigneeID) +} + +func (s *ticketService) validateAssignee(userID int64) error { + if userID <= 0 { + return nil + } + return s.validateRequiredAssignee(userID) +} + +func (s *ticketService) validateRequiredAssignee(userID int64) error { + if userID <= 0 { + return errorsx.InvalidParamI18n("error.e0334") + } + user := UserService.Get(userID) + if user == nil || user.Status != enums.StatusOk { + return errorsx.InvalidParamI18n("error.e0334") + } + return nil +} + +func (s *ticketService) resolveConversationChannel(conversation *models.Conversation) string { + if conversation == nil || conversation.ChannelID <= 0 { + return "" + } + if channel := ChannelService.Get(conversation.ChannelID); channel != nil { + return channel.ChannelType + } + return "" +} + +func normalizeInt64IDs(ids []int64) []int64 { + if len(ids) == 0 { + return nil + } + seen := make(map[int64]struct{}, len(ids)) + result := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} diff --git a/internal/services/ticket_service_test.go b/internal/services/ticket_service_test.go new file mode 100644 index 0000000..7333139 --- /dev/null +++ b/internal/services/ticket_service_test.go @@ -0,0 +1,718 @@ +package services_test + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "agent-desk/internal/bootstrap" + "agent-desk/internal/events" + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/eventbus" + "agent-desk/internal/repositories" + "agent-desk/internal/services" + + "github.com/mlogclub/simple/sqls" +) + +func TestTicketLightweightStatuses(t *testing.T) { + if !enums.IsValidTicketStatus(string(enums.TicketStatusPending)) { + t.Fatalf("pending should be valid") + } + if !enums.IsValidTicketStatus(string(enums.TicketStatusInProgress)) { + t.Fatalf("in_progress should be valid") + } + if !enums.IsValidTicketStatus(string(enums.TicketStatusDone)) { + t.Fatalf("done should be valid") + } + for _, status := range []string{"new", "open", "pending_customer", "pending_internal", "resolved", "closed", "cancelled"} { + if enums.IsValidTicketStatus(status) { + t.Fatalf("legacy status %s should be invalid", status) + } + } +} + +func TestTicketProgressModelExists(t *testing.T) { + item := models.TicketProgress{ + TicketID: 12, + Content: "已电话联系客户确认问题仍存在", + AuthorID: 7, + } + if item.TicketID != 12 || item.AuthorID != 7 || item.Content == "" { + t.Fatalf("unexpected progress model: %+v", item) + } +} + +func TestTicketServiceCreateTicketSetsPendingStatusAndTicketNo(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "creator") + customerID := createTestCustomer(t, "create-customer") + tagID := createTestTag(t, "create-tag") + + created, err := services.TicketService.CreateTicket(request.CreateTicketRequest{ + Title: "create ticket", + Description: "create ticket description", + CustomerID: customerID, + TagIDs: []int64{tagID}, + CurrentAssigneeID: operator.UserID, + }, operator) + if err != nil { + t.Fatalf("CreateTicket() error = %v", err) + } + if created.TicketNo == "" || !strings.HasPrefix(created.TicketNo, "TK") { + t.Fatalf("expected generated ticket number, got %q", created.TicketNo) + } + if created.Status != enums.TicketStatusPending { + t.Fatalf("expected pending status, got %s", created.Status) + } + if created.Source != enums.TicketSourceManual { + t.Fatalf("expected manual source, got %s", created.Source) + } + + progresses := services.TicketProgressService.Find(sqls.NewCnd().Eq("ticket_id", created.ID)) + if len(progresses) != 1 { + t.Fatalf("expected initial progress, got %d", len(progresses)) + } + if progresses[0].Content != "Created ticket" || progresses[0].AuthorID != operator.UserID { + t.Fatalf("unexpected initial progress: %+v", progresses[0]) + } + + tags := services.TicketService.GetTags(created.ID) + if len(tags) != 1 || tags[0].ID != tagID { + t.Fatalf("expected ticket tag %d, got %+v", tagID, tags) + } +} + +func TestTicketServiceCreateTicketPublishesTicketCreatedEvent(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "event-creator") + eventsCh := make(chan events.TicketCreatedEvent, 1) + _, unsubscribe := eventbus.Subscribe(func(ctx context.Context, event events.TicketCreatedEvent) error { + eventsCh <- event + return nil + }) + defer unsubscribe() + + created, err := services.TicketService.CreateTicket(createTestTicketRequest("event-ticket"), operator) + if err != nil { + t.Fatalf("CreateTicket() error = %v", err) + } + + select { + case event := <-eventsCh: + if event.TicketID != created.ID { + t.Fatalf("expected ticket id %d, got %d", created.ID, event.TicketID) + } + if event.OperatorID != operator.UserID { + t.Fatalf("expected operator id %d, got %d", operator.UserID, event.OperatorID) + } + case <-time.After(time.Second): + t.Fatalf("expected ticket created event") + } +} + +func TestTicketServiceLinkCustomerUpdatesTicketCustomerID(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "link-ticket-customer") + customerID := createTestCustomer(t, "link-ticket-customer") + ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("link-ticket-customer"), operator) + if err != nil { + t.Fatalf("CreateTicket() error = %v", err) + } + if ticket.CustomerID != 0 { + t.Fatalf("expected ticket without customer, got %d", ticket.CustomerID) + } + + if err := services.TicketService.LinkCustomer(ticket.ID, customerID, operator); err != nil { + t.Fatalf("LinkCustomer() error = %v", err) + } + + updated := services.TicketService.Get(ticket.ID) + if updated == nil { + t.Fatalf("expected ticket") + } + if updated.CustomerID != customerID { + t.Fatalf("expected customer id %d, got %d", customerID, updated.CustomerID) + } +} + +func TestTicketServiceLinkCustomerRejectsMissingCustomer(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "link-ticket-missing-customer") + ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("link-ticket-missing-customer"), operator) + if err != nil { + t.Fatalf("CreateTicket() error = %v", err) + } + + if err := services.TicketService.LinkCustomer(ticket.ID, 999999, operator); err == nil { + t.Fatalf("expected LinkCustomer() to reject missing customer") + } +} + +func TestTicketServiceChangeStatusSetsHandledAt(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "status-operator") + ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("status-ticket"), operator) + if err != nil { + t.Fatalf("CreateTicket() error = %v", err) + } + + if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{ + TicketID: ticket.ID, + Status: string(enums.TicketStatusInProgress), + }, operator); err != nil { + t.Fatalf("ChangeStatus() in_progress error = %v", err) + } + inProgress := services.TicketService.Get(ticket.ID) + if inProgress == nil { + t.Fatalf("expected ticket to exist") + } + if inProgress.Status != enums.TicketStatusInProgress { + t.Fatalf("expected in_progress status, got %s", inProgress.Status) + } + if inProgress.HandledAt != nil { + t.Fatalf("expected handled_at to remain nil before done") + } + + if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{ + TicketID: ticket.ID, + Status: string(enums.TicketStatusDone), + }, operator); err != nil { + t.Fatalf("ChangeStatus() done error = %v", err) + } + done := services.TicketService.Get(ticket.ID) + if done == nil || done.HandledAt == nil { + t.Fatalf("expected handled_at to be set after done, got %+v", done) + } + + if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{ + TicketID: ticket.ID, + Status: string(enums.TicketStatusPending), + }, operator); err != nil { + t.Fatalf("ChangeStatus() pending error = %v", err) + } + pending := services.TicketService.Get(ticket.ID) + if pending == nil || pending.HandledAt != nil { + t.Fatalf("expected handled_at to be cleared away from done, got %+v", pending) + } +} + +func TestTicketServiceAddProgressStoresContentAndAuthor(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "progress-operator") + ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("progress-ticket"), operator) + if err != nil { + t.Fatalf("CreateTicket() error = %v", err) + } + + progress, err := services.TicketService.AddProgress(request.CreateTicketProgressRequest{ + TicketID: ticket.ID, + Content: "客户已确认问题复现路径", + }, operator) + if err != nil { + t.Fatalf("AddProgress() error = %v", err) + } + if progress.ID <= 0 { + t.Fatalf("expected progress id") + } + if progress.Content != "客户已确认问题复现路径" || progress.AuthorID != operator.UserID { + t.Fatalf("unexpected progress: %+v", progress) + } +} + +func TestTicketServiceCreateTicketPreservesRichDescription(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "rich-description-operator") + + created, err := services.TicketService.CreateTicket(request.CreateTicketRequest{ + Title: "rich description ticket", + Description: "

客户反馈无法登录

  • 验证码错误
", + }, operator) + if err != nil { + t.Fatalf("CreateTicket() error = %v", err) + } + if created.Description != "

客户反馈无法登录

  • 验证码错误
" { + t.Fatalf("expected rich description to be preserved, got %q", created.Description) + } +} + +func TestTicketServiceAddProgressPreservesRichContent(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "rich-progress-operator") + ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("rich-progress-ticket"), operator) + if err != nil { + t.Fatalf("CreateTicket() error = %v", err) + } + + progress, err := services.TicketService.AddProgress(request.CreateTicketProgressRequest{ + TicketID: ticket.ID, + Content: "

已回访客户,结论:继续观察

", + }, operator) + if err != nil { + t.Fatalf("AddProgress() error = %v", err) + } + if progress.Content != "

已回访客户,结论:继续观察

" { + t.Fatalf("expected rich progress content to be preserved, got %q", progress.Content) + } +} + +func TestTicketServiceAssignTicketRequiresTargetUser(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "assign-operator") + ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("assign-ticket"), operator) + if err != nil { + t.Fatalf("CreateTicket() error = %v", err) + } + + err = services.TicketService.AssignTicket(request.AssignTicketRequest{ + TicketID: ticket.ID, + ToUserID: 0, + Reason: "invalid assignment", + }, operator) + if err == nil { + t.Fatalf("expected AssignTicket() to reject empty target user") + } +} + +func TestTicketServiceAssignTicketRejectsDisabledUser(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "assign-disabled-operator") + disabledUserID := createTestUserWithStatus(t, "assign-disabled-user", enums.StatusDisabled) + ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("assign-disabled-ticket"), operator) + if err != nil { + t.Fatalf("CreateTicket() error = %v", err) + } + + err = services.TicketService.AssignTicket(request.AssignTicketRequest{ + TicketID: ticket.ID, + ToUserID: disabledUserID, + Reason: "disabled assignment", + }, operator) + if err == nil { + t.Fatalf("expected AssignTicket() to reject disabled target user") + } +} + +func TestTicketServiceAssignTicketCreatesProgressEntry(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "assign-progress-operator") + firstAssignee := createTestOperator(t, "assign-progress-first") + nextAssignee := createTestOperator(t, "assign-progress-next") + ticket, err := services.TicketService.CreateTicket(request.CreateTicketRequest{ + Title: "assign progress ticket", + Description: "assign progress description", + CurrentAssigneeID: firstAssignee.UserID, + }, operator) + if err != nil { + t.Fatalf("CreateTicket() error = %v", err) + } + + if err := services.TicketService.AssignTicket(request.AssignTicketRequest{ + TicketID: ticket.ID, + ToUserID: nextAssignee.UserID, + Reason: "需要二线继续跟进", + }, operator); err != nil { + t.Fatalf("AssignTicket() error = %v", err) + } + + progresses := services.TicketProgressService.Find(sqls.NewCnd().Eq("ticket_id", ticket.ID).Asc("id")) + if len(progresses) != 2 { + t.Fatalf("expected create progress and assignment progress, got %d: %+v", len(progresses), progresses) + } + assignmentProgress := progresses[1] + if assignmentProgress.AuthorID != operator.UserID { + t.Fatalf("expected assignment progress author %d, got %d", operator.UserID, assignmentProgress.AuthorID) + } + if !strings.Contains(assignmentProgress.Content, "指派处理人") { + t.Fatalf("expected assignment progress content to mention assignment, got %q", assignmentProgress.Content) + } + if !strings.Contains(assignmentProgress.Content, firstAssignee.Username) || !strings.Contains(assignmentProgress.Content, nextAssignee.Username) { + t.Fatalf("expected assignment progress to include assignee names, got %q", assignmentProgress.Content) + } + if !strings.Contains(assignmentProgress.Content, "需要二线继续跟进") { + t.Fatalf("expected assignment reason in progress content, got %q", assignmentProgress.Content) + } +} + +func TestTicketServiceCreateTicketRejectsMismatchedCustomerConversation(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "mismatch-operator") + customerID := createTestCustomer(t, "mismatch-customer") + otherCustomerID := createTestCustomer(t, "mismatch-other-customer") + conversationID := createTestConversation(t, otherCustomerID, "mismatch-conversation") + + _, err := services.TicketService.CreateTicket(request.CreateTicketRequest{ + Title: "mismatch ticket", + Description: "mismatch ticket description", + CustomerID: customerID, + ConversationID: conversationID, + }, operator) + if err == nil { + t.Fatalf("expected CreateTicket() to reject mismatched customer and conversation") + } +} + +func TestTicketServiceSummaryCountsStaleTickets(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "summary-operator") + mine, err := services.TicketService.CreateTicket(request.CreateTicketRequest{ + Title: "mine stale ticket", + Description: "mine stale description", + CurrentAssigneeID: operator.UserID, + }, operator) + if err != nil { + t.Fatalf("CreateTicket() mine error = %v", err) + } + if _, err := services.TicketService.CreateTicket(createTestTicketRequest("unassigned ticket"), operator); err != nil { + t.Fatalf("CreateTicket() unassigned error = %v", err) + } + staleUpdatedAt := time.Now().Add(-36 * time.Hour) + if err := repositories.TicketRepository.Updates(sqls.DB(), mine.ID, map[string]any{ + "updated_at": staleUpdatedAt, + }); err != nil { + t.Fatalf("update stale ticket error = %v", err) + } + + summary := services.TicketService.GetSummary(operator, 24) + if summary.All != 2 { + t.Fatalf("expected all count 2, got %d", summary.All) + } + if summary.Pending != 2 { + t.Fatalf("expected pending count 2, got %d", summary.Pending) + } + if summary.Mine != 1 { + t.Fatalf("expected mine count 1, got %d", summary.Mine) + } + if summary.Unassigned != 1 { + t.Fatalf("expected unassigned count 1, got %d", summary.Unassigned) + } + if summary.Stale != 1 { + t.Fatalf("expected stale count 1, got %d", summary.Stale) + } + + summary48 := services.TicketService.GetSummary(operator, 48) + if summary48.Stale != 0 { + t.Fatalf("expected stale count 0 for 48 hour threshold, got %d", summary48.Stale) + } + summaryInvalid := services.TicketService.GetSummary(operator, 1<<30) + if summaryInvalid.Stale != 1 { + t.Fatalf("expected invalid stale threshold to use 24 hours, got %d", summaryInvalid.Stale) + } +} + +func TestTicketServiceFindPageAggregateFiltersStaleTickets(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "stale-list-operator") + staleOpen, err := services.TicketService.CreateTicket(createTestTicketRequest("stale open ticket"), operator) + if err != nil { + t.Fatalf("CreateTicket() stale open error = %v", err) + } + staleDone, err := services.TicketService.CreateTicket(createTestTicketRequest("stale done ticket"), operator) + if err != nil { + t.Fatalf("CreateTicket() stale done error = %v", err) + } + freshOpen, err := services.TicketService.CreateTicket(createTestTicketRequest("fresh open ticket"), operator) + if err != nil { + t.Fatalf("CreateTicket() fresh open error = %v", err) + } + + if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{ + TicketID: staleDone.ID, + Status: string(enums.TicketStatusDone), + }, operator); err != nil { + t.Fatalf("ChangeStatus() stale done error = %v", err) + } + staleUpdatedAt := time.Now().Add(-48 * time.Hour) + for _, ticketID := range []int64{staleOpen.ID, staleDone.ID} { + if err := repositories.TicketRepository.Updates(sqls.DB(), ticketID, map[string]any{ + "updated_at": staleUpdatedAt, + }); err != nil { + t.Fatalf("update stale ticket %d error = %v", ticketID, err) + } + } + + aggregate, err := services.TicketService.FindPageAggregateByCnd( + services.TicketService.ApplyStaleFilter(sqls.NewCnd(), 24).Page(1, 10), + operator.UserID, + ) + if err != nil { + t.Fatalf("FindPageAggregateByCnd() error = %v", err) + } + if len(aggregate.List) != 1 { + t.Fatalf("expected 1 stale non-done ticket, got %d: %+v", len(aggregate.List), aggregate.List) + } + if aggregate.List[0].ID != staleOpen.ID { + t.Fatalf("expected stale open ticket %d, got %d", staleOpen.ID, aggregate.List[0].ID) + } + if aggregate.List[0].ID == freshOpen.ID || aggregate.List[0].ID == staleDone.ID { + t.Fatalf("stale list included fresh or done ticket: %+v", aggregate.List[0]) + } +} + +func TestTicketServiceFindPageAggregateEnrichesLookups(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "aggregate-operator") + assignee := createTestOperator(t, "aggregate-assignee") + customerID := createTestCustomer(t, "aggregate-customer") + tagID := createTestTag(t, "aggregate-tag") + + ticket, err := services.TicketService.CreateTicket(request.CreateTicketRequest{ + Title: "aggregate ticket", + Description: "aggregate description", + CustomerID: customerID, + TagIDs: []int64{tagID}, + CurrentAssigneeID: assignee.UserID, + }, operator) + if err != nil { + t.Fatalf("CreateTicket() error = %v", err) + } + + aggregate, err := services.TicketService.FindPageAggregateByCnd(sqls.NewCnd().Eq("id", ticket.ID).Page(1, 10), operator.UserID) + if err != nil { + t.Fatalf("FindPageAggregateByCnd() error = %v", err) + } + if len(aggregate.List) != 1 { + t.Fatalf("expected 1 ticket, got %d", len(aggregate.List)) + } + if len(aggregate.TagsByTicketID[ticket.ID]) != 1 || aggregate.TagsByTicketID[ticket.ID][0].ID != tagID { + t.Fatalf("expected tag lookup to be populated") + } + if aggregate.Customers[customerID] == nil { + t.Fatalf("expected customer lookup to be populated") + } + if aggregate.Users[assignee.UserID] == nil { + t.Fatalf("expected assignee lookup to be populated") + } +} + +func TestTicketServiceTicketNoNextConcurrent(t *testing.T) { + setupTicketTestDBWithMaxOpenConns(t, 8) + + const count = 50 + results := make(chan string, count) + errs := make(chan error, count) + var wg sync.WaitGroup + + for range count { + wg.Add(1) + go func() { + defer wg.Done() + ticketNo, err := services.TicketNoSequenceService.Next(time.Now()) + if err != nil { + errs <- err + } + results <- ticketNo + }() + } + + wg.Wait() + close(results) + close(errs) + + for err := range errs { + if err != nil { + t.Fatalf("TicketNoService.Next() concurrent error = %v", err) + } + } + + seen := make(map[string]struct{}, count) + for ticketNo := range results { + if _, ok := seen[ticketNo]; ok { + t.Fatalf("duplicate ticket number generated: %s", ticketNo) + } + seen[ticketNo] = struct{}{} + } + if len(seen) != count { + t.Fatalf("expected %d unique ticket numbers, got %d", count, len(seen)) + } +} + +func TestTicketServiceCreateTicketConcurrentAllocatesUniqueTicketNos(t *testing.T) { + setupTicketTestDBWithMaxOpenConns(t, 8) + operator := createTestOperator(t, "concurrent-create-operator") + + const count = 50 + results := make(chan string, count) + errs := make(chan error, count) + var wg sync.WaitGroup + + for i := 0; i < count; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + ticket, err := services.TicketService.CreateTicket(request.CreateTicketRequest{ + Title: fmt.Sprintf("concurrent ticket %d", index), + Description: fmt.Sprintf("concurrent ticket %d description", index), + }, operator) + if err != nil { + errs <- err + return + } + results <- ticket.TicketNo + }(i) + } + + wg.Wait() + close(results) + close(errs) + + for err := range errs { + if err != nil { + t.Fatalf("CreateTicket() concurrent error = %v", err) + } + } + + seen := make(map[string]struct{}, count) + for ticketNo := range results { + if ticketNo == "" { + t.Fatalf("expected non-empty ticket number") + } + if _, ok := seen[ticketNo]; ok { + t.Fatalf("duplicate ticket number generated: %s", ticketNo) + } + seen[ticketNo] = struct{}{} + } + if len(seen) != count { + t.Fatalf("expected %d unique ticket numbers, got %d", count, len(seen)) + } +} + +func setupTicketTestDB(t *testing.T) { + setupTicketTestDBWithMaxOpenConns(t, 0) +} + +func setupTicketTestDBWithMaxOpenConns(t *testing.T, maxOpenConns int) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "ticket-test.db") + db, err := bootstrap.InitDB(config.DBConfig{ + Type: "sqlite", + DSN: "file:" + dbPath + "?_busy_timeout=5000", + MaxIdleConns: 1, + MaxOpenConns: maxOpenConns, + }) + if err != nil { + t.Fatalf("InitDB() error = %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := bootstrap.InitMigrations(); err != nil { + t.Fatalf("InitMigrations() error = %v", err) + } +} + +func createTestTicketRequest(title string) request.CreateTicketRequest { + return request.CreateTicketRequest{ + Title: title, + Description: title + " description", + } +} + +func createTestOperator(t *testing.T, prefix string) *dto.AuthPrincipal { + t.Helper() + userID := createTestUser(t, prefix) + return &dto.AuthPrincipal{UserID: userID, Username: prefix} +} + +func createTestUser(t *testing.T, prefix string) int64 { + return createTestUserWithStatus(t, prefix, enums.StatusOk) +} + +func createTestUserWithStatus(t *testing.T, prefix string, status enums.Status) int64 { + t.Helper() + now := time.Now() + username := fmt.Sprintf("%s_%d", prefix, now.UnixNano()) + user := &models.User{ + Username: username, + Nickname: prefix, + Status: status, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: 1, + CreateUserName: "admin", + UpdatedAt: now, + UpdateUserID: 1, + UpdateUserName: "admin", + }, + } + if err := repositories.UserRepository.Create(sqls.DB(), user); err != nil { + t.Fatalf("create user error = %v", err) + } + return user.ID +} + +func createTestConversation(t *testing.T, customerID int64, prefix string) int64 { + t.Helper() + + now := time.Now() + item := &models.Conversation{ + CustomerID: customerID, + CustomerName: prefix, + Status: enums.IMConversationStatusActive, + ServiceMode: enums.IMConversationServiceModeAIOnly, + LastMessageAt: now, + LastActiveAt: now, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := repositories.ConversationRepository.Create(sqls.DB(), item); err != nil { + t.Fatalf("create conversation error = %v", err) + } + return item.ID +} + +func createTestCustomer(t *testing.T, prefix string) int64 { + t.Helper() + + now := time.Now() + item := &models.Customer{ + Name: fmt.Sprintf("%s-%d", prefix, now.UnixNano()), + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: 1, + CreateUserName: "admin", + UpdatedAt: now, + UpdateUserID: 1, + UpdateUserName: "admin", + }, + } + if err := repositories.CustomerRepository.Create(sqls.DB(), item); err != nil { + t.Fatalf("create customer error = %v", err) + } + return item.ID +} + +func createTestTag(t *testing.T, prefix string) int64 { + t.Helper() + + now := time.Now() + item := &models.Tag{ + Name: fmt.Sprintf("%s-%d", prefix, now.UnixNano()), + Status: enums.StatusOk, + SortNo: 1, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: 1, + CreateUserName: "admin", + UpdatedAt: now, + UpdateUserID: 1, + UpdateUserName: "admin", + }, + } + if err := repositories.TagRepository.Create(sqls.DB(), item); err != nil { + t.Fatalf("create tag error = %v", err) + } + return item.ID +} diff --git a/internal/services/ticket_tag_service.go b/internal/services/ticket_tag_service.go new file mode 100644 index 0000000..d597601 --- /dev/null +++ b/internal/services/ticket_tag_service.go @@ -0,0 +1,104 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/repositories" + "time" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var TicketTagService = newTicketTagService() + +func newTicketTagService() *ticketTagService { + return &ticketTagService{} +} + +type ticketTagService struct{} + +func (s *ticketTagService) Get(id int64) *models.TicketTag { + return repositories.TicketTagRepository.Get(sqls.DB(), id) +} + +func (s *ticketTagService) Take(where ...interface{}) *models.TicketTag { + return repositories.TicketTagRepository.Take(sqls.DB(), where...) +} + +func (s *ticketTagService) Find(cnd *sqls.Cnd) []models.TicketTag { + return repositories.TicketTagRepository.Find(sqls.DB(), cnd) +} + +func (s *ticketTagService) Create(db *gorm.DB, item *models.TicketTag) error { + return repositories.TicketTagRepository.Create(db, item) +} + +func (s *ticketTagService) DeleteByTicketID(db *gorm.DB, ticketID int64) error { + return repositories.TicketTagRepository.DeleteByTicketID(db, ticketID) +} + +func (s *ticketTagService) NormalizeTagIDs(tagIDs []int64) []int64 { + if len(tagIDs) == 0 { + return nil + } + seen := make(map[int64]struct{}, len(tagIDs)) + result := make([]int64, 0, len(tagIDs)) + for _, tagID := range tagIDs { + if tagID <= 0 { + continue + } + if _, ok := seen[tagID]; ok { + continue + } + seen[tagID] = struct{}{} + result = append(result, tagID) + } + return result +} + +func (s *ticketTagService) ValidateTagIDs(tagIDs []int64) ([]int64, error) { + normalized := s.NormalizeTagIDs(tagIDs) + if len(normalized) == 0 { + return nil, nil + } + tags := repositories.TagRepository.Find(sqls.DB(), sqls.NewCnd().In("id", normalized)) + if len(tags) != len(normalized) { + return nil, errorsx.InvalidParamI18n("error.e0153") + } + for i := range tags { + if tags[i].Status != enums.StatusOk { + return nil, errorsx.InvalidParamI18n("error.e0154") + } + } + return normalized, nil +} + +func (s *ticketTagService) ReplaceTicketTags(db *gorm.DB, ticketID int64, tagIDs []int64, operator *dto.AuthPrincipal) error { + if err := s.DeleteByTicketID(db, ticketID); err != nil { + return err + } + if len(tagIDs) == 0 { + return nil + } + now := time.Now() + for _, tagID := range tagIDs { + if err := s.Create(db, &models.TicketTag{ + TicketID: ticketID, + TagID: tagID, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: operator.UserID, + CreateUserName: operator.Username, + UpdatedAt: now, + UpdateUserID: operator.UserID, + UpdateUserName: operator.Username, + }, + }); err != nil { + return err + } + } + return nil +} diff --git a/internal/services/ticket_view_service.go b/internal/services/ticket_view_service.go new file mode 100644 index 0000000..6e616ea --- /dev/null +++ b/internal/services/ticket_view_service.go @@ -0,0 +1,92 @@ +package services + +import ( + "encoding/json" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +var TicketViewService = newTicketViewService() + +func newTicketViewService() *ticketViewService { + return &ticketViewService{} +} + +type ticketViewService struct { +} + +func (s *ticketViewService) Get(id int64) *models.TicketView { + return repositories.TicketViewRepository.Get(sqls.DB(), id) +} + +func (s *ticketViewService) Find(cnd *sqls.Cnd) []models.TicketView { + return repositories.TicketViewRepository.Find(sqls.DB(), cnd) +} + +func (s *ticketViewService) ListByUser(userID int64) []models.TicketView { + if userID <= 0 { + return nil + } + return s.Find(sqls.NewCnd().Eq("user_id", userID).Asc("sort_no").Desc("id")) +} + +func (s *ticketViewService) Save(req request.SaveTicketViewRequest, operator *dto.AuthPrincipal) (*models.TicketView, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errorsx.InvalidParamI18n("error.e0303") + } + filtersJSON, err := json.Marshal(req.Filters) + if err != nil { + return nil, errorsx.InvalidParamI18n("error.e0304") + } + now := time.Now() + if req.ID > 0 { + item := repositories.TicketViewRepository.Get(sqls.DB(), req.ID) + if item == nil || item.UserID != operator.UserID { + return nil, errorsx.InvalidParamI18n("error.e0302") + } + if err := repositories.TicketViewRepository.Updates(sqls.DB(), req.ID, map[string]any{ + "name": name, + "filters_json": string(filtersJSON), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return nil, err + } + return repositories.TicketViewRepository.Get(sqls.DB(), req.ID), nil + } + item := &models.TicketView{ + UserID: operator.UserID, + Name: name, + FiltersJSON: string(filtersJSON), + AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.TicketViewRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return item, nil +} + +func (s *ticketViewService) Delete(id int64, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + item := repositories.TicketViewRepository.Get(sqls.DB(), id) + if item == nil || item.UserID != operator.UserID { + return errorsx.InvalidParamI18n("error.e0302") + } + return repositories.TicketViewRepository.Delete(sqls.DB(), id) +} diff --git a/internal/services/tool_catalog_service.go b/internal/services/tool_catalog_service.go new file mode 100644 index 0000000..64cc822 --- /dev/null +++ b/internal/services/tool_catalog_service.go @@ -0,0 +1,141 @@ +package services + +import ( + "context" + "log/slog" + "slices" + "strings" + + "agent-desk/internal/ai/mcps" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/i18nx" + "agent-desk/internal/pkg/toolx" +) + +var ToolCatalogService = newToolCatalogService() + +func newToolCatalogService() *toolCatalogService { + return &toolCatalogService{} +} + +type toolCatalogService struct{} + +type MCPToolCatalogItem struct { + ToolCode string + ServerCode string + ToolName string + SourceType enums.ToolSourceType + AutoInjected bool + Title string + Description string + InputSchema any + OutputSchema any + RiskLevel string + RequireConfirmation bool + RiskEditable bool +} + +func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalogItem, error) { + return s.ListMCPToolsWithLocale(ctx, i18nx.DefaultLocale) +} + +func (s *toolCatalogService) ListMCPToolsWithLocale(ctx context.Context, locale string) ([]MCPToolCatalogItem, error) { + cfg := config.Current() + ret := make([]MCPToolCatalogItem, 0, 3) + for _, spec := range toolx.ListAgentDirectToolSpecs() { + if spec.Code == toolx.BuiltinToolSearch.Code && !cfg.MCP.Enabled { + continue + } + ret = append(ret, MCPToolCatalogItem{ + ToolCode: spec.Code, + ServerCode: spec.ServerCode, + ToolName: spec.Name, + SourceType: spec.SourceType, + AutoInjected: spec.AutoInjected, + Title: toolx.GetRegisteredToolTitleLocale(spec.Code, locale), + Description: toolx.GetRegisteredToolDescriptionLocale(spec.Code, locale), + }) + } + if !cfg.MCP.Enabled { + return ret, nil + } + serverCodes := make([]string, 0, len(cfg.MCP.Servers)) + for serverCode, server := range cfg.MCP.Servers { + if !server.Enabled { + continue + } + serverCodes = append(serverCodes, serverCode) + } + slices.Sort(serverCodes) + for _, serverCode := range serverCodes { + tools, err := mcps.Runtime.ListTools(ctx, serverCode) + if err != nil { + slog.Warn("list mcp server tools failed, skipping server", "server", serverCode, "error", err) + continue + } + for _, item := range tools { + toolCode := toolx.BuildMCPToolCode(serverCode, item.Name) + title := strings.TrimSpace(item.Title) + riskLevel := toolx.MCPRiskLevelWrite + requireConfirmation := true + riskEditable := true + if item.ReadOnlyHint { + riskLevel = toolx.MCPRiskLevelRead + requireConfirmation = false + } + if policy, ok := toolx.GetTrustedMCPToolPolicy(toolCode); ok { + title = policy.Title + riskLevel = policy.RiskLevel + requireConfirmation = policy.RequireConfirmation + riskEditable = false + } + if title == "" { + title = strings.TrimSpace(item.Name) + } + ret = append(ret, MCPToolCatalogItem{ + ToolCode: toolCode, + ServerCode: serverCode, + ToolName: strings.TrimSpace(item.Name), + SourceType: enums.ToolSourceTypeMCP, + AutoInjected: false, + Title: title, + Description: strings.TrimSpace(item.Description), + InputSchema: item.InputSchema, + OutputSchema: item.OutputSchema, + RiskLevel: riskLevel, + RequireConfirmation: requireConfirmation, + RiskEditable: riskEditable, + }) + } + } + return ret, nil +} + +func (s *toolCatalogService) ValidateMCPToolCode(toolCode string) error { + return s.ValidateToolCode(toolCode) +} + +func (s *toolCatalogService) ValidateToolCode(toolCode string) error { + cfg := config.Current() + toolCode = strings.TrimSpace(toolCode) + if toolCode == "" { + return errorsx.InvalidParamI18n("error.e0074") + } + if toolx.IsAgentDirectToolCode(toolCode) { + return nil + } + serverCode, toolName := toolx.SplitMCPToolCode(toolCode) + if serverCode == "" || toolName == "" { + return errorsx.InvalidParamI18n("error.e0075") + } + if !cfg.MCP.Enabled { + return errorsx.InvalidParamI18n("error.e0035") + } + server, ok := cfg.MCP.Servers[serverCode] + if !ok || !server.Enabled { + return errorsx.InvalidParamI18n("error.e0073") + } + return nil +} diff --git a/internal/services/user_identity_service.go b/internal/services/user_identity_service.go new file mode 100644 index 0000000..556eea5 --- /dev/null +++ b/internal/services/user_identity_service.go @@ -0,0 +1,67 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var UserIdentityService = newUserIdentityService() + +func newUserIdentityService() *userIdentityService { + return &userIdentityService{} +} + +type userIdentityService struct { +} + +func (s *userIdentityService) Get(id int64) *models.UserIdentity { + return repositories.UserIdentityRepository.Get(sqls.DB(), id) +} + +func (s *userIdentityService) Take(where ...interface{}) *models.UserIdentity { + return repositories.UserIdentityRepository.Take(sqls.DB(), where...) +} + +func (s *userIdentityService) Find(cnd *sqls.Cnd) []models.UserIdentity { + return repositories.UserIdentityRepository.Find(sqls.DB(), cnd) +} + +func (s *userIdentityService) FindOne(cnd *sqls.Cnd) *models.UserIdentity { + return repositories.UserIdentityRepository.FindOne(sqls.DB(), cnd) +} + +func (s *userIdentityService) FindPageByParams(params *params.QueryParams) (list []models.UserIdentity, paging *sqls.Paging) { + return repositories.UserIdentityRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *userIdentityService) FindPageByCnd(cnd *sqls.Cnd) (list []models.UserIdentity, paging *sqls.Paging) { + return repositories.UserIdentityRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *userIdentityService) Count(cnd *sqls.Cnd) int64 { + return repositories.UserIdentityRepository.Count(sqls.DB(), cnd) +} + +func (s *userIdentityService) Create(t *models.UserIdentity) error { + return repositories.UserIdentityRepository.Create(sqls.DB(), t) +} + +func (s *userIdentityService) Update(t *models.UserIdentity) error { + return repositories.UserIdentityRepository.Update(sqls.DB(), t) +} + +func (s *userIdentityService) Updates(id int64, columns map[string]interface{}) error { + return repositories.UserIdentityRepository.Updates(sqls.DB(), id, columns) +} + +func (s *userIdentityService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.UserIdentityRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *userIdentityService) Delete(id int64) { + repositories.UserIdentityRepository.Delete(sqls.DB(), id) +} diff --git a/internal/services/user_permission_service.go b/internal/services/user_permission_service.go new file mode 100644 index 0000000..f49edd6 --- /dev/null +++ b/internal/services/user_permission_service.go @@ -0,0 +1,67 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var UserPermissionService = newUserPermissionService() + +func newUserPermissionService() *userPermissionService { + return &userPermissionService{} +} + +type userPermissionService struct { +} + +func (s *userPermissionService) Get(id int64) *models.UserPermission { + return repositories.UserPermissionRepository.Get(sqls.DB(), id) +} + +func (s *userPermissionService) Take(where ...interface{}) *models.UserPermission { + return repositories.UserPermissionRepository.Take(sqls.DB(), where...) +} + +func (s *userPermissionService) Find(cnd *sqls.Cnd) []models.UserPermission { + return repositories.UserPermissionRepository.Find(sqls.DB(), cnd) +} + +func (s *userPermissionService) FindOne(cnd *sqls.Cnd) *models.UserPermission { + return repositories.UserPermissionRepository.FindOne(sqls.DB(), cnd) +} + +func (s *userPermissionService) FindPageByParams(params *params.QueryParams) (list []models.UserPermission, paging *sqls.Paging) { + return repositories.UserPermissionRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *userPermissionService) FindPageByCnd(cnd *sqls.Cnd) (list []models.UserPermission, paging *sqls.Paging) { + return repositories.UserPermissionRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *userPermissionService) Count(cnd *sqls.Cnd) int64 { + return repositories.UserPermissionRepository.Count(sqls.DB(), cnd) +} + +func (s *userPermissionService) Create(t *models.UserPermission) error { + return repositories.UserPermissionRepository.Create(sqls.DB(), t) +} + +func (s *userPermissionService) Update(t *models.UserPermission) error { + return repositories.UserPermissionRepository.Update(sqls.DB(), t) +} + +func (s *userPermissionService) Updates(id int64, columns map[string]interface{}) error { + return repositories.UserPermissionRepository.Updates(sqls.DB(), id, columns) +} + +func (s *userPermissionService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.UserPermissionRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *userPermissionService) Delete(id int64) { + repositories.UserPermissionRepository.Delete(sqls.DB(), id) +} diff --git a/internal/services/user_role_service.go b/internal/services/user_role_service.go new file mode 100644 index 0000000..164f93e --- /dev/null +++ b/internal/services/user_role_service.go @@ -0,0 +1,67 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/repositories" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" +) + +var UserRoleService = newUserRoleService() + +func newUserRoleService() *userRoleService { + return &userRoleService{} +} + +type userRoleService struct { +} + +func (s *userRoleService) Get(id int64) *models.UserRole { + return repositories.UserRoleRepository.Get(sqls.DB(), id) +} + +func (s *userRoleService) Take(where ...interface{}) *models.UserRole { + return repositories.UserRoleRepository.Take(sqls.DB(), where...) +} + +func (s *userRoleService) Find(cnd *sqls.Cnd) []models.UserRole { + return repositories.UserRoleRepository.Find(sqls.DB(), cnd) +} + +func (s *userRoleService) FindOne(cnd *sqls.Cnd) *models.UserRole { + return repositories.UserRoleRepository.FindOne(sqls.DB(), cnd) +} + +func (s *userRoleService) FindPageByParams(params *params.QueryParams) (list []models.UserRole, paging *sqls.Paging) { + return repositories.UserRoleRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *userRoleService) FindPageByCnd(cnd *sqls.Cnd) (list []models.UserRole, paging *sqls.Paging) { + return repositories.UserRoleRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *userRoleService) Count(cnd *sqls.Cnd) int64 { + return repositories.UserRoleRepository.Count(sqls.DB(), cnd) +} + +func (s *userRoleService) Create(t *models.UserRole) error { + return repositories.UserRoleRepository.Create(sqls.DB(), t) +} + +func (s *userRoleService) Update(t *models.UserRole) error { + return repositories.UserRoleRepository.Update(sqls.DB(), t) +} + +func (s *userRoleService) Updates(id int64, columns map[string]interface{}) error { + return repositories.UserRoleRepository.Updates(sqls.DB(), id, columns) +} + +func (s *userRoleService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.UserRoleRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *userRoleService) Delete(id int64) { + repositories.UserRoleRepository.Delete(sqls.DB(), id) +} diff --git a/internal/services/user_service.go b/internal/services/user_service.go new file mode 100644 index 0000000..6761067 --- /dev/null +++ b/internal/services/user_service.go @@ -0,0 +1,316 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + "slices" + "strings" + "time" + + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +var UserService = newUserService() + +func newUserService() *userService { + return &userService{} +} + +type userService struct { +} + +func (s *userService) Get(id int64) *models.User { + return repositories.UserRepository.Get(sqls.DB(), id) +} + +func (s *userService) Take(where ...interface{}) *models.User { + return repositories.UserRepository.Take(sqls.DB(), where...) +} + +func (s *userService) Find(cnd *sqls.Cnd) []models.User { + return repositories.UserRepository.Find(sqls.DB(), cnd) +} + +func (s *userService) FindOne(cnd *sqls.Cnd) *models.User { + return repositories.UserRepository.FindOne(sqls.DB(), cnd) +} + +func (s *userService) FindPageByParams(params *params.QueryParams) (list []models.User, paging *sqls.Paging) { + return repositories.UserRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *userService) FindPageByCnd(cnd *sqls.Cnd) (list []models.User, paging *sqls.Paging) { + return repositories.UserRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *userService) Count(cnd *sqls.Cnd) int64 { + return repositories.UserRepository.Count(sqls.DB(), cnd) +} + +func (s *userService) FindByIds(ids []int64) []models.User { + return repositories.UserRepository.FindByIds(sqls.DB(), ids) +} + +func (s *userService) Create(t *models.User) error { + return repositories.UserRepository.Create(sqls.DB(), t) +} + +func (s *userService) Update(t *models.User) error { + return repositories.UserRepository.Update(sqls.DB(), t) +} + +func (s *userService) Updates(id int64, columns map[string]interface{}) error { + return repositories.UserRepository.Updates(sqls.DB(), id, columns) +} + +func (s *userService) UpdateColumn(id int64, name string, value interface{}) error { + return repositories.UserRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *userService) GetByUsername(username string) *models.User { + return repositories.UserRepository.GetByUsername(sqls.DB(), username) +} + +func (s *userService) GetByMobile(mobile string) *models.User { + return repositories.UserRepository.GetByMobile(sqls.DB(), mobile) +} + +func (s *userService) GetByEmail(email string) *models.User { + return repositories.UserRepository.GetByEmail(sqls.DB(), email) +} + +func (s *userService) CreateUser(req request.CreateUserRequest, operator *dto.AuthPrincipal) (*models.User, string, error) { + username := strings.TrimSpace(req.Username) + if username == "" { + return nil, "", errorsx.InvalidParamI18n("error.e0257") + } + if s.GetByUsername(username) != nil { + return nil, "", errorsx.InvalidParamI18n("error.e0259") + } + + mobile := utils.NormalizeNullableString(req.Mobile) + email := utils.NormalizeNullableString(req.Email) + if mobile != nil && s.GetByMobile(*mobile) != nil { + return nil, "", errorsx.InvalidParamI18n("error.e0206") + } + if email != nil && s.GetByEmail(*email) != nil { + return nil, "", errorsx.InvalidParamI18n("error.e0338") + } + + plain, err := utils.GenerateRandomPassword(12) + if err != nil { + return nil, "", err + } + passwordHash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) + if err != nil { + return nil, "", err + } + + user := &models.User{ + Username: username, + Nickname: strings.TrimSpace(req.Nickname), + Password: string(passwordHash), + Avatar: strings.TrimSpace(req.Avatar), + Mobile: mobile, + Email: email, + UserType: enums.UserTypeEmployee, + Status: enums.StatusOk, + Remark: strings.TrimSpace(req.Remark), + PasswordSalt: "", + AuditFields: utils.BuildAuditFields(operator), + } + if user.Nickname == "" { + user.Nickname = username + } + + err = sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.UserRepository.Create(ctx.Tx, user); err != nil { + return err + } + return s.replaceUserRolesDB(ctx.Tx, user.ID, req.RoleIDs, operator) + }) + if err != nil { + return nil, "", err + } + return user, plain, nil +} + +func (s *userService) UpdateUser(req request.UpdateUserRequest, operator *dto.AuthPrincipal) error { + user := s.Get(req.ID) + if user == nil || user.DeletedAt != nil { + return errorsx.InvalidParamI18n("error.e0255") + } + + mobile := utils.NormalizeNullableString(req.Mobile) + email := utils.NormalizeNullableString(req.Email) + if mobile != nil { + if existed := s.GetByMobile(*mobile); existed != nil && existed.ID != req.ID { + return errorsx.InvalidParamI18n("error.e0206") + } + } + if email != nil { + if existed := s.GetByEmail(*email); existed != nil && existed.ID != req.ID { + return errorsx.InvalidParamI18n("error.e0338") + } + } + + return s.Updates(req.ID, map[string]any{ + "nickname": strings.TrimSpace(req.Nickname), + "avatar": strings.TrimSpace(req.Avatar), + "mobile": mobile, + "email": email, + "remark": strings.TrimSpace(req.Remark), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) +} + +func (s *userService) DeleteUser(id int64, operator *dto.AuthPrincipal) error { + user := s.Get(id) + if user == nil { + return errorsx.InvalidParamI18n("error.e0255") + } + + if err := s.Updates(id, map[string]any{ + "status": enums.StatusDisabled, + "deleted_at": time.Now(), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }); err != nil { + return err + } + return LoginSessionService.RevokeByUser(id, operator.UserID, operator.Username) +} + +func (s *userService) UpdateStatus(id int64, status int, operator *dto.AuthPrincipal) error { + user := s.Get(id) + if user == nil { + return errorsx.InvalidParamI18n("error.e0255") + } + if !slices.Contains(enums.StatusValues, enums.Status(status)) { + return errorsx.InvalidParamI18n("error.e0254") + } + if err := s.Updates(id, map[string]any{ + "status": status, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }); err != nil { + return err + } + if status == int(enums.StatusDisabled) || status == int(enums.StatusDeleted) { + return LoginSessionService.RevokeByUser(id, operator.UserID, operator.Username) + } + return nil +} + +func (s *userService) ResetPassword(userID int64, operator *dto.AuthPrincipal) (string, error) { + password, err := utils.GenerateRandomPassword(12) + if err != nil { + return "", err + } + if err = s.changePassword(userID, password, operator); err != nil { + return "", err + } + return password, nil +} + +func (s *userService) ChangeOwnPassword(password string, operator *dto.AuthPrincipal) error { + if operator == nil || operator.UserID <= 0 { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + return s.changePassword(operator.UserID, password, operator) +} + +func (s *userService) AssignRoles(userID int64, roleIDs []int64, operator *dto.AuthPrincipal) error { + user := s.Get(userID) + if user == nil || user.DeletedAt != nil { + return errorsx.InvalidParamI18n("error.e0255") + } + if err := s.replaceUserRoles(userID, roleIDs, operator); err != nil { + return err + } + return LoginSessionService.RevokeByUser(userID, operator.UserID, operator.Username) +} + +func (s *userService) replaceUserRoles(userID int64, roleIDs []int64, operator *dto.AuthPrincipal) error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + return s.replaceUserRolesDB(ctx.Tx, userID, roleIDs, operator) + }) +} + +func (s *userService) replaceUserRolesDB(db *gorm.DB, userID int64, roleIDs []int64, operator *dto.AuthPrincipal) error { + if err := db.Where("user_id = ?", userID).Delete(&models.UserRole{}).Error; err != nil { + return err + } + for _, roleID := range roleIDs { + role := RoleService.Get(roleID) + if role == nil { + return errorsx.InvalidParamI18n("error.e0305") + } + if role.Status != enums.StatusOk { + return errorsx.InvalidParamI18n("error.e0291") + } + if role.IsSystem && (operator == nil || !slices.Contains(operator.Roles, string(constants.RoleCodeSuperAdmin))) { + return errorsx.ForbiddenI18n("error.e0293") + } + relation := &models.UserRole{ + UserID: userID, + RoleID: roleID, + AuditFields: utils.BuildAuditFields(operator), + } + if err := db.Create(relation).Error; err != nil { + return err + } + } + return nil +} + +func (s *userService) changePassword(userID int64, password string, operator *dto.AuthPrincipal) error { + user := s.Get(userID) + if user == nil || user.DeletedAt != nil { + return errorsx.InvalidParamI18n("error.e0255") + } + if operator != nil && operator.UserID != userID && !slices.Contains(operator.Roles, string(constants.RoleCodeSuperAdmin)) { + var superAdminCount int64 + if err := sqls.DB().Model(&models.UserRole{}). + Joins("JOIN t_role ON t_role.id = t_user_role.role_id"). + Where("t_user_role.user_id = ? AND t_role.code = ?", userID, string(constants.RoleCodeSuperAdmin)). + Count(&superAdminCount).Error; err != nil { + return err + } + if superAdminCount > 0 { + return errorsx.ForbiddenI18n("error.e0293") + } + } + if strings.TrimSpace(password) == "" { + return errorsx.InvalidParamI18n("error.e0220") + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + now := time.Now() + if err = s.Updates(userID, map[string]any{ + "password": string(passwordHash), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + return LoginSessionService.RevokeByUser(userID, operator.UserID, operator.Username) +} diff --git a/internal/services/webhook_sync_service.go b/internal/services/webhook_sync_service.go new file mode 100644 index 0000000..cc6436a --- /dev/null +++ b/internal/services/webhook_sync_service.go @@ -0,0 +1,304 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/repositories" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "strings" + "time" + + "github.com/mlogclub/simple/sqls" +) + +var WebhookSyncService = newWebhookSyncService() + +func newWebhookSyncService() *webhookSyncService { + return &webhookSyncService{} +} + +type webhookSyncService struct{} + +func (s *webhookSyncService) VerifySignature(payload []byte, signature string) bool { + secret := strings.TrimSpace(config.Current().Webhook.OrgSyncSecret) + if secret == "" { + secret = strings.TrimSpace(config.Current().Webhook.DOSOrgSyncSecret) + } + if secret == "" { + secret = strings.TrimSpace(config.Current().OIDC.ClientSecret) + } + if secret == "" { + return true + } + + sig := strings.TrimSpace(signature) + if strings.HasPrefix(sig, "sha256=") { + sig = strings.TrimPrefix(sig, "sha256=") + } + if sig == "" { + return false + } + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + expected := hex.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(sig), []byte(expected)) +} + +func (s *webhookSyncService) HandleOrgSync(req request.OrgSyncWebhookRequest) error { + event := strings.TrimSpace(req.Event) + data := req.Data + + orgCode := strings.TrimSpace(data.OrgID) + if orgCode == "" { + return errorsx.InvalidParam("org_id is required") + } + + switch event { + case "org.created", "org.updated": + return s.handleOrgUpsert(data) + case "org.deleted": + return s.handleOrgDelete(orgCode) + case "org.member_added", "org.member_updated": + return s.handleMemberUpsert(data) + case "org.member_removed": + return s.handleMemberRemove(data) + default: + return nil + } +} + +func (s *webhookSyncService) HandleDOSOrgSync(req request.DOSOrgSyncWebhookRequest) error { + return s.HandleOrgSync(req) +} + +func (s *webhookSyncService) handleOrgUpsert(data request.OrgSyncEventData) error { + now := time.Now() + orgCode := strings.TrimSpace(data.OrgID) + orgName := strings.TrimSpace(data.OrgName) + if orgName == "" { + orgName = orgCode + } + plan := strings.TrimSpace(data.Plan) + if plan == "" { + plan = "free" + } + + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + org := repositories.OrganizationRepository.GetByCode(ctx.Tx, orgCode) + if org == nil { + org = &models.Organization{ + Code: orgCode, + Name: orgName, + Plan: plan, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: 0, + CreateUserName: "webhook-sync", + UpdatedAt: now, + UpdateUserID: 0, + UpdateUserName: "webhook-sync", + }, + } + return repositories.OrganizationRepository.Create(ctx.Tx, org) + } + + updates := map[string]any{ + "name": orgName, + "status": enums.StatusOk, + "update_user_id": 0, + "update_user_name": "webhook-sync", + "updated_at": now, + } + if plan != "" { + updates["plan"] = plan + } + return repositories.OrganizationRepository.Updates(ctx.Tx, org.ID, updates) + }) +} + +func (s *webhookSyncService) handleOrgDelete(orgCode string) error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + org := repositories.OrganizationRepository.GetByCode(ctx.Tx, orgCode) + if org == nil { + return nil + } + return repositories.OrganizationRepository.UpdateColumn(ctx.Tx, org.ID, "status", enums.StatusDeleted) + }) +} + +func (s *webhookSyncService) handleMemberUpsert(data request.OrgSyncEventData) error { + now := time.Now() + orgCode := strings.TrimSpace(data.OrgID) + userSubject := strings.TrimSpace(data.UserID) + userEmail := strings.TrimSpace(strings.ToLower(data.UserEmail)) + userName := strings.TrimSpace(data.UserName) + role := strings.ToUpper(strings.TrimSpace(data.Role)) + if role == "" { + role = "MEMBER" + } + + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + org := repositories.OrganizationRepository.GetByCode(ctx.Tx, orgCode) + if org == nil { + orgName := strings.TrimSpace(data.OrgName) + if orgName == "" { + orgName = orgCode + } + org = &models.Organization{ + Code: orgCode, + Name: orgName, + Plan: "free", + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: 0, + CreateUserName: "webhook-sync", + UpdatedAt: now, + UpdateUserID: 0, + UpdateUserName: "webhook-sync", + }, + } + if err := repositories.OrganizationRepository.Create(ctx.Tx, org); err != nil { + return err + } + } + + var user *models.User + if userSubject != "" { + identity := repositories.UserIdentityRepository.GetBy(ctx.Tx, enums.ThirdProviderOIDC, "", userSubject) + if identity != nil { + user = repositories.UserRepository.Get(ctx.Tx, identity.UserID) + } + } + if user == nil && userEmail != "" { + user = repositories.UserRepository.GetByEmail(ctx.Tx, userEmail) + } + if user == nil { + username := userEmail + if username == "" { + username = "u_" + userSubject + } + user = &models.User{ + Username: username, + Nickname: userName, + Email: &userEmail, + UserType: enums.UserTypeEmployee, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: 0, + CreateUserName: "webhook-sync", + UpdatedAt: now, + UpdateUserID: 0, + UpdateUserName: "webhook-sync", + }, + } + if err := repositories.UserRepository.Create(ctx.Tx, user); err != nil { + return err + } + if userSubject != "" { + _ = repositories.UserIdentityRepository.Create(ctx.Tx, &models.UserIdentity{ + UserID: user.ID, + Provider: enums.ThirdProviderOIDC, + ProviderUserID: userSubject, + ProviderName: "OIDC", + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: user.ID, + CreateUserName: user.Username, + UpdatedAt: now, + UpdateUserID: user.ID, + UpdateUserName: user.Username, + }, + }) + } + } + + member := repositories.OrganizationMemberRepository.GetByOrgAndUser(ctx.Tx, org.ID, user.ID) + if member == nil { + member = &models.OrganizationMember{ + OrganizationID: org.ID, + UserID: user.ID, + Role: role, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: user.ID, + CreateUserName: user.Username, + UpdatedAt: now, + UpdateUserID: user.ID, + UpdateUserName: user.Username, + }, + } + if err := repositories.OrganizationMemberRepository.Create(ctx.Tx, member); err != nil { + return err + } + } else { + _ = repositories.OrganizationMemberRepository.Updates(ctx.Tx, member.ID, map[string]any{ + "role": role, + "status": enums.StatusOk, + "update_user_id": user.ID, + "update_user_name": user.Username, + "updated_at": now, + }) + } + + if user.ActiveOrgID == 0 { + _ = repositories.UserRepository.UpdateColumn(ctx.Tx, user.ID, "active_org_id", org.ID) + } + + return nil + }) +} + +func (s *webhookSyncService) handleMemberRemove(data request.OrgSyncEventData) error { + orgCode := strings.TrimSpace(data.OrgID) + userSubject := strings.TrimSpace(data.UserID) + userEmail := strings.TrimSpace(strings.ToLower(data.UserEmail)) + + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + org := repositories.OrganizationRepository.GetByCode(ctx.Tx, orgCode) + if org == nil { + return nil + } + + var user *models.User + if userSubject != "" { + identity := repositories.UserIdentityRepository.GetBy(ctx.Tx, enums.ThirdProviderOIDC, "", userSubject) + if identity != nil { + user = repositories.UserRepository.Get(ctx.Tx, identity.UserID) + } + } + if user == nil && userEmail != "" { + user = repositories.UserRepository.GetByEmail(ctx.Tx, userEmail) + } + if user == nil { + return nil + } + + member := repositories.OrganizationMemberRepository.GetByOrgAndUser(ctx.Tx, org.ID, user.ID) + if member != nil { + _ = repositories.OrganizationMemberRepository.UpdateColumn(ctx.Tx, member.ID, "status", enums.StatusDeleted) + } + + if user.ActiveOrgID == org.ID { + remaining := repositories.OrganizationMemberRepository.Find(ctx.Tx, sqls.NewCnd().Eq("user_id", user.ID).Eq("status", enums.StatusOk).Where("organization_id <> ?", org.ID)) + var newActiveOrgID int64 = 0 + if len(remaining) > 0 { + newActiveOrgID = remaining[0].OrganizationID + } + _ = repositories.UserRepository.UpdateColumn(ctx.Tx, user.ID, "active_org_id", newActiveOrgID) + } + + return nil + }) +} diff --git a/internal/services/webhook_sync_service_test.go b/internal/services/webhook_sync_service_test.go new file mode 100644 index 0000000..d7f3026 --- /dev/null +++ b/internal/services/webhook_sync_service_test.go @@ -0,0 +1,178 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "testing" + "time" +) + +func TestWebhookDOSOrgSync_OrgEvents(t *testing.T) { + db := setupAuthServiceTestDB(t) + svc := newWebhookSyncService() + + // 1. Test org.created + err := svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{ + Event: "org.created", + Timestamp: time.Now().Format(time.RFC3339), + Data: request.DOSOrgSyncEventData{ + OrgID: "org_tingee_001", + OrgName: "Tingee Corporation", + Plan: "pro", + }, + }) + if err != nil { + t.Fatalf("HandleDOSOrgSync org.created failed: %v", err) + } + + org := repositories.OrganizationRepository.GetByCode(db, "org_tingee_001") + if org == nil || org.Name != "Tingee Corporation" || org.Plan != "pro" || org.Status != enums.StatusOk { + t.Fatalf("unexpected created org: %+v", org) + } + + // 2. Test org.updated + err = svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{ + Event: "org.updated", + Timestamp: time.Now().Format(time.RFC3339), + Data: request.DOSOrgSyncEventData{ + OrgID: "org_tingee_001", + OrgName: "Tingee Global Corp", + Plan: "enterprise", + }, + }) + if err != nil { + t.Fatalf("HandleDOSOrgSync org.updated failed: %v", err) + } + + org = repositories.OrganizationRepository.GetByCode(db, "org_tingee_001") + if org == nil || org.Name != "Tingee Global Corp" || org.Plan != "enterprise" { + t.Fatalf("unexpected updated org: %+v", org) + } + + // 3. Test org.deleted + err = svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{ + Event: "org.deleted", + Timestamp: time.Now().Format(time.RFC3339), + Data: request.DOSOrgSyncEventData{ + OrgID: "org_tingee_001", + }, + }) + if err != nil { + t.Fatalf("HandleDOSOrgSync org.deleted failed: %v", err) + } + + org = repositories.OrganizationRepository.GetByCode(db, "org_tingee_001") + if org == nil || org.Status != enums.StatusDeleted { + t.Fatalf("expected org to be deleted, got: %+v", org) + } +} + +func TestWebhookDOSOrgSync_MemberEvents(t *testing.T) { + db := setupAuthServiceTestDB(t) + svc := newWebhookSyncService() + + // 1. Test org.member_added + err := svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{ + Event: "org.member_added", + Timestamp: time.Now().Format(time.RFC3339), + Data: request.DOSOrgSyncEventData{ + OrgID: "org_tingee_002", + OrgName: "Tingee R&D", + UserID: "usr_dos_001", + UserEmail: "member@tingee.com", + UserName: "Nguyen Van A", + Role: "ADMIN", + }, + }) + if err != nil { + t.Fatalf("HandleDOSOrgSync org.member_added failed: %v", err) + } + + org := repositories.OrganizationRepository.GetByCode(db, "org_tingee_002") + if org == nil { + t.Fatalf("expected org to be auto-provisioned") + } + + user := repositories.UserRepository.GetByEmail(db, "member@tingee.com") + if user == nil || user.Nickname != "Nguyen Van A" { + t.Fatalf("expected user to be created: %+v", user) + } + + member := repositories.OrganizationMemberRepository.GetByOrgAndUser(db, org.ID, user.ID) + if member == nil || member.Role != "ADMIN" || member.Status != enums.StatusOk { + t.Fatalf("unexpected member record: %+v", member) + } + + if user.ActiveOrgID != org.ID { + t.Fatalf("expected active_org_id = %d, got %d", org.ID, user.ActiveOrgID) + } + + // 2. Test org.member_removed + err = svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{ + Event: "org.member_removed", + Timestamp: time.Now().Format(time.RFC3339), + Data: request.DOSOrgSyncEventData{ + OrgID: "org_tingee_002", + UserID: "usr_dos_001", + UserEmail: "member@tingee.com", + }, + }) + if err != nil { + t.Fatalf("HandleDOSOrgSync org.member_removed failed: %v", err) + } + + member = repositories.OrganizationMemberRepository.GetByOrgAndUser(db, org.ID, user.ID) + if member == nil || member.Status != enums.StatusDeleted { + t.Fatalf("expected member to be marked deleted, got: %+v", member) + } +} + +func TestOrganizationService_SwitchAndList(t *testing.T) { + db := setupAuthServiceTestDB(t) + user := createAuthTestUser(t, db, "testuser", "secret") + + org1 := &models.Organization{Code: "org1", Name: "Org 1", Status: enums.StatusOk} + org2 := &models.Organization{Code: "org2", Name: "Org 2", Status: enums.StatusOk} + _ = repositories.OrganizationRepository.Create(db, org1) + _ = repositories.OrganizationRepository.Create(db, org2) + + _ = repositories.OrganizationMemberRepository.Create(db, &models.OrganizationMember{ + OrganizationID: org1.ID, + UserID: user.ID, + Role: "OWNER", + Status: enums.StatusOk, + }) + _ = repositories.OrganizationMemberRepository.Create(db, &models.OrganizationMember{ + OrganizationID: org2.ID, + UserID: user.ID, + Role: "MEMBER", + Status: enums.StatusOk, + }) + + _ = repositories.UserRepository.UpdateColumn(db, user.ID, "active_org_id", org1.ID) + + // List + res, err := OrganizationService.GetUserOrganizations(user.ID) + if err != nil { + t.Fatalf("GetUserOrganizations failed: %v", err) + } + if len(res.Organizations) != 2 || res.CurrentOrganizationID != org1.ID { + t.Fatalf("unexpected user org list: %+v", res) + } + + // Switch + switched, err := OrganizationService.SwitchActiveOrganization(user.ID, org2.ID) + if err != nil { + t.Fatalf("SwitchActiveOrganization failed: %v", err) + } + if switched == nil || switched.ID != org2.ID { + t.Fatalf("unexpected switched org: %+v", switched) + } + + updatedUser := repositories.UserRepository.Get(db, user.ID) + if updatedUser.ActiveOrgID != org2.ID { + t.Fatalf("expected active_org_id %d, got %d", org2.ID, updatedUser.ActiveOrgID) + } +} diff --git a/internal/services/ws_connection_manager.go b/internal/services/ws_connection_manager.go new file mode 100644 index 0000000..e2e3c70 --- /dev/null +++ b/internal/services/ws_connection_manager.go @@ -0,0 +1,134 @@ +package services + +import "sync" + +type WsConnectionManager struct { + mu sync.RWMutex + sessions map[string]*ClientSession + topics map[string]map[string]*ClientSession +} + +func newWsConnectionManager() *WsConnectionManager { + return &WsConnectionManager{ + sessions: make(map[string]*ClientSession), + topics: make(map[string]map[string]*ClientSession), + } +} + +func (m *WsConnectionManager) Register(session *ClientSession, defaultTopics []string) int { + if session == nil { + return 0 + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.sessions[session.ID] = session + for _, topic := range defaultTopics { + m.subscribeLocked(session, topic) + } + return len(m.sessions) +} + +func (m *WsConnectionManager) Unregister(session *ClientSession) int { + if session == nil { + return 0 + } + + m.mu.Lock() + defer m.mu.Unlock() + + delete(m.sessions, session.ID) + for topic := range session.Topics { + m.unsubscribeLocked(session, topic) + } + return len(m.sessions) +} + +func (m *WsConnectionManager) Subscribe(session *ClientSession, topics []string) []string { + if session == nil || len(topics) == 0 { + return nil + } + + m.mu.Lock() + defer m.mu.Unlock() + + ret := make([]string, 0, len(topics)) + for _, topic := range topics { + if _, exists := session.Topics[topic]; exists { + continue + } + m.subscribeLocked(session, topic) + ret = append(ret, topic) + } + return ret +} + +func (m *WsConnectionManager) Unsubscribe(session *ClientSession, topics []string, keep map[string]struct{}) []string { + if session == nil || len(topics) == 0 { + return nil + } + + m.mu.Lock() + defer m.mu.Unlock() + + ret := make([]string, 0, len(topics)) + for _, topic := range topics { + if _, isDefault := keep[topic]; isDefault { + continue + } + if _, exists := session.Topics[topic]; !exists { + continue + } + m.unsubscribeLocked(session, topic) + ret = append(ret, topic) + } + return ret +} + +func (m *WsConnectionManager) FindByTopics(topics []string) []*ClientSession { + m.mu.RLock() + defer m.mu.RUnlock() + + uniq := make(map[string]*ClientSession) + for _, topic := range topics { + for connID, session := range m.topics[topic] { + uniq[connID] = session + } + } + + ret := make([]*ClientSession, 0, len(uniq)) + for _, session := range uniq { + ret = append(ret, session) + } + return ret +} + +func (m *WsConnectionManager) HasTopic(topic string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + + sessions := m.topics[topic] + return len(sessions) > 0 +} + +func (m *WsConnectionManager) subscribeLocked(session *ClientSession, topic string) { + if _, exists := session.Topics[topic]; exists { + return + } + if m.topics[topic] == nil { + m.topics[topic] = make(map[string]*ClientSession) + } + m.topics[topic][session.ID] = session + session.Topics[topic] = struct{}{} +} + +func (m *WsConnectionManager) unsubscribeLocked(session *ClientSession, topic string) { + if sessions, ok := m.topics[topic]; ok { + delete(sessions, session.ID) + if len(sessions) == 0 { + delete(m.topics, topic) + } + } + delete(session.Topics, topic) +} diff --git a/internal/services/ws_realtime_types.go b/internal/services/ws_realtime_types.go new file mode 100644 index 0000000..66c0359 --- /dev/null +++ b/internal/services/ws_realtime_types.go @@ -0,0 +1,363 @@ +package services + +import ( + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/openidentity" + "encoding/json" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" +) + +const ( + realtimeWriteWait = 10 * time.Second + realtimePongWait = 60 * time.Second + realtimePingPeriod = 30 * time.Second + realtimeMaxMessageSize = 8 << 10 + realtimeSendBufferSize = 64 +) + +const ( + realtimeRoleUser = "user" + realtimeRoleAdmin = "admin" + realtimeRoleNotification = "notification" +) + +const ( + realtimeTopicUserPrefix = "user:" + realtimeTopicGuestPrefix = "guest:" + realtimeTopicAdminPrefix = "admin:" + realtimeTopicConversationPrefix = "conversation:" + realtimeTopicNotificationPrefix = "notification:" + realtimeTopicAdminAll = "admin:all" +) + +type RealtimeEvent struct { + EventID string `json:"eventId"` + Type string `json:"type"` + Topic string `json:"topic,omitempty"` + Data RealtimeEventPayload `json:"data,omitempty"` + At string `json:"at"` +} + +type RealtimeDomainEvent interface { + EventType() string + EventPayload() RealtimeEventPayload +} + +type RealtimeEventPayload interface { + realtimeEventPayload() +} + +type RealtimeConnectedPayload struct { + ConnID string `json:"connId,omitempty"` + UserID int64 `json:"userId,omitempty"` + GuestID string `json:"guestId,omitempty"` + Role string `json:"role,omitempty"` + TerminalType string `json:"terminalType,omitempty"` + Topics []string `json:"topics,omitempty"` +} + +func (RealtimeConnectedPayload) realtimeEventPayload() {} + +type RealtimeConnectedEvent struct { + Payload RealtimeConnectedPayload +} + +func (e RealtimeConnectedEvent) EventType() string { + return enums.IMRealtimeEventConnected +} + +func (e RealtimeConnectedEvent) EventPayload() RealtimeEventPayload { + return e.Payload +} + +type RealtimeTopicsPayload struct { + Topics []string `json:"topics,omitempty"` +} + +func (RealtimeTopicsPayload) realtimeEventPayload() {} + +type RealtimeSubscribedEvent struct { + Payload RealtimeTopicsPayload +} + +func (e RealtimeSubscribedEvent) EventType() string { + return enums.IMRealtimeEventSubscribed +} + +func (e RealtimeSubscribedEvent) EventPayload() RealtimeEventPayload { + return e.Payload +} + +type RealtimeUnsubscribedEvent struct { + Payload RealtimeTopicsPayload +} + +func (e RealtimeUnsubscribedEvent) EventType() string { + return enums.IMRealtimeEventUnsubscribed +} + +func (e RealtimeUnsubscribedEvent) EventPayload() RealtimeEventPayload { + return e.Payload +} + +type RealtimePongEvent struct{} + +func (RealtimePongEvent) EventType() string { + return enums.IMRealtimeEventPong +} + +func (RealtimePongEvent) EventPayload() RealtimeEventPayload { + return nil +} + +type RealtimeResyncRequiredPayload struct { + Reason string `json:"reason,omitempty"` +} + +func (RealtimeResyncRequiredPayload) realtimeEventPayload() {} + +type RealtimeResyncRequiredEvent struct { + Payload RealtimeResyncRequiredPayload +} + +func (e RealtimeResyncRequiredEvent) EventType() string { + return enums.IMRealtimeEventResyncRequired +} + +func (e RealtimeResyncRequiredEvent) EventPayload() RealtimeEventPayload { + return e.Payload +} + +type RealtimeMessageCreatedPayload struct { + ConversationID int64 `json:"conversationId,omitempty"` + MessageID int64 `json:"messageId,omitempty"` + RequestID string `json:"requestId,omitempty"` + Message response.MessageResponse `json:"message,omitempty"` + Status enums.IMConversationStatus `json:"status,omitempty"` + CurrentAssigneeID int64 `json:"currentAssigneeId,omitempty"` + SenderType enums.IMSenderType `json:"senderType,omitempty"` + SenderID int64 `json:"senderId,omitempty"` + MessageType enums.IMMessageType `json:"messageType,omitempty"` + Content string `json:"content,omitempty"` + Payload string `json:"payload,omitempty"` + SendStatus enums.IMMessageStatus `json:"sendStatus,omitempty"` + SentAt string `json:"sentAt,omitempty"` +} + +func (RealtimeMessageCreatedPayload) realtimeEventPayload() {} + +type RealtimeMessageCreatedEvent struct { + Payload RealtimeMessageCreatedPayload +} + +func (e RealtimeMessageCreatedEvent) EventType() string { + return enums.IMRealtimeEventMessageCreated +} + +func (e RealtimeMessageCreatedEvent) EventPayload() RealtimeEventPayload { + return e.Payload +} + +type RealtimeMessageRecalledPayload struct { + ConversationID int64 `json:"conversationId,omitempty"` + MessageID int64 `json:"messageId,omitempty"` + SenderType enums.IMSenderType `json:"senderType,omitempty"` + SenderID int64 `json:"senderId,omitempty"` + SendStatus enums.IMMessageStatus `json:"sendStatus,omitempty"` + RecalledAt string `json:"recalledAt,omitempty"` +} + +func (RealtimeMessageRecalledPayload) realtimeEventPayload() {} + +type RealtimeMessageRecalledEvent struct { + Payload RealtimeMessageRecalledPayload +} + +func (e RealtimeMessageRecalledEvent) EventType() string { + return enums.IMRealtimeEventMessageRecalled +} + +func (e RealtimeMessageRecalledEvent) EventPayload() RealtimeEventPayload { + return e.Payload +} + +type RealtimeConversationChangedPayload struct { + ConversationID int64 `json:"conversationId,omitempty"` + Status enums.IMConversationStatus `json:"status,omitempty"` + ServiceMode enums.IMConversationServiceMode `json:"serviceMode,omitempty"` + CurrentAssigneeID int64 `json:"currentAssigneeId,omitempty"` + CurrentTeamID int64 `json:"currentTeamId,omitempty"` + LastMessageID int64 `json:"lastMessageId,omitempty"` + LastMessageAt string `json:"lastMessageAt,omitempty"` + LastActiveAt string `json:"lastActiveAt,omitempty"` + LastMessageSummary string `json:"lastMessageSummary,omitempty"` + CustomerUnreadCount int `json:"customerUnreadCount,omitempty"` + AgentUnreadCount int `json:"agentUnreadCount,omitempty"` + CustomerLastReadMessageID int64 `json:"customerLastReadMessageId,omitempty"` + CustomerLastReadAt string `json:"customerLastReadAt,omitempty"` + AgentLastReadMessageID int64 `json:"agentLastReadMessageId,omitempty"` + AgentLastReadAt string `json:"agentLastReadAt,omitempty"` +} + +func (RealtimeConversationChangedPayload) realtimeEventPayload() {} + +type RealtimeConversationChangedEvent struct { + Type string + Payload RealtimeConversationChangedPayload +} + +func (e RealtimeConversationChangedEvent) EventType() string { + return e.Type +} + +func (e RealtimeConversationChangedEvent) EventPayload() RealtimeEventPayload { + return e.Payload +} + +type RealtimeNotificationCreatedPayload struct { + Notification response.NotificationResponse `json:"notification"` +} + +func (RealtimeNotificationCreatedPayload) realtimeEventPayload() {} + +type RealtimeNotificationCreatedEvent struct { + Payload RealtimeNotificationCreatedPayload +} + +func (e RealtimeNotificationCreatedEvent) EventType() string { + return enums.IMRealtimeEventNotificationCreated +} + +func (e RealtimeNotificationCreatedEvent) EventPayload() RealtimeEventPayload { + return e.Payload +} + +type RealtimeCustomerSessionRefreshPayload struct { + CustomerSessionToken string `json:"customerSessionToken"` + ExpiresAt string `json:"expiresAt"` +} + +func (RealtimeCustomerSessionRefreshPayload) realtimeEventPayload() {} + +type RealtimeCustomerSessionRefreshEvent struct { + Payload RealtimeCustomerSessionRefreshPayload +} + +func (e RealtimeCustomerSessionRefreshEvent) EventType() string { + return enums.IMRealtimeEventCustomerSessionRefresh +} + +func (e RealtimeCustomerSessionRefreshEvent) EventPayload() RealtimeEventPayload { + return e.Payload +} + +type RealtimeAgentActivityPayload struct { + ConversationID int64 `json:"conversationId"` + StepCode string `json:"stepCode"` + ToolName string `json:"toolName,omitempty"` + Status string `json:"status"` + At string `json:"at"` +} + +func (RealtimeAgentActivityPayload) realtimeEventPayload() {} + +type RealtimeAgentActivityEvent struct { + Payload RealtimeAgentActivityPayload +} + +func (e RealtimeAgentActivityEvent) EventType() string { + return enums.IMRealtimeEventAgentActivity +} + +func (e RealtimeAgentActivityEvent) EventPayload() RealtimeEventPayload { + return e.Payload +} + +type RealtimeOperatorPresencePayload struct { + ConversationID int64 `json:"conversationId"` + UserID int64 `json:"userId"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Action string `json:"action"` // "viewing", "typing", "left" + At string `json:"at"` +} + +func (RealtimeOperatorPresencePayload) realtimeEventPayload() {} + +type RealtimeOperatorPresenceEvent struct { + Payload RealtimeOperatorPresencePayload +} + +func (e RealtimeOperatorPresenceEvent) EventType() string { + return enums.IMRealtimeEventOperatorPresence +} + +func (e RealtimeOperatorPresenceEvent) EventPayload() RealtimeEventPayload { + return e.Payload +} + +type realtimeClientMessage struct { + Type string `json:"type"` + Topics []string `json:"topics,omitempty"` + EventID string `json:"eventId,omitempty"` + ConversationID int64 `json:"conversationId,omitempty"` + Action string `json:"action,omitempty"` +} + +type ClientSession struct { + ID string + Conn *websocket.Conn + Principal *dto.AuthPrincipal + External *openidentity.ExternalUser + Role string + TerminalType string + Topics map[string]struct{} + Send chan []byte + Closed atomic.Bool + LastActiveAt atomic.Int64 + closeOnce sync.Once +} + +func (s *ClientSession) enqueue(payload []byte) bool { + if s == nil || s.Closed.Load() { + return false + } + select { + case s.Send <- payload: + return true + default: + return false + } +} + +func (s *ClientSession) enqueueEvent(event RealtimeEvent) bool { + payload, err := json.Marshal(event) + if err != nil { + return false + } + return s.enqueue(payload) +} + +func (s *ClientSession) touch() { + if s == nil { + return + } + s.LastActiveAt.Store(time.Now().Unix()) +} + +func (s *ClientSession) topicList() []string { + if s == nil { + return nil + } + ret := make([]string, 0, len(s.Topics)) + for topic := range s.Topics { + ret = append(ret, topic) + } + return ret +} diff --git a/internal/services/ws_service.go b/internal/services/ws_service.go new file mode 100644 index 0000000..f367553 --- /dev/null +++ b/internal/services/ws_service.go @@ -0,0 +1,819 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/i18nx" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/pkg/utils" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "slices" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/mlogclub/simple/web" +) + +var WsService = newWsService() + +type wsService struct { + upgrader websocket.Upgrader + seq atomic.Uint64 + manager *WsConnectionManager +} + +func newWsService() *wsService { + return &wsService{ + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, + }, + manager: newWsConnectionManager(), + } +} + +func (s *wsService) HandleDashboardWS(ctx *gin.Context) { + principal := AuthService.GetAuthPrincipal(ctx) + if principal == nil { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonErrorCode(errorsx.CodeAuthUnauthorized, i18nx.T(ctx, "error.auth.expired"))) + return + } + if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleAdmin); err != nil { + slog.Error("upgrade admin websocket failed", "error", err, "path", ctx.Request.URL.Path) + ctx.Abort() + return + } +} + +func (s *wsService) HandleDashboardNotificationWS(ctx *gin.Context) { + principal := AuthService.GetAuthPrincipal(ctx) + if principal == nil { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonErrorCode(errorsx.CodeAuthUnauthorized, i18nx.T(ctx, "error.auth.expired"))) + return + } + if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleNotification); err != nil { + slog.Error("upgrade dashboard notification websocket failed", "error", err, "path", ctx.Request.URL.Path) + ctx.Abort() + return + } +} + +func (s *wsService) HandleOpenWS(ctx *gin.Context) { + channel := ChannelService.GetEnabledChannel(ctx) + if channel == nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, web.JsonErrorCode(errorsx.CodeInvalidParam, i18nx.T(ctx, "error.e0209"))) + return + } + + var ( + principal = AuthService.GetAuthPrincipal(ctx) + external *openidentity.ExternalUser + customerSessionInfo *CustomerSessionVerifyResult + ) + if principal == nil { + result, err := CustomerSessionService.VerifyRequest(ctx, channel) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err)) + return + } + external = result.ExternalUser + customerSessionInfo = result + } + if err := s.upgradeConnection(ctx, principal, external, realtimeRoleUser, customerSessionInfo); err != nil { + slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Request.URL.Path, "channelId", channel.ChannelID, "channel_id", channel.ID) + ctx.Abort() + return + } +} + +func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalUser, role string, customerSessionInfo ...*CustomerSessionVerifyResult) error { + conn, err := s.upgrader.Upgrade(ctx.Writer, ctx.Request, nil) + if err != nil { + return err + } + + session := &ClientSession{ + ID: s.nextID("conn"), + Conn: conn, + Principal: principal, + External: external, + Role: role, + TerminalType: s.resolveTerminalType(ctx, role), + Topics: make(map[string]struct{}), + Send: make(chan []byte, realtimeSendBufferSize), + } + session.touch() + + conn.SetReadLimit(realtimeMaxMessageSize) + _ = conn.SetReadDeadline(time.Now().Add(realtimePongWait)) + conn.SetPongHandler(func(string) error { + session.touch() + return conn.SetReadDeadline(time.Now().Add(realtimePongWait)) + }) + + var logUserID int64 + var logExternalID string + if principal != nil { + logUserID = principal.UserID + } + if external != nil { + logExternalID = strings.TrimSpace(external.ExternalID) + } + + sessionCount := s.manager.Register(session, s.defaultTopics(session)) + slog.Info("realtime client connected", + "connId", session.ID, + "role", session.Role, + "userId", logUserID, + "externalId", logExternalID, + "terminalType", session.TerminalType, + "topicCount", len(session.Topics), + "sessionCount", sessionCount, + ) + + go s.writePump(session) + go s.readPump(session) + + session.enqueueEvent(s.newEvent("", RealtimeConnectedEvent{ + Payload: RealtimeConnectedPayload{ + ConnID: session.ID, + UserID: logUserID, + GuestID: logExternalID, + Role: role, + TerminalType: session.TerminalType, + Topics: session.topicList(), + }, + })) + if len(customerSessionInfo) > 0 && customerSessionInfo[0] != nil && customerSessionInfo[0].Refreshed { + session.enqueueEvent(s.newEvent("", RealtimeCustomerSessionRefreshEvent{ + Payload: RealtimeCustomerSessionRefreshPayload{ + CustomerSessionToken: customerSessionInfo[0].Token, + ExpiresAt: customerSessionInfo[0].ExpiresAt.UTC().Format(time.RFC3339), + }, + })) + } + return nil +} + +func (s *wsService) readPump(session *ClientSession) { + defer s.closeSession(session) + + for { + _, body, err := session.Conn.ReadMessage() + if err != nil { + return + } + session.touch() + s.handleClientMessage(session, body) + } +} + +func (s *wsService) handleClientMessage(session *ClientSession, payload []byte) { + input := realtimeClientMessage{} + if err := json.Unmarshal(payload, &input); err != nil { + session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{ + Payload: RealtimeResyncRequiredPayload{ + Reason: enums.IMRealtimeResyncReasonInvalidPayload, + }, + })) + return + } + + switch strings.TrimSpace(input.Type) { + case enums.IMRealtimeClientTypePing: + session.enqueueEvent(s.newEvent("", RealtimePongEvent{})) + case enums.IMRealtimeClientTypeSubscribe: + topics := s.subscribeTopics(session, input.Topics) + if len(topics) > 0 { + session.enqueueEvent(s.newEvent("", RealtimeSubscribedEvent{ + Payload: RealtimeTopicsPayload{Topics: topics}, + })) + } + case enums.IMRealtimeClientTypeUnsubscribe: + topics := s.unsubscribeTopics(session, input.Topics) + if len(topics) > 0 { + session.enqueueEvent(s.newEvent("", RealtimeUnsubscribedEvent{ + Payload: RealtimeTopicsPayload{Topics: topics}, + })) + } + case enums.IMRealtimeClientTypeAck: + slog.Debug("realtime event ack", + "connId", session.ID, + "eventId", strings.TrimSpace(input.EventID), + ) + case enums.IMRealtimeClientTypePresence: + if input.ConversationID > 0 && session.Principal != nil { + action := strings.TrimSpace(input.Action) + if action == "" { + action = "viewing" + } + s.PublishOperatorPresence(input.ConversationID, session.Principal, action, session.ID) + } + default: + session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{ + Payload: RealtimeResyncRequiredPayload{ + Reason: enums.IMRealtimeResyncReasonUnsupportedMessageType, + }, + })) + } +} + +func (s *wsService) writePump(session *ClientSession) { + ticker := time.NewTicker(realtimePingPeriod) + defer func() { + ticker.Stop() + s.closeSession(session) + }() + + for { + select { + case payload, ok := <-session.Send: + _ = session.Conn.SetWriteDeadline(time.Now().Add(realtimeWriteWait)) + if !ok { + _ = session.Conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := session.Conn.WriteMessage(websocket.TextMessage, payload); err != nil { + return + } + case <-ticker.C: + _ = session.Conn.SetWriteDeadline(time.Now().Add(realtimeWriteWait)) + if err := session.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +func (s *wsService) closeSession(session *ClientSession) { + if session == nil { + return + } + session.closeOnce.Do(func() { + session.Closed.Store(true) + remaining := s.manager.Unregister(session) + + close(session.Send) + if session.Conn != nil { + _ = session.Conn.Close() + } + + var discUserID int64 + var discExternalID string + if session.Principal != nil { + discUserID = session.Principal.UserID + } + if session.External != nil { + discExternalID = strings.TrimSpace(session.External.ExternalID) + } + slog.Info("realtime client disconnected", + "connId", session.ID, + "role", session.Role, + "userId", discUserID, + "externalId", discExternalID, + "terminalType", session.TerminalType, + "sessionCount", remaining, + ) + }) +} + +func (s *wsService) subscribeTopics(session *ClientSession, topics []string) []string { + allowed := s.filterAllowedTopics(session, topics) + if len(allowed) == 0 { + return nil + } + return s.manager.Subscribe(session, allowed) +} + +func (s *wsService) unsubscribeTopics(session *ClientSession, topics []string) []string { + allowed := s.filterAllowedTopics(session, topics) + if len(allowed) == 0 { + return nil + } + return s.manager.Unsubscribe(session, allowed, sliceToSet(s.defaultTopics(session))) +} + +func (s *wsService) PublishMessageCreated(conversation *models.Conversation, message *models.Message) { + if conversation == nil || message == nil { + return + } + content, payload := utils.BuildRenderableMessage(message) + + event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeMessageCreatedEvent{ + Payload: RealtimeMessageCreatedPayload{ + ConversationID: conversation.ID, + MessageID: message.ID, + RequestID: message.RequestID, + Message: s.buildRealtimeMessage(message), + Status: conversation.Status, + CurrentAssigneeID: conversation.CurrentAssigneeID, + SenderType: message.SenderType, + SenderID: message.SenderID, + MessageType: message.MessageType, + Content: content, + Payload: payload, + SendStatus: message.SendStatus, + SentAt: formatWsTime(message.SentAt), + }, + }) + s.PublishToTopics(s.routeConversationTopics(conversation), event) +} + +func (s *wsService) buildRealtimeMessage(item *models.Message) response.MessageResponse { + if item == nil { + return response.MessageResponse{} + } + agentReadState, customerReadState := ConversationReadStateService.GetConversationReadStates(item.ConversationID) + 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: content, + Payload: payload, + SendStatus: item.SendStatus, + SentAt: utils.FormatTimePtr(item.SentAt), + DeliveredAt: utils.FormatTimePtr(item.DeliveredAt), + ReadAt: utils.FormatTimePtr(item.ReadAt), + CustomerRead: isRealtimeMessageRead(item, customerReadState), + CustomerReadAt: realtimeReadMessageAt(item, customerReadState), + AgentRead: isRealtimeMessageRead(item, agentReadState), + AgentReadAt: realtimeReadMessageAt(item, agentReadState), + RecalledAt: utils.FormatTimePtr(item.RecalledAt), + QuotedMessageID: item.QuotedMessageID, + } + s.fillRealtimeMessageSender(&ret, item) + return ret +} + +func (s *wsService) fillRealtimeMessageSender(ret *response.MessageResponse, item *models.Message) { + if ret == nil || item == nil || item.SenderID <= 0 { + return + } + switch item.SenderType { + case enums.IMSenderTypeAI: + if aiAgent := AIAgentService.Get(item.SenderID); aiAgent != nil { + ret.SenderName = aiAgent.Name + } + case enums.IMSenderTypeAgent: + if profile := AgentProfileService.GetByUserID(item.SenderID); profile != nil { + if displayName := strings.TrimSpace(profile.DisplayName); displayName != "" { + ret.SenderName = displayName + } + if avatar := strings.TrimSpace(profile.Avatar); avatar != "" { + ret.SenderAvatar = avatar + } + } + if ret.SenderName == "" { + s.fillRealtimeMessageUserName(ret, item.SenderID) + } + default: + s.fillRealtimeMessageUserName(ret, item.SenderID) + } +} + +func (s *wsService) fillRealtimeMessageUserName(ret *response.MessageResponse, userID int64) { + if ret == nil || userID <= 0 { + return + } + if user := UserService.Get(userID); user != nil { + ret.SenderName = user.Nickname + if ret.SenderName == "" { + ret.SenderName = user.Username + } + } +} + +func isRealtimeMessageRead(item *models.Message, state *models.ConversationReadState) bool { + return item != nil && state != nil && state.LastReadMessageID >= item.ID +} + +func realtimeReadMessageAt(item *models.Message, state *models.ConversationReadState) string { + if !isRealtimeMessageRead(item, state) { + return "" + } + return utils.FormatTimePtr(state.LastReadAt) +} + +func (s *wsService) PublishMessageRecalled(conversation *models.Conversation, message *models.Message) { + if conversation == nil || message == nil { + return + } + + event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeMessageRecalledEvent{ + Payload: RealtimeMessageRecalledPayload{ + ConversationID: conversation.ID, + MessageID: message.ID, + SenderType: message.SenderType, + SenderID: message.SenderID, + SendStatus: message.SendStatus, + RecalledAt: formatWsTime(message.RecalledAt), + }, + }) + s.PublishToTopics(s.routeConversationTopics(conversation), event) +} + +func (s *wsService) PublishConversationChanged(conversation *models.Conversation, eventType string) { + if conversation == nil { + return + } + agentReadState, customerReadState := ConversationReadStateService.GetConversationReadStates(conversation.ID) + + event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeConversationChangedEvent{ + Type: eventType, + Payload: RealtimeConversationChangedPayload{ + ConversationID: conversation.ID, + Status: conversation.Status, + ServiceMode: conversation.ServiceMode, + CurrentAssigneeID: conversation.CurrentAssigneeID, + CurrentTeamID: conversation.CurrentTeamID, + LastMessageID: conversation.LastMessageID, + LastMessageAt: formatWsTime(&conversation.LastMessageAt), + LastActiveAt: formatWsTime(&conversation.LastActiveAt), + LastMessageSummary: conversation.LastMessageSummary, + CustomerUnreadCount: conversation.CustomerUnreadCount, + AgentUnreadCount: conversation.AgentUnreadCount, + CustomerLastReadMessageID: readStateMessageID(customerReadState), + CustomerLastReadAt: readStateAt(customerReadState), + AgentLastReadMessageID: readStateMessageID(agentReadState), + AgentLastReadAt: readStateAt(agentReadState), + }, + }) + s.PublishToTopics(s.routeConversationTopics(conversation), event) +} + +func (s *wsService) PublishNotificationCreated(userID int64, notification response.NotificationResponse) { + if userID <= 0 || notification.ID <= 0 { + return + } + topic := s.notificationTopic(userID) + event := s.newEvent(topic, RealtimeNotificationCreatedEvent{ + Payload: RealtimeNotificationCreatedPayload{ + Notification: notification, + }, + }) + s.PublishToTopic(topic, event) +} + +func (s *wsService) PublishAgentActivity(conversation *models.Conversation, stepCode, toolName, status string) { + if conversation == nil || conversation.ID <= 0 { + return + } + now := time.Now() + event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeAgentActivityEvent{ + Payload: RealtimeAgentActivityPayload{ + ConversationID: conversation.ID, + StepCode: stepCode, + ToolName: toolName, + Status: status, + At: formatWsTime(&now), + }, + }) + s.PublishToTopics(s.routeConversationTopics(conversation), event) +} + +func (s *wsService) PublishOperatorPresence(conversationID int64, principal *dto.AuthPrincipal, action string, excludeConnID string) { + if conversationID <= 0 || principal == nil { + return + } + action = strings.TrimSpace(action) + if action == "" { + action = "viewing" + } + topic := s.conversationTopic(conversationID) + payload := RealtimeOperatorPresencePayload{ + ConversationID: conversationID, + UserID: principal.UserID, + Username: principal.Username, + Nickname: principal.Nickname, + Action: action, + At: time.Now().Format(time.RFC3339), + } + event := s.newEvent(topic, RealtimeOperatorPresenceEvent{Payload: payload}) + s.publishTopicWithFilter(topic, event, func(session *ClientSession) bool { + return excludeConnID == "" || session.ID != excludeConnID + }) +} + +func (s *wsService) PublishResyncRequired(topics []string, reason string) { + reason = strings.TrimSpace(reason) + if reason == "" { + reason = enums.IMRealtimeResyncReasonManual + } + s.PublishToTopics(topics, s.newEvent("", RealtimeResyncRequiredEvent{ + Payload: RealtimeResyncRequiredPayload{Reason: reason}, + })) +} + +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) +} + +func (s *wsService) Publish(event RealtimeEvent) { + if strings.TrimSpace(event.Topic) == "" { + return + } + s.PublishToTopic(event.Topic, event) +} + +func (s *wsService) PublishToTopic(topic string, event RealtimeEvent) { + topic = strings.TrimSpace(topic) + if topic == "" { + return + } + if event.Topic == "" { + event.Topic = topic + } + s.PublishToTopics([]string{topic}, event) +} + +func (s *wsService) publishTopicWithFilter(topic string, event RealtimeEvent, filter func(session *ClientSession) bool) { + topic = strings.TrimSpace(topic) + if topic == "" { + return + } + if event.Topic == "" { + event.Topic = topic + } + s.PublishToTopicsWithFilter([]string{topic}, event, filter) +} + +func (s *wsService) PublishToTopics(topics []string, event RealtimeEvent) { + s.PublishToTopicsWithFilter(topics, event, nil) +} + +func (s *wsService) PublishToTopicsWithFilter(topics []string, event RealtimeEvent, filter func(session *ClientSession) bool) { + normalized := normalizeRealtimeTopics(topics) + if len(normalized) == 0 { + return + } + + targets := s.manager.FindByTopics(normalized) + if len(targets) == 0 { + return + } + + payload, err := json.Marshal(event) + if err != nil { + slog.Error("marshal realtime event failed", "error", err, "type", event.Type) + return + } + + for _, session := range targets { + if filter != nil && !filter(session) { + continue + } + if session.enqueue(payload) { + continue + } + slog.Warn("drop slow realtime client", + "connId", session.ID, + "role", session.Role, + "type", event.Type, + "topic", event.Topic, + ) + go s.closeSession(session) + } +} + +func (s *wsService) IsGuestOnline(guestID string) bool { + guestID = strings.TrimSpace(guestID) + if guestID == "" { + return false + } + return s.manager.HasTopic(s.guestTopic(guestID)) +} + +func (s *wsService) routeConversationTopics(conversation *models.Conversation) []string { + if conversation == nil { + return nil + } + + topics := []string{s.conversationTopic(conversation.ID)} + if identity := ConversationService.GetConversationExternalIdentity(conversation); identity != nil && strings.TrimSpace(identity.ExternalID) != "" { + topics = append(topics, s.guestTopic(identity.ExternalID)) + } + if conversation.CurrentAssigneeID > 0 { + topics = append(topics, s.adminTopic(conversation.CurrentAssigneeID)) + } else { + topics = append(topics, realtimeTopicAdminAll) + } + return normalizeRealtimeTopics(topics) +} + +func (s *wsService) defaultTopics(session *ClientSession) []string { + if session == nil { + return nil + } + + switch session.Role { + case realtimeRoleNotification: + if session.Principal == nil || session.Principal.UserID <= 0 { + return nil + } + return []string{s.notificationTopic(session.Principal.UserID)} + case realtimeRoleAdmin: + if session.Principal == nil || session.Principal.UserID <= 0 { + return []string{realtimeTopicAdminAll} + } + return []string{s.adminTopic(session.Principal.UserID), realtimeTopicAdminAll} + default: + // 开放 IM:仅 External、无 AuthPrincipal 的访客连接必须仍能订阅 guest:{externalId},否则收不到推送。 + if session.External != nil && strings.TrimSpace(session.External.ExternalID) != "" { + return []string{s.guestTopic(session.External.ExternalID)} + } + if session.Principal != nil && session.Principal.UserID > 0 { + return []string{s.userTopic(session.Principal.UserID)} + } + return nil + } +} + +func (s *wsService) filterAllowedTopics(session *ClientSession, topics []string) []string { + normalized := normalizeRealtimeTopics(topics) + if len(normalized) == 0 || session == nil { + return nil + } + switch session.Role { + case realtimeRoleNotification: + if session.Principal == nil { + return nil + } + case realtimeRoleAdmin: + if session.Principal == nil { + return nil + } + default: + hasUser := session.Principal != nil && session.Principal.UserID > 0 + hasExternal := session.External != nil && strings.TrimSpace(session.External.ExternalID) != "" + if !hasUser && !hasExternal { + return nil + } + } + + defaultTopics := sliceToSet(s.defaultTopics(session)) + ret := make([]string, 0, len(normalized)) + for _, topic := range normalized { + if _, ok := defaultTopics[topic]; ok { + ret = append(ret, topic) + continue + } + if conversationID, ok := parseConversationTopic(topic); ok && s.canSubscribeConversation(session, conversationID) { + ret = append(ret, topic) + } + } + return ret +} + +func (s *wsService) canSubscribeConversation(session *ClientSession, conversationID int64) bool { + if session == nil || conversationID <= 0 { + return false + } + if session.Role == realtimeRoleAdmin { + // Staff sessions must hold the same conversation-view permission the + // REST endpoints require; a bare admin-role websocket must not become + // a side channel around RequirePermission. + if session.Principal != nil && slices.Contains(session.Principal.Permissions, constants.PermissionConversationView.Code) { + return true + } + return false + } + conversation := ConversationService.Get(conversationID) + if conversation == nil { + return false + } + if session.External != nil { + return ConversationService.IsCustomerConversationOwner(conversation, *session.External) + } + return false +} + +func (s *wsService) resolveTerminalType(ctx *gin.Context, role string) string { + if ctx == nil { + return "web" + } + terminalType := strings.TrimSpace(ctx.Query("terminalType")) + if terminalType != "" { + return terminalType + } + if role == realtimeRoleAdmin { + return "dashboard_web" + } + return "web" +} + +func (s *wsService) newEvent(topic string, event RealtimeDomainEvent) RealtimeEvent { + if event == nil { + return RealtimeEvent{ + EventID: s.nextID("evt"), + Topic: topic, + At: time.Now().Format(time.DateTime), + } + } + return RealtimeEvent{ + EventID: s.nextID("evt"), + Type: event.EventType(), + Topic: topic, + Data: event.EventPayload(), + At: time.Now().Format(time.DateTime), + } +} + +func (s *wsService) nextID(prefix string) string { + seq := s.seq.Add(1) + return fmt.Sprintf("%s_%d_%d", prefix, time.Now().UnixNano(), seq) +} + +func (s *wsService) userTopic(userID int64) string { + return realtimeTopicUserPrefix + strconv.FormatInt(userID, 10) +} + +func (s *wsService) guestTopic(guestID string) string { + return realtimeTopicGuestPrefix + strings.TrimSpace(guestID) +} + +func (s *wsService) adminTopic(userID int64) string { + return realtimeTopicAdminPrefix + strconv.FormatInt(userID, 10) +} + +func (s *wsService) notificationTopic(userID int64) string { + return realtimeTopicNotificationPrefix + strconv.FormatInt(userID, 10) +} + +func (s *wsService) conversationTopic(conversationID int64) string { + return realtimeTopicConversationPrefix + strconv.FormatInt(conversationID, 10) +} + +func normalizeRealtimeTopics(topics []string) []string { + if len(topics) == 0 { + return nil + } + ret := make([]string, 0, len(topics)) + seen := make(map[string]struct{}, len(topics)) + for _, topic := range topics { + item := strings.TrimSpace(topic) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + ret = append(ret, item) + } + return ret +} + +func parseConversationTopic(topic string) (int64, bool) { + if !strings.HasPrefix(topic, realtimeTopicConversationPrefix) { + return 0, false + } + value := strings.TrimPrefix(topic, realtimeTopicConversationPrefix) + id, err := strconv.ParseInt(value, 10, 64) + if err != nil || id <= 0 { + return 0, false + } + return id, true +} + +func sliceToSet(items []string) map[string]struct{} { + ret := make(map[string]struct{}, len(items)) + for _, item := range items { + ret[item] = struct{}{} + } + return ret +} + +func formatWsTime(t *time.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.Format(time.DateTime) +} diff --git a/internal/services/ws_service_test.go b/internal/services/ws_service_test.go new file mode 100644 index 0000000..1f57b1c --- /dev/null +++ b/internal/services/ws_service_test.go @@ -0,0 +1,203 @@ +package services + +import ( + "encoding/json" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" +) + +func TestWsNotificationTopic(t *testing.T) { + svc := newWsService() + if got := svc.notificationTopic(123); got != "notification:123" { + t.Fatalf("expected notification:123, got %q", got) + } +} + +func TestWsNotificationCreatedEventType(t *testing.T) { + event := RealtimeNotificationCreatedEvent{ + Payload: RealtimeNotificationCreatedPayload{ + Notification: response.NotificationResponse{ID: 1}, + }, + } + if got := event.EventType(); got != "notification.created" { + t.Fatalf("expected notification.created, got %q", got) + } + if payload := event.EventPayload(); payload == nil { + t.Fatalf("expected payload") + } +} + +func TestWsAgentActivityEventType(t *testing.T) { + event := RealtimeAgentActivityEvent{ + Payload: RealtimeAgentActivityPayload{ + ConversationID: 100, + StepCode: "searching_knowledge", + Status: "running", + }, + } + if got := event.EventType(); got != enums.IMRealtimeEventAgentActivity { + t.Fatalf("expected %s, got %q", enums.IMRealtimeEventAgentActivity, got) + } + if payload := event.EventPayload(); payload == nil { + t.Fatalf("expected payload") + } +} + +func TestPublishAgentActivity(t *testing.T) { + ws := newWsService() + session := &ClientSession{ + ID: "test-sess", + Topics: map[string]struct{}{}, + Send: make(chan []byte, 10), + } + ws.manager.Register(session, []string{"conversation:100"}) + + conv := &models.Conversation{ID: 100} + ws.PublishAgentActivity(conv, "searching_knowledge", "", "running") + + select { + case payload := <-session.Send: + var event struct { + Type string `json:"type"` + Data RealtimeAgentActivityPayload `json:"data"` + } + if err := json.Unmarshal(payload, &event); err != nil { + t.Fatalf("failed to unmarshal event: %v", err) + } + if event.Type != enums.IMRealtimeEventAgentActivity { + t.Fatalf("expected type %s, got %s", enums.IMRealtimeEventAgentActivity, event.Type) + } + if event.Data.ConversationID != 100 || event.Data.StepCode != "searching_knowledge" || event.Data.Status != "running" { + t.Fatalf("unexpected data: %+v", event.Data) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for agent activity event") + } +} + +func TestWsOperatorPresenceEventType(t *testing.T) { + event := RealtimeOperatorPresenceEvent{ + Payload: RealtimeOperatorPresencePayload{ + ConversationID: 100, + UserID: 1, + Username: "admin", + Nickname: "Admin", + Action: "viewing", + At: time.Now().Format(time.RFC3339), + }, + } + if got := event.EventType(); got != enums.IMRealtimeEventOperatorPresence { + t.Fatalf("expected %s, got %q", enums.IMRealtimeEventOperatorPresence, got) + } + if payload := event.EventPayload(); payload == nil { + t.Fatalf("expected payload") + } +} + +func TestPublishOperatorPresence(t *testing.T) { + ws := newWsService() + + subscriber := &ClientSession{ + ID: "subscriber-conn", + Topics: map[string]struct{}{}, + Send: make(chan []byte, 10), + } + ws.manager.Register(subscriber, []string{"conversation:100"}) + + sender := &ClientSession{ + ID: "sender-conn", + Topics: map[string]struct{}{}, + Send: make(chan []byte, 10), + } + ws.manager.Register(sender, []string{"conversation:100"}) + + principal := &dto.AuthPrincipal{ + UserID: 2, + Username: "operator", + Nickname: "Agent Smith", + } + + ws.PublishOperatorPresence(100, principal, "typing", sender.ID) + + select { + case payload := <-subscriber.Send: + var event struct { + Type string `json:"type"` + Data RealtimeOperatorPresencePayload `json:"data"` + } + if err := json.Unmarshal(payload, &event); err != nil { + t.Fatalf("failed to unmarshal event: %v", err) + } + if event.Type != enums.IMRealtimeEventOperatorPresence { + t.Fatalf("expected type %s, got %s", enums.IMRealtimeEventOperatorPresence, event.Type) + } + if event.Data.ConversationID != 100 || event.Data.UserID != 2 || event.Data.Username != "operator" || event.Data.Nickname != "Agent Smith" || event.Data.Action != "typing" { + t.Fatalf("unexpected data: %+v", event.Data) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for operator presence event on subscriber") + } + + select { + case payload := <-sender.Send: + t.Fatalf("sender should have been excluded, received: %s", string(payload)) + default: + } +} + +func TestHandleClientMessagePresence(t *testing.T) { + ws := newWsService() + + subscriber := &ClientSession{ + ID: "subscriber-conn", + Topics: map[string]struct{}{}, + Send: make(chan []byte, 10), + } + ws.manager.Register(subscriber, []string{"conversation:100"}) + + principal := &dto.AuthPrincipal{ + UserID: 2, + Username: "operator", + Nickname: "Agent Smith", + } + sender := &ClientSession{ + ID: "sender-conn", + Principal: principal, + Topics: map[string]struct{}{}, + Send: make(chan []byte, 10), + } + ws.manager.Register(sender, []string{"conversation:100"}) + + msgJSON := []byte(`{"type":"presence","conversationId":100,"action":"viewing"}`) + ws.handleClientMessage(sender, msgJSON) + + select { + case payload := <-subscriber.Send: + var event struct { + Type string `json:"type"` + Data RealtimeOperatorPresencePayload `json:"data"` + } + if err := json.Unmarshal(payload, &event); err != nil { + t.Fatalf("failed to unmarshal event: %v", err) + } + if event.Type != enums.IMRealtimeEventOperatorPresence { + t.Fatalf("expected %s, got %s", enums.IMRealtimeEventOperatorPresence, event.Type) + } + if event.Data.ConversationID != 100 || event.Data.Action != "viewing" || event.Data.UserID != 2 { + t.Fatalf("unexpected data: %+v", event.Data) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for presence broadcast") + } + + select { + case payload := <-sender.Send: + t.Fatalf("sender should not receive its own presence: %s", string(payload)) + default: + } +} diff --git a/internal/services/zalo_inbound_service.go b/internal/services/zalo_inbound_service.go new file mode 100644 index 0000000..73dc8b8 --- /dev/null +++ b/internal/services/zalo_inbound_service.go @@ -0,0 +1,126 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/zalo" +) + +var ZaloOAInboundService = newZaloOAInboundService() + +func newZaloOAInboundService() *zaloOAInboundService { + return &zaloOAInboundService{} +} + +type zaloOAInboundService struct{} + +// HandleWebhook processes an incoming webhook Event from Zalo OA. +func (s *zaloOAInboundService) HandleWebhook(ctx context.Context, channelID string, signatureHeader string, rawPayload []byte) error { + channelID = strings.TrimSpace(channelID) + var channel *models.Channel + if channelID != "" { + channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeZaloOA, enums.StatusOk) + } + if channel == nil { + channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeZaloOA, enums.StatusOk) + } + if channel == nil { + return errorsx.InvalidParam("zalo oa channel not found or disabled") + } + + cfg, err := ChannelService.ParseZaloOAChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil || cfg.AccessToken == "" { + return errorsx.InvalidParam("zalo oa channel config invalid") + } + + if cfg.WebhookSecret != "" && strings.TrimSpace(signatureHeader) != "" { + // Optional header/secret verification + if strings.TrimSpace(signatureHeader) != cfg.WebhookSecret { + return errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + } + + var event zalo.WebhookEvent + if err := json.Unmarshal(rawPayload, &event); err != nil { + return fmt.Errorf("unmarshal zalo oa event failed: %w", err) + } + + // Filter user-sent text messages + if event.EventName != "user_send_text" && event.EventName != "user_send_image" && event.EventName != "user_send_file" { + return nil + } + + senderID := strings.TrimSpace(event.Sender.ID) + if senderID == "" { + return nil + } + + text := "" + if event.Message != nil { + text = strings.TrimSpace(event.Message.Text) + } + if text == "" && event.EventName != "user_send_text" { + text = fmt.Sprintf("[%s]", event.EventName) + } + if text == "" { + return nil + } + + // 1. Resolve customer identity + name := fmt.Sprintf("Zalo User %s", senderID) + if displayName, ok := event.Info["display_name"].(string); ok && strings.TrimSpace(displayName) != "" { + name = strings.TrimSpace(displayName) + } + + externalUser := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceZaloOA, + ExternalID: senderID, + ExternalName: name, + } + + // 2. Create or match Conversation + conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID) + if err != nil { + return fmt.Errorf("create zalo conversation failed: %w", err) + } + + // 3. Send message through MessageService + msgID := "" + if event.Message != nil { + msgID = event.Message.MsgID + } + if msgID == "" { + msgID = fmt.Sprintf("zalo_%s_%s", senderID, event.Timestamp) + } + clientMsgID := fmt.Sprintf("zalo_%s", msgID) + + payloadMap := map[string]any{ + "zalo_user_id": senderID, + "zalo_msg_id": msgID, + "zalo_app_id": event.AppID, + "zalo_recipient": event.Recipient.ID, + "zalo_event": event.EventName, + } + payloadBytes, _ := json.Marshal(payloadMap) + + _, err = MessageService.SendCustomerMessage( + conversation.ID, + clientMsgID, + enums.IMMessageTypeText, + text, + string(payloadBytes), + externalUser, + ) + if err != nil { + return fmt.Errorf("send customer message failed: %w", err) + } + + return nil +} diff --git a/internal/services/zalo_outbound_service.go b/internal/services/zalo_outbound_service.go new file mode 100644 index 0000000..ca23327 --- /dev/null +++ b/internal/services/zalo_outbound_service.go @@ -0,0 +1,144 @@ +package services + +import ( + "context" + "log/slog" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/zalo" + + "github.com/mlogclub/simple/sqls" +) + +const ( + zaloOAOutboxBatchSize = 20 + zaloOAOutboxMaxRetry = 5 +) + +var ZaloOAOutboundService = newZaloOAOutboundService() + +func newZaloOAOutboundService() *zaloOAOutboundService { + return &zaloOAOutboundService{} +} + +type zaloOAOutboundService struct{} + +func (s *zaloOAOutboundService) DispatchPendingOutbox() int { + return s.doDispatchPendingOutbox(zaloOAOutboxBatchSize) +} + +func (s *zaloOAOutboundService) doDispatchPendingOutbox(limit int) int { + if limit <= 0 { + limit = zaloOAOutboxBatchSize + } + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeZaloOA, limit) + if len(items) == 0 { + return 0 + } + + successCount := 0 + for i := range items { + if err := s.processOutbox(items[i].ID); err != nil { + slog.Warn("process zalo oa outbox failed", + "outbox_id", items[i].ID, + "error", err, + ) + continue + } + successCount++ + } + return successCount +} + +func (s *zaloOAOutboundService) processOutbox(outboxID int64) error { + outbox := ChannelMessageOutboxService.Get(outboxID) + if outbox == nil { + return nil + } + if outbox.ChannelType != enums.ChannelTypeZaloOA { + return nil + } + if outbox.SendStatus == string(enums.ChannelMessageOutboxStatusSent) { + return nil + } + if outbox.NextRetryAt != nil && outbox.NextRetryAt.After(time.Now()) { + return nil + } + + if err := ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSending), + "updated_at": time.Now(), + }); err != nil { + return err + } + + message := MessageService.Get(outbox.MessageID) + if message == nil { + return s.markOutboxFailed(outbox, "message not found") + } + conversation := ConversationService.Get(outbox.ConversationID) + if conversation == nil { + return s.markOutboxFailed(outbox, "conversation not found") + } + + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.Status != enums.StatusOk { + return s.markOutboxFailed(outbox, "zalo oa channel not found or disabled") + } + cfg, err := ChannelService.ParseZaloOAChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil || cfg.AccessToken == "" { + return s.markOutboxFailed(outbox, "zalo oa access token not configured") + } + + // Resolve target Zalo User ID + var zaloUserID string + customerIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", conversation.CustomerID). + Eq("external_source", enums.ExternalSourceZaloOA)) + if customerIdentity != nil { + zaloUserID = strings.TrimSpace(customerIdentity.ExternalID) + } + if zaloUserID == "" { + return s.markOutboxFailed(outbox, "unable to resolve zalo user_id") + } + + // Send message via Zalo Client + client := zalo.NewClient(cfg.AccessToken) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + _, sendErr := client.SendCSMessage(ctx, zaloUserID, message.Content) + if sendErr != nil { + return s.markOutboxFailed(outbox, sendErr.Error()) + } + + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSent), + "sent_at": time.Now(), + "updated_at": time.Now(), + }) +} + +func (s *zaloOAOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error { + if outbox == nil { + return nil + } + retryCount := outbox.RetryCount + 1 + status := string(enums.ChannelMessageOutboxStatusFailed) + if retryCount >= zaloOAOutboxMaxRetry { + status = string(enums.ChannelMessageOutboxStatusIgnored) + } + nextRetryAt := time.Now().Add(time.Duration(retryCount*30) * time.Second) + + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": status, + "retry_count": retryCount, + "next_retry_at": &nextRetryAt, + "last_error": errMsg, + "updated_at": time.Now(), + }) +} diff --git a/internal/slack/client.go b/internal/slack/client.go new file mode 100644 index 0000000..4ed2fdc --- /dev/null +++ b/internal/slack/client.go @@ -0,0 +1,109 @@ +package slack + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const defaultBaseURL = "https://slack.com/api" + +type Client struct { + botToken string + baseURL string + httpClient *http.Client +} + +func NewClient(botToken string) *Client { + return &Client{ + botToken: strings.TrimSpace(botToken), + baseURL: defaultBaseURL, + httpClient: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (c *Client) SetBaseURL(url string) { + if strings.TrimSpace(url) != "" { + c.baseURL = strings.TrimRight(strings.TrimSpace(url), "/") + } +} + +func (c *Client) PostMessage(ctx context.Context, channel string, text string, threadTS string) (*SendMessageResponse, error) { + channel = strings.TrimSpace(channel) + if channel == "" { + return nil, fmt.Errorf("slack channel is required") + } + text = strings.TrimSpace(text) + if text == "" { + return nil, fmt.Errorf("message text is required") + } + + payload := SendMessageRequest{ + Channel: channel, + Text: text, + ThreadTS: threadTS, + } + + var resp SendMessageResponse + if err := c.doRequest(ctx, "/chat.postMessage", payload, &resp); err != nil { + return nil, err + } + if !resp.OK { + return nil, fmt.Errorf("slack api error: %s", resp.Error) + } + return &resp, nil +} + +func (c *Client) doRequest(ctx context.Context, path string, payload any, result any) error { + if c.botToken == "" { + return fmt.Errorf("slack bot token is required") + } + + endpoint := fmt.Sprintf("%s%s", c.baseURL, path) + + var bodyReader io.Reader + if payload != nil { + bodyBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal slack request failed: %w", err) + } + bodyReader = bytes.NewBuffer(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bodyReader) + if err != nil { + return fmt.Errorf("create slack request failed: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.botToken) + if payload != nil { + req.Header.Set("Content-Type", "application/json; charset=utf-8") + } + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("slack http request failed: %w", err) + } + defer res.Body.Close() + + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("read slack response failed: %w", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("slack api error (%d): %s", res.StatusCode, string(bodyBytes)) + } + + if result != nil { + if err := json.Unmarshal(bodyBytes, result); err != nil { + return fmt.Errorf("unmarshal slack response failed: %w (body: %s)", err, string(bodyBytes)) + } + } + return nil +} diff --git a/internal/slack/client_test.go b/internal/slack/client_test.go new file mode 100644 index 0000000..96fe56e --- /dev/null +++ b/internal/slack/client_test.go @@ -0,0 +1,120 @@ +package slack + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestPostMessageSendsThreadedReply(t *testing.T) { + var ( + gotPath string + gotAuth string + gotBody map[string]any + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &gotBody) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"channel":"C0123","ts":"1725260000.000100"}`)) + })) + defer server.Close() + + client := NewClient("xoxb-test-token") + client.SetBaseURL(server.URL) + + resp, err := client.PostMessage(context.Background(), "C0123", "hello there", "1725260000.000200") + if err != nil { + t.Fatalf("PostMessage failed: %v", err) + } + if resp == nil || resp.TS != "1725260000.000100" { + t.Fatalf("response = %+v, want the posted message ts", resp) + } + + if gotPath != "/chat.postMessage" { + t.Errorf("path = %q, want /chat.postMessage", gotPath) + } + if gotAuth != "Bearer xoxb-test-token" { + t.Errorf("authorization = %q, want the bot token as a bearer", gotAuth) + } + if gotBody["channel"] != "C0123" { + t.Errorf("channel = %v, want C0123", gotBody["channel"]) + } + if gotBody["text"] != "hello there" { + t.Errorf("text = %v, want 'hello there'", gotBody["text"]) + } + // A reply has to stay in the customer's thread, otherwise the workspace + // timeline fills up with support answers. + if gotBody["thread_ts"] != "1725260000.000200" { + t.Errorf("thread_ts = %v, want the parent ts", gotBody["thread_ts"]) + } +} + +func TestPostMessageOmitsThreadTSForTopLevel(t *testing.T) { + var gotRaw string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + gotRaw = string(raw) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + client := NewClient("xoxb-test-token") + client.SetBaseURL(server.URL) + + if _, err := client.PostMessage(context.Background(), "C0123", "top level", ""); err != nil { + t.Fatalf("PostMessage failed: %v", err) + } + // thread_ts is omitempty on the request struct, so a top-level post must not + // carry an empty one. + var body map[string]any + if err := json.Unmarshal([]byte(gotRaw), &body); err != nil { + t.Fatalf("unmarshal request body: %v", err) + } + if _, present := body["thread_ts"]; present { + t.Errorf("request body carried thread_ts for a top-level message: %s", gotRaw) + } +} + +func TestPostMessageSurfacesSlackAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Slack returns HTTP 200 with ok:false for application-level failures, so + // the client has to read the envelope rather than the status code. + _, _ = w.Write([]byte(`{"ok":false,"error":"channel_not_found"}`)) + })) + defer server.Close() + + client := NewClient("xoxb-test-token") + client.SetBaseURL(server.URL) + + _, err := client.PostMessage(context.Background(), "C_MISSING", "hello", "") + if err == nil { + t.Fatalf("expected an error when Slack answers ok:false") + } + if got := err.Error(); got != "slack api error: channel_not_found" { + t.Errorf("error = %q, want the slack error code to be carried through", got) + } +} + +func TestPostMessageValidatesInputs(t *testing.T) { + client := NewClient("xoxb-test-token") + + if _, err := client.PostMessage(context.Background(), "", "hello", ""); err == nil { + t.Errorf("expected an error for an empty channel") + } + if _, err := client.PostMessage(context.Background(), "C0123", " ", ""); err == nil { + t.Errorf("expected an error for blank text") + } + + noToken := NewClient("") + if _, err := noToken.PostMessage(context.Background(), "C0123", "hello", ""); err == nil { + t.Errorf("expected an error when no bot token is configured") + } +} diff --git a/internal/slack/types.go b/internal/slack/types.go new file mode 100644 index 0000000..270e4e4 --- /dev/null +++ b/internal/slack/types.go @@ -0,0 +1,37 @@ +package slack + +// SendMessageRequest represents payload for Slack chat.postMessage API. +type SendMessageRequest struct { + Channel string `json:"channel"` + Text string `json:"text"` + ThreadTS string `json:"thread_ts,omitempty"` + ParseMode string `json:"parse,omitempty"` +} + +// SendMessageResponse represents response from Slack Web API. +type SendMessageResponse struct { + OK bool `json:"ok"` + Channel string `json:"channel,omitempty"` + TS string `json:"ts,omitempty"` + Error string `json:"error,omitempty"` +} + +// EventCallback represents incoming Slack Events API payload. +type EventCallback struct { + Token string `json:"token"` + TeamID string `json:"team_id"` + APIAppID string `json:"api_app_id"` + Type string `json:"type"` // url_verification | event_callback + Challenge string `json:"challenge"` // for url_verification + Event *struct { + Type string `json:"type"` // message | app_mention + User string `json:"user"` + Text string `json:"text"` + TS string `json:"ts"` + ThreadTS string `json:"thread_ts,omitempty"` + Channel string `json:"channel"` + ChannelType string `json:"channel_type"` // im | channel | group + BotID string `json:"bot_id,omitempty"` + Subtype string `json:"subtype,omitempty"` + } `json:"event,omitempty"` +} diff --git a/internal/telegram/client.go b/internal/telegram/client.go new file mode 100644 index 0000000..ea87318 --- /dev/null +++ b/internal/telegram/client.go @@ -0,0 +1,137 @@ +package telegram + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const defaultBaseURL = "https://api.telegram.org" + +type Client struct { + token string + baseURL string + httpClient *http.Client +} + +func NewClient(token string) *Client { + return &Client{ + token: strings.TrimSpace(token), + baseURL: defaultBaseURL, + httpClient: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (c *Client) SetBaseURL(url string) { + if strings.TrimSpace(url) != "" { + c.baseURL = strings.TrimRight(strings.TrimSpace(url), "/") + } +} + +func (c *Client) GetMe(ctx context.Context) (*User, error) { + var resp APIResponse[User] + if err := c.doRequest(ctx, "getMe", nil, &resp); err != nil { + return nil, err + } + if !resp.OK { + return nil, fmt.Errorf("telegram getMe failed (%d): %s", resp.ErrorCode, resp.Description) + } + return &resp.Result, nil +} + +func (c *Client) SetWebhook(ctx context.Context, req SetWebhookRequest) error { + var resp APIResponse[bool] + if err := c.doRequest(ctx, "setWebhook", req, &resp); err != nil { + return err + } + if !resp.OK { + return fmt.Errorf("telegram setWebhook failed (%d): %s", resp.ErrorCode, resp.Description) + } + return nil +} + +func (c *Client) DeleteWebhook(ctx context.Context) error { + var resp APIResponse[bool] + if err := c.doRequest(ctx, "deleteWebhook", nil, &resp); err != nil { + return err + } + if !resp.OK { + return fmt.Errorf("telegram deleteWebhook failed (%d): %s", resp.ErrorCode, resp.Description) + } + return nil +} + +func (c *Client) GetWebhookInfo(ctx context.Context) (*WebhookInfo, error) { + var resp APIResponse[WebhookInfo] + if err := c.doRequest(ctx, "getWebhookInfo", nil, &resp); err != nil { + return nil, err + } + if !resp.OK { + return nil, fmt.Errorf("telegram getWebhookInfo failed (%d): %s", resp.ErrorCode, resp.Description) + } + return &resp.Result, nil +} + +func (c *Client) SendMessage(ctx context.Context, req SendMessageRequest) (*Message, error) { + if strings.TrimSpace(req.Text) == "" { + return nil, fmt.Errorf("telegram message text is required") + } + if req.ChatID == 0 { + return nil, fmt.Errorf("telegram chat_id is required") + } + + var resp APIResponse[Message] + if err := c.doRequest(ctx, "sendMessage", req, &resp); err != nil { + return nil, err + } + if !resp.OK { + return nil, fmt.Errorf("telegram sendMessage failed (%d): %s", resp.ErrorCode, resp.Description) + } + return &resp.Result, nil +} + +func (c *Client) doRequest(ctx context.Context, method string, payload any, result any) error { + if c.token == "" { + return fmt.Errorf("telegram bot token is required") + } + + endpoint := fmt.Sprintf("%s/bot%s/%s", c.baseURL, c.token, method) + + var bodyReader io.Reader + if payload != nil { + bodyBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal telegram request failed: %w", err) + } + bodyReader = bytes.NewBuffer(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bodyReader) + if err != nil { + return fmt.Errorf("create telegram request failed: %w", err) + } + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("telegram http request failed: %w", err) + } + defer res.Body.Close() + + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("read telegram response failed: %w", err) + } + + if err := json.Unmarshal(bodyBytes, result); err != nil { + return fmt.Errorf("unmarshal telegram response failed: %w (body: %s)", err, string(bodyBytes)) + } + return nil +} diff --git a/internal/telegram/client_test.go b/internal/telegram/client_test.go new file mode 100644 index 0000000..5c3a328 --- /dev/null +++ b/internal/telegram/client_test.go @@ -0,0 +1,124 @@ +package telegram + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestTelegramClient_GetMe(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/bot123456:ABC-DEF/getMe" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(APIResponse[User]{ + OK: true, + Result: User{ + ID: 987654321, + IsBot: true, + FirstName: "Crove Desk Bot", + Username: "crove_desk_bot", + }, + }) + })) + defer ts.Close() + + client := NewClient("123456:ABC-DEF") + client.SetBaseURL(ts.URL) + + user, err := client.GetMe(context.Background()) + if err != nil { + t.Fatalf("GetMe failed: %v", err) + } + if user.Username != "crove_desk_bot" || !user.IsBot { + t.Fatalf("unexpected user: %+v", user) + } +} + +func TestTelegramClient_SendMessage(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/bot123456:ABC-DEF/sendMessage" { + http.NotFound(w, r) + return + } + var req SendMessageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if req.ChatID != 112233 || req.Text != "Xin chào từ Crove Desk!" { + http.Error(w, "invalid params", http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(APIResponse[Message]{ + OK: true, + Result: Message{ + MessageID: 101, + Chat: Chat{ + ID: 112233, + Type: "private", + }, + Text: req.Text, + }, + }) + })) + defer ts.Close() + + client := NewClient("123456:ABC-DEF") + client.SetBaseURL(ts.URL) + + msg, err := client.SendMessage(context.Background(), SendMessageRequest{ + ChatID: 112233, + Text: "Xin chào từ Crove Desk!", + }) + if err != nil { + t.Fatalf("SendMessage failed: %v", err) + } + if msg.MessageID != 101 || msg.Text != "Xin chào từ Crove Desk!" { + t.Fatalf("unexpected sent message: %+v", msg) + } +} + +func TestTelegramClient_SetAndDeleteWebhook(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/bot123456:ABC-DEF/setWebhook": + _ = json.NewEncoder(w).Encode(APIResponse[bool]{OK: true, Result: true}) + case "/bot123456:ABC-DEF/deleteWebhook": + _ = json.NewEncoder(w).Encode(APIResponse[bool]{OK: true, Result: true}) + case "/bot123456:ABC-DEF/getWebhookInfo": + _ = json.NewEncoder(w).Encode(APIResponse[WebhookInfo]{ + OK: true, + Result: WebhookInfo{ + URL: "https://desk.crove.com/api/third/telegram/webhook/ch_123", + }, + }) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + client := NewClient("123456:ABC-DEF") + client.SetBaseURL(ts.URL) + + err := client.SetWebhook(context.Background(), SetWebhookRequest{ + URL: "https://desk.crove.com/api/third/telegram/webhook/ch_123", + }) + if err != nil { + t.Fatalf("SetWebhook failed: %v", err) + } + + info, err := client.GetWebhookInfo(context.Background()) + if err != nil || info.URL != "https://desk.crove.com/api/third/telegram/webhook/ch_123" { + t.Fatalf("GetWebhookInfo failed: %+v, err: %v", info, err) + } + + err = client.DeleteWebhook(context.Background()) + if err != nil { + t.Fatalf("DeleteWebhook failed: %v", err) + } +} diff --git a/internal/telegram/types.go b/internal/telegram/types.go new file mode 100644 index 0000000..95795c2 --- /dev/null +++ b/internal/telegram/types.go @@ -0,0 +1,70 @@ +package telegram + +// Update represents an incoming update from Telegram Bot Webhook. +type Update struct { + UpdateID int64 `json:"update_id"` + Message *Message `json:"message,omitempty"` +} + +// Message represents a Telegram message. +type Message struct { + MessageID int64 `json:"message_id"` + From *User `json:"from,omitempty"` + Chat Chat `json:"chat"` + Date int64 `json:"date"` + Text string `json:"text,omitempty"` + Caption string `json:"caption,omitempty"` +} + +// User represents a Telegram user or bot. +type User struct { + ID int64 `json:"id"` + IsBot bool `json:"is_bot"` + FirstName string `json:"first_name"` + LastName string `json:"last_name,omitempty"` + Username string `json:"username,omitempty"` + LanguageCode string `json:"language_code,omitempty"` +} + +// Chat represents a Telegram chat (private, group, supergroup, channel). +type Chat struct { + ID int64 `json:"id"` + Type string `json:"type"` + Title string `json:"title,omitempty"` + Username string `json:"username,omitempty"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` +} + +// SendMessageRequest represents payload for Telegram sendMessage API. +type SendMessageRequest struct { + ChatID int64 `json:"chat_id"` + Text string `json:"text"` + ParseMode string `json:"parse_mode,omitempty"` + ReplyToMessageID int64 `json:"reply_to_message_id,omitempty"` + DisableWebPagePreview bool `json:"disable_web_page_preview,omitempty"` +} + +// APIResponse represents standard Telegram Bot API response envelope. +type APIResponse[T any] struct { + OK bool `json:"ok"` + Result T `json:"result,omitempty"` + Description string `json:"description,omitempty"` + ErrorCode int `json:"error_code,omitempty"` +} + +// SetWebhookRequest represents payload for Telegram setWebhook API. +type SetWebhookRequest struct { + URL string `json:"url"` + SecretToken string `json:"secret_token,omitempty"` + AllowedUpdates []string `json:"allowed_updates,omitempty"` +} + +// WebhookInfo represents current Telegram webhook status. +type WebhookInfo struct { + URL string `json:"url"` + HasCustomCertificate bool `json:"has_custom_certificate"` + PendingUpdateCount int `json:"pending_update_count"` + LastErrorDate int64 `json:"last_error_date,omitempty"` + LastErrorMessage string `json:"last_error_message,omitempty"` +} diff --git a/internal/zalo/client.go b/internal/zalo/client.go new file mode 100644 index 0000000..34cad66 --- /dev/null +++ b/internal/zalo/client.go @@ -0,0 +1,140 @@ +package zalo + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const defaultBaseURL = "https://openapi.zalo.me" + +type Client struct { + accessToken string + baseURL string + httpClient *http.Client +} + +func NewClient(accessToken string) *Client { + return &Client{ + accessToken: strings.TrimSpace(accessToken), + baseURL: defaultBaseURL, + httpClient: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (c *Client) SetBaseURL(u string) { + if strings.TrimSpace(u) != "" { + c.baseURL = strings.TrimRight(strings.TrimSpace(u), "/") + } +} + +func (c *Client) SendCSMessage(ctx context.Context, userID string, text string) (*APIResponse, error) { + if strings.TrimSpace(userID) == "" { + return nil, fmt.Errorf("zalo user_id is required") + } + if strings.TrimSpace(text) == "" { + return nil, fmt.Errorf("zalo message text is required") + } + + reqPayload := SendMessageRequest{ + Recipient: UserRef{ID: strings.TrimSpace(userID)}, + Message: SendContent{Text: text}, + } + + var resp APIResponse + if err := c.doPost(ctx, "/v3.0/oa/message/cs", reqPayload, &resp); err != nil { + return nil, err + } + if resp.Error != 0 { + return nil, fmt.Errorf("zalo send message failed (%d): %s", resp.Error, resp.Message) + } + return &resp, nil +} + +func (c *Client) GetUserProfile(ctx context.Context, userID string) (*UserProfile, error) { + if strings.TrimSpace(userID) == "" { + return nil, fmt.Errorf("zalo user_id is required") + } + + params := url.Values{} + params.Set("data", fmt.Sprintf(`{"user_id":"%s"}`, strings.TrimSpace(userID))) + + var resp UserProfileResponse + if err := c.doGet(ctx, "/v3.0/oa/user/detail?"+params.Encode(), &resp); err != nil { + return nil, err + } + if resp.Error != 0 { + return nil, fmt.Errorf("zalo get user profile failed (%d): %s", resp.Error, resp.Message) + } + return &resp.Data, nil +} + +func (c *Client) doPost(ctx context.Context, path string, payload any, result any) error { + if c.accessToken == "" { + return fmt.Errorf("zalo access_token is required") + } + + endpoint := fmt.Sprintf("%s%s", c.baseURL, path) + bodyBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal zalo request failed: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(bodyBytes)) + if err != nil { + return fmt.Errorf("create zalo request failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("access_token", c.accessToken) + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("zalo http request failed: %w", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("read zalo response failed: %w", err) + } + + if err := json.Unmarshal(body, result); err != nil { + return fmt.Errorf("unmarshal zalo response failed: %w (body: %s)", err, string(body)) + } + return nil +} + +func (c *Client) doGet(ctx context.Context, path string, result any) error { + if c.accessToken == "" { + return fmt.Errorf("zalo access_token is required") + } + + endpoint := fmt.Sprintf("%s%s", c.baseURL, path) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return fmt.Errorf("create zalo request failed: %w", err) + } + req.Header.Set("access_token", c.accessToken) + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("zalo http request failed: %w", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("read zalo response failed: %w", err) + } + + if err := json.Unmarshal(body, result); err != nil { + return fmt.Errorf("unmarshal zalo response failed: %w (body: %s)", err, string(body)) + } + return nil +} diff --git a/internal/zalo/client_test.go b/internal/zalo/client_test.go new file mode 100644 index 0000000..b5564d4 --- /dev/null +++ b/internal/zalo/client_test.go @@ -0,0 +1,81 @@ +package zalo + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestZaloClient_SendCSMessage(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3.0/oa/message/cs" { + http.NotFound(w, r) + return + } + if r.Header.Get("access_token") != "test_access_token_123" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var req SendMessageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if req.Recipient.ID != "zalo_user_001" || req.Message.Text != "Xin chào từ Crove Desk!" { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(APIResponse{ + Error: 0, + Message: "Success", + Data: map[string]any{ + "message_id": "zalo_msg_1001", + }, + }) + })) + defer ts.Close() + + client := NewClient("test_access_token_123") + client.SetBaseURL(ts.URL) + + resp, err := client.SendCSMessage(context.Background(), "zalo_user_001", "Xin chào từ Crove Desk!") + if err != nil { + t.Fatalf("SendCSMessage failed: %v", err) + } + if resp.Error != 0 { + t.Fatalf("unexpected resp: %+v", resp) + } +} + +func TestZaloClient_GetUserProfile(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3.0/oa/user/detail" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(UserProfileResponse{ + Error: 0, + Message: "Success", + Data: UserProfile{ + UserID: "zalo_user_001", + DisplayName: "Nguyen Van B", + UserGender: "1", + Avatar: "https://avatar.zalo.me/1.jpg", + }, + }) + })) + defer ts.Close() + + client := NewClient("test_access_token_123") + client.SetBaseURL(ts.URL) + + profile, err := client.GetUserProfile(context.Background(), "zalo_user_001") + if err != nil { + t.Fatalf("GetUserProfile failed: %v", err) + } + if profile.DisplayName != "Nguyen Van B" || profile.UserID != "zalo_user_001" { + t.Fatalf("unexpected profile: %+v", profile) + } +} diff --git a/internal/zalo/types.go b/internal/zalo/types.go new file mode 100644 index 0000000..7ef6542 --- /dev/null +++ b/internal/zalo/types.go @@ -0,0 +1,59 @@ +package zalo + +// WebhookEvent represents an incoming webhook event from Zalo Official Account. +type WebhookEvent struct { + EventName string `json:"event_name"` + AppID string `json:"app_id"` + Sender UserRef `json:"sender"` + Recipient UserRef `json:"recipient"` + Message *EventMessage `json:"message,omitempty"` + Info map[string]any `json:"info,omitempty"` + Timestamp string `json:"timestamp"` +} + +type UserRef struct { + ID string `json:"id"` +} + +type EventMessage struct { + MsgID string `json:"msg_id"` + Text string `json:"text,omitempty"` + Attachments []EventAttachment `json:"attachments,omitempty"` +} + +type EventAttachment struct { + Type string `json:"type"` + Payload any `json:"payload"` +} + +// SendMessageRequest represents payload for Zalo OA Customer Support message API (/v3.0/oa/message/cs). +type SendMessageRequest struct { + Recipient UserRef `json:"recipient"` + Message SendContent `json:"message"` +} + +type SendContent struct { + Text string `json:"text"` +} + +// APIResponse represents standard Zalo OpenAPI response. +type APIResponse struct { + Error int `json:"error"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +// UserProfileResponse represents Zalo OA get profile API response. +type UserProfileResponse struct { + Error int `json:"error"` + Message string `json:"message"` + Data UserProfile `json:"data"` +} + +type UserProfile struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` + UserGender string `json:"user_gender"` + Avatar string `json:"avatar"` + IsSensitive bool `json:"is_sensitive"` +} diff --git a/qdrant b/qdrant new file mode 160000 index 0000000..31816f1 --- /dev/null +++ b/qdrant @@ -0,0 +1 @@ +Subproject commit 31816f1398b105cb8032e48d4474465f5b55fc77 diff --git a/screenshots/1.png b/screenshots/1.png new file mode 100644 index 0000000000000000000000000000000000000000..a64948dcb046617586b88fbd5d061a2d6fb66a80 GIT binary patch literal 197055 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ɚ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 literal 0 HcmV?d00001 diff --git a/screenshots/2.png b/screenshots/2.png new file mode 100644 index 0000000000000000000000000000000000000000..4614ea220a567f47f7b4049e7079a2966c989bb1 GIT binary patch literal 212039 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 z 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∋ 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