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

This commit is contained in:
2026-10-16 11:58:36 +00:00
parent 89c5231c4c
commit fe4a99cf4b
127 changed files with 29641 additions and 0 deletions

View File

@@ -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",
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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(&current, "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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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"}
}

View File

@@ -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,
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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(),
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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(),
})
}

View File

@@ -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),
})
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
}

View File

@@ -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))
}
}

View File

@@ -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)
})
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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",
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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(),
})
}

View File

@@ -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
}

View File

@@ -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 ""
}

View File

@@ -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)
}
}

View File

@@ -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())
}

View File

@@ -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("<agent-desk-msg-%d-%d@%s>", 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<<retryCount) * 10 * time.Second
nextRetry := now.Add(backoff)
return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
"send_status": string(enums.ChannelMessageOutboxStatusPending),
"send_detail": fmt.Sprintf("retry #%d error: %s", retryCount, errMsg),
"retry_count": retryCount,
"updated_at": now,
"next_retry_at": &nextRetry,
})
}

View File

@@ -0,0 +1,445 @@
package services
import (
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"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/i18nx"
"agent-desk/internal/pkg/openidentity"
"agent-desk/internal/pkg/utils"
"github.com/mlogclub/simple/common/strs"
"github.com/mlogclub/simple/sqls"
)
var EmailRequestService = newEmailRequestService()
func newEmailRequestService() *emailRequestService {
return &emailRequestService{}
}
type emailRequestService struct{}
// EmailRequestSourceAPI marks a message that entered through the API rather than
// through a real inbound email, so nothing downstream mistakes it for one.
const EmailRequestSourceAPI = "api_request"
// maxEmailRequestSubjectLength mirrors the conversation subject column width.
const maxEmailRequestSubjectLength = 255
// EmailRequestInput is what an external system submits to open a support request
// on a visitor's behalf.
type EmailRequestInput struct {
Email string
Name string
Subject string
Message string
}
// EmailRequestOutcome says where the submission actually landed, which is not
// necessarily an email thread: a live chat takes precedence.
type EmailRequestOutcome string
const (
// EmailRequestOutcomeEmail means the request was filed as an email thread.
EmailRequestOutcomeEmail EmailRequestOutcome = "email"
// EmailRequestOutcomeChat means a chat was still live, so the submission was
// appended to it and the visitor was nudged back to it.
EmailRequestOutcomeChat EmailRequestOutcome = "chat"
)
type EmailRequestResult struct {
ConversationID int64 `json:"conversationId"`
Outcome EmailRequestOutcome `json:"outcome"`
// Verified reports whether the submitter presented the channel secret. When
// false the contact details are self-reported and agents must be told so.
Verified bool `json:"verified"`
Subject string `json:"subject"`
}
// HandleRequest files a support request supplied over the API and continues the
// conversation over email from there.
//
// Two modes share one endpoint. Presenting the channel secret means the submitter
// is trusted and the address becomes a verified contact; omitting it files the
// request as self-reported. A secret that is present but wrong is an error rather
// than a silent downgrade, because a typo must not quietly turn a verified
// integration into an unverified one.
func (s *emailRequestService) HandleRequest(ctx context.Context, channelID, secretHeader string, in EmailRequestInput) (*EmailRequestResult, error) {
channel, err := s.resolveChannel(channelID)
if err != nil {
return nil, err
}
channelCfg, err := ChannelService.ParseEmailChannelConfig(channel.ConfigJSON)
if err != nil || channelCfg == nil {
return nil, errorsx.InvalidParamI18n("error.email.channelConfigInvalid")
}
verified, err := verifyEmailRequestSecret(channelCfg, secretHeader)
if err != nil {
return nil, err
}
fromEmail := normalizeEmailAddress(in.Email)
if err := validateEmailRequestAddress(fromEmail); err != nil {
return nil, err
}
fromName := strings.TrimSpace(in.Name)
if fromName == "" {
fromName = localPartOf(fromEmail)
}
subject := normalizeEmailRequestSubject(in.Subject)
bodyText := strings.TrimSpace(in.Message)
if bodyText == "" {
return nil, errorsx.InvalidParamI18n("error.email.requestMessageRequired")
}
externalUser := openidentity.ExternalUser{
ExternalSource: enums.ExternalSourceEmail,
ExternalID: fromEmail,
ExternalName: fromName,
}
customerID, err := s.ensureCustomer(externalUser)
if err != nil {
return nil, err
}
if err := s.markContactVerification(customerID, fromEmail, verified); err != nil {
slog.Warn("failed to record email contact verification",
"customerId", customerID, "email", fromEmail, "verified", verified, "error", err)
}
content := bodyText
if subject != "" {
content = fmt.Sprintf("[%s]\n\n%s", subject, bodyText)
}
// A chat the visitor is still using wins over their email. Mail that jumps
// into an open chat gets answered where the visitor is no longer watching.
if liveChat := s.findLiveChat(customerID, channel.ID); liveChat != nil {
if err := s.postRequestMessage(liveChat, externalUser, content, subject, fromEmail, fromName); err != nil {
return nil, err
}
if err := s.sendChatNudge(liveChat, channel); err != nil {
// The message is already in the chat, so a failed notice is not fatal.
slog.Warn("failed to send chat continuation notice",
"conversationId", liveChat.ID, "error", err)
}
return &EmailRequestResult{
ConversationID: liveChat.ID,
Outcome: EmailRequestOutcomeChat,
Verified: verified,
Subject: subject,
}, nil
}
conversation, err := ConversationService.CreateNew(externalUser, channel.ID, channel.AIAgentID, subject)
if err != nil {
return nil, fmt.Errorf("create email conversation failed: %w", err)
}
if err := s.postRequestMessage(conversation, externalUser, content, subject, fromEmail, fromName); err != nil {
return nil, err
}
if err := s.sendAcknowledgement(conversation, channelCfg, channel); err != nil {
// The request is already filed. Failing here would make the caller retry
// and file the same request twice, which is worse than a missing receipt.
slog.Warn("failed to send email acknowledgement",
"conversationId", conversation.ID, "error", err)
}
return &EmailRequestResult{
ConversationID: conversation.ID,
Outcome: EmailRequestOutcomeEmail,
Verified: verified,
Subject: subject,
}, nil
}
// resolveChannel prefers an explicit channel id and otherwise falls back to the
// first active email channel, mirroring the webhook path.
func (s *emailRequestService) resolveChannel(channelID string) (*models.Channel, error) {
channelID = strings.TrimSpace(channelID)
if channelID != "" {
channel := ChannelService.Take(
"channel_id = ? AND channel_type = ? AND status = ?",
channelID, enums.ChannelTypeEmail, enums.StatusOk,
)
if channel == nil {
return nil, errorsx.InvalidParamI18n("error.email.channelNotFound")
}
return channel, nil
}
channels := ChannelService.Find(sqls.NewCnd().
Eq("channel_type", enums.ChannelTypeEmail).
Eq("status", enums.StatusOk).
Asc("id"))
if len(channels) == 0 {
return nil, errorsx.InvalidParamI18n("error.email.channelNotFound")
}
return &channels[0], nil
}
// verifyEmailRequestSecret decides between the trusted and self-reported modes.
func verifyEmailRequestSecret(cfg *dto.EmailChannelConfig, secretHeader string) (bool, error) {
expected := strings.TrimSpace(cfg.WebhookSecret)
if expected == "" {
if current := config.GetCurrent(); current != nil {
expected = strings.TrimSpace(current.Email.InboundSecret)
}
}
provided := strings.TrimSpace(secretHeader)
if provided == "" {
// Nothing was offered to check, so nothing is being trusted. This is the
// documented guest mode, not a bypass.
return false, nil
}
if expected == "" {
// A secret was sent but the deployment never configured one to compare
// against, so we cannot honestly call the submitter verified.
return false, errorsx.InvalidParamI18n("error.email.requestSecretNotConfigured")
}
if subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 {
return false, errorsx.UnauthorizedI18n("error.auth.invalidSignature")
}
return true, nil
}
func normalizeEmailAddress(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func localPartOf(address string) string {
if parts := strings.Split(address, "@"); len(parts) > 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")
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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))
}

View File

@@ -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",
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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 := &params.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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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<cursor 的更旧消息。
// 返回的 list 已按 id 升序(时间正序)。nextCursor 为下一页请求传入的游标(本批最小 id);hasMore 表示可能还有更旧消息。
func (s *messageService) FindByConversationIDCursor(conversationID int64, cursor int64, limit int, senderType, messageType string) (list []models.Message, nextCursor int64, hasMore bool) {
if limit > 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
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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 &notificationService{}
}
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())
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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))
}

View File

@@ -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")
}
}

View File

@@ -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(),
})
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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), "/")
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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(`<html><body><script>fetch("/api/dashboard/user/current")</script></body></html>`)
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(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
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)
}
}
}

View File

@@ -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 ""
}

View File

@@ -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
}

View File

@@ -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: "<p>Hello</p>",
}, 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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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},
}
}

View File

@@ -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)
}
}

View File

@@ -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,
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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(),
})
}

View File

@@ -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")
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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: "<p>客户反馈<strong>无法登录</strong></p><ul><li>验证码错误</li></ul>",
}, operator)
if err != nil {
t.Fatalf("CreateTicket() error = %v", err)
}
if created.Description != "<p>客户反馈<strong>无法登录</strong></p><ul><li>验证码错误</li></ul>" {
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: "<p>已回访客户,结论:<strong>继续观察</strong></p>",
}, operator)
if err != nil {
t.Fatalf("AddProgress() error = %v", err)
}
if progress.Content != "<p>已回访客户,结论:<strong>继续观察</strong></p>" {
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
}

Some files were not shown because too many files have changed in this diff Show More