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 0000000..a64948d Binary files /dev/null and b/screenshots/1.png differ diff --git a/screenshots/2.png b/screenshots/2.png new file mode 100644 index 0000000..4614ea2 Binary files /dev/null and b/screenshots/2.png differ