chore: import clean Agent Desk snapshot (3/7)
This commit is contained in:
393
internal/services/ai_workflow_service_test.go
Normal file
393
internal/services/ai_workflow_service_test.go
Normal 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",
|
||||
}
|
||||
}
|
||||
215
internal/services/asset_service.go
Normal file
215
internal/services/asset_service.go
Normal 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
|
||||
}
|
||||
542
internal/services/auth_service.go
Normal file
542
internal/services/auth_service.go
Normal 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
|
||||
}
|
||||
521
internal/services/auth_service_test.go
Normal file
521
internal/services/auth_service_test.go
Normal 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(¤t, "token = ?", "ak_current").Error; err != nil {
|
||||
t.Fatalf("query current session: %v", err)
|
||||
}
|
||||
if current.RevokedAt == nil {
|
||||
t.Fatal("expected current session to be revoked")
|
||||
}
|
||||
var other models.LoginSession
|
||||
if err := db.Take(&other, "token = ?", "ak_other").Error; err != nil {
|
||||
t.Fatalf("query other session: %v", err)
|
||||
}
|
||||
if other.RevokedAt != nil {
|
||||
t.Fatal("expected other session to remain active")
|
||||
}
|
||||
}
|
||||
|
||||
func setupAuthServiceTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&models.Organization{},
|
||||
&models.OrganizationMember{},
|
||||
&models.User{},
|
||||
&models.UserIdentity{},
|
||||
&models.Role{},
|
||||
&models.Permission{},
|
||||
&models.UserRole{},
|
||||
&models.RolePermission{},
|
||||
&models.UserPermission{},
|
||||
&models.LoginSession{},
|
||||
&models.LoginCredentialLog{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate auth tables: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
return db
|
||||
}
|
||||
|
||||
func createAuthTestUser(t *testing.T, db *gorm.DB, username, password string) *models.User {
|
||||
t.Helper()
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
user := &models.User{
|
||||
Username: username,
|
||||
Nickname: username,
|
||||
Password: string(passwordHash),
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create auth test user: %v", err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func findCredentialLogs(t *testing.T, db *gorm.DB) []models.LoginCredentialLog {
|
||||
t.Helper()
|
||||
var logs []models.LoginCredentialLog
|
||||
if err := db.Order("id ASC").Find(&logs).Error; err != nil {
|
||||
t.Fatalf("query credential logs: %v", err)
|
||||
}
|
||||
return logs
|
||||
}
|
||||
|
||||
func hasCode(err error, code int) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var codeErr *web.CodeError
|
||||
if errors.As(err, &codeErr) {
|
||||
return codeErr.Code == code
|
||||
}
|
||||
return false
|
||||
}
|
||||
142
internal/services/branding_service.go
Normal file
142
internal/services/branding_service.go
Normal 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
|
||||
}
|
||||
152
internal/services/business_tool_executor.go
Normal file
152
internal/services/business_tool_executor.go
Normal 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
|
||||
}
|
||||
426
internal/services/channel_message_outbox_service.go
Normal file
426
internal/services/channel_message_outbox_service.go
Normal 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)
|
||||
}
|
||||
685
internal/services/channel_service.go
Normal file
685
internal/services/channel_service.go
Normal 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
|
||||
}
|
||||
139
internal/services/channel_service_test.go
Normal file
139
internal/services/channel_service_test.go
Normal 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"}
|
||||
}
|
||||
150
internal/services/company_service.go
Normal file
150
internal/services/company_service.go
Normal 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,
|
||||
})
|
||||
}
|
||||
76
internal/services/conversation_assignment_service.go
Normal file
76
internal/services/conversation_assignment_service.go
Normal 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
|
||||
}
|
||||
376
internal/services/conversation_customer_service_test.go
Normal file
376
internal/services/conversation_customer_service_test.go
Normal 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
|
||||
}
|
||||
476
internal/services/conversation_dispatch_service.go
Normal file
476
internal/services/conversation_dispatch_service.go
Normal 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
|
||||
}
|
||||
88
internal/services/conversation_event_log_service.go
Normal file
88
internal/services/conversation_event_log_service.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
242
internal/services/conversation_human_dispatch_realtime_test.go
Normal file
242
internal/services/conversation_human_dispatch_realtime_test.go
Normal 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
|
||||
}
|
||||
443
internal/services/conversation_human_dispatch_service.go
Normal file
443
internal/services/conversation_human_dispatch_service.go
Normal 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
|
||||
}
|
||||
423
internal/services/conversation_human_dispatch_service_test.go
Normal file
423
internal/services/conversation_human_dispatch_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
195
internal/services/conversation_interrupt_service.go
Normal file
195
internal/services/conversation_interrupt_service.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
83
internal/services/conversation_participant_service.go
Normal file
83
internal/services/conversation_participant_service.go
Normal 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),
|
||||
})
|
||||
}
|
||||
275
internal/services/conversation_read_state_service.go
Normal file
275
internal/services/conversation_read_state_service.go
Normal 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
|
||||
}
|
||||
85
internal/services/conversation_satisfaction_service.go
Normal file
85
internal/services/conversation_satisfaction_service.go
Normal 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)
|
||||
}
|
||||
150
internal/services/conversation_satisfaction_service_test.go
Normal file
150
internal/services/conversation_satisfaction_service_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
1006
internal/services/conversation_service.go
Normal file
1006
internal/services/conversation_service.go
Normal file
File diff suppressed because it is too large
Load Diff
95
internal/services/conversation_tag_service.go
Normal file
95
internal/services/conversation_tag_service.go
Normal 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
|
||||
}
|
||||
54
internal/services/cronx/cron.go
Normal file
54
internal/services/cronx/cron.go
Normal 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))
|
||||
}
|
||||
}
|
||||
524
internal/services/customer_contact_service.go
Normal file
524
internal/services/customer_contact_service.go
Normal 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)
|
||||
})
|
||||
}
|
||||
67
internal/services/customer_identity_service.go
Normal file
67
internal/services/customer_identity_service.go
Normal 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)
|
||||
}
|
||||
373
internal/services/customer_service.go
Normal file
373
internal/services/customer_service.go
Normal 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
|
||||
}
|
||||
125
internal/services/customer_service_test.go
Normal file
125
internal/services/customer_service_test.go
Normal 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
|
||||
}
|
||||
252
internal/services/customer_session_service.go
Normal file
252
internal/services/customer_session_service.go
Normal 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)
|
||||
}
|
||||
519
internal/services/dashboard_service.go
Normal file
519
internal/services/dashboard_service.go
Normal 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",
|
||||
}
|
||||
41
internal/services/dashboard_service_test.go
Normal file
41
internal/services/dashboard_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
176
internal/services/discord_inbound_service.go
Normal file
176
internal/services/discord_inbound_service.go
Normal 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
|
||||
}
|
||||
294
internal/services/discord_inbound_service_test.go
Normal file
294
internal/services/discord_inbound_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
169
internal/services/discord_integration_test.go
Normal file
169
internal/services/discord_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
231
internal/services/discord_outbound_service.go
Normal file
231
internal/services/discord_outbound_service.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
71
internal/services/email_delivery_service.go
Normal file
71
internal/services/email_delivery_service.go
Normal 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
|
||||
}
|
||||
125
internal/services/email_delivery_settings.go
Normal file
125
internal/services/email_delivery_settings.go
Normal 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 ""
|
||||
}
|
||||
144
internal/services/email_delivery_settings_test.go
Normal file
144
internal/services/email_delivery_settings_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
243
internal/services/email_inbound_service.go
Normal file
243
internal/services/email_inbound_service.go
Normal 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())
|
||||
}
|
||||
239
internal/services/email_outbound_service.go
Normal file
239
internal/services/email_outbound_service.go
Normal 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,
|
||||
})
|
||||
}
|
||||
445
internal/services/email_request_service.go
Normal file
445
internal/services/email_request_service.go
Normal 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")
|
||||
}
|
||||
510
internal/services/email_request_service_test.go
Normal file
510
internal/services/email_request_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
100
internal/services/event_handlers/notification_event_handler.go
Normal file
100
internal/services/event_handlers/notification_event_handler.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
116
internal/services/im_message_asset.go
Normal file
116
internal/services/im_message_asset.go
Normal 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
|
||||
}
|
||||
331
internal/services/knowledge_base_service.go
Normal file
331
internal/services/knowledge_base_service.go
Normal 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
|
||||
}
|
||||
}
|
||||
205
internal/services/knowledge_base_service_test.go
Normal file
205
internal/services/knowledge_base_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
212
internal/services/knowledge_directory_service.go
Normal file
212
internal/services/knowledge_directory_service.go
Normal 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))
|
||||
}
|
||||
153
internal/services/knowledge_directory_service_test.go
Normal file
153
internal/services/knowledge_directory_service_test.go
Normal 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",
|
||||
}
|
||||
}
|
||||
290
internal/services/knowledge_document_service.go
Normal file
290
internal/services/knowledge_document_service.go
Normal 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
|
||||
}
|
||||
371
internal/services/knowledge_faq_excel.go
Normal file
371
internal/services/knowledge_faq_excel.go
Normal 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
|
||||
}
|
||||
249
internal/services/knowledge_faq_excel_test.go
Normal file
249
internal/services/knowledge_faq_excel_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
238
internal/services/knowledge_faq_service.go
Normal file
238
internal/services/knowledge_faq_service.go
Normal 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
|
||||
}
|
||||
47
internal/services/knowledge_retrieve_log_service.go
Normal file
47
internal/services/knowledge_retrieve_log_service.go
Normal 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 := ¶ms.Cnd
|
||||
cnd.Find(sqls.DB(), &list)
|
||||
count := cnd.Count(sqls.DB(), &models.KnowledgeRetrieveLog{})
|
||||
paging = &sqls.Paging{
|
||||
Page: cnd.Paging.Page,
|
||||
Limit: cnd.Paging.Limit,
|
||||
Total: count,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *knowledgeRetrieveLogService) FindHitsByRetrieveLogID(retrieveLogID int64) []models.KnowledgeRetrieveHit {
|
||||
if retrieveLogID <= 0 {
|
||||
return nil
|
||||
}
|
||||
var list []models.KnowledgeRetrieveHit
|
||||
sqls.DB().Where("retrieve_log_id = ?", retrieveLogID).Order("rank_no asc, id asc").Find(&list)
|
||||
return list
|
||||
}
|
||||
67
internal/services/login_credential_log_service.go
Normal file
67
internal/services/login_credential_log_service.go
Normal 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)
|
||||
}
|
||||
95
internal/services/login_session_service.go
Normal file
95
internal/services/login_session_service.go
Normal 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
|
||||
}
|
||||
155
internal/services/mcp_debug_service.go
Normal file
155
internal/services/mcp_debug_service.go
Normal 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)
|
||||
}
|
||||
747
internal/services/message_service.go
Normal file
747
internal/services/message_service.go
Normal 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
|
||||
}
|
||||
461
internal/services/message_service_test.go
Normal file
461
internal/services/message_service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
67
internal/services/migration_service.go
Normal file
67
internal/services/migration_service.go
Normal 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)
|
||||
}
|
||||
105
internal/services/notification_service.go
Normal file
105
internal/services/notification_service.go
Normal 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 ¬ificationService{}
|
||||
}
|
||||
|
||||
type notificationService struct {
|
||||
}
|
||||
|
||||
func (s *notificationService) Create(req request.CreateNotificationRequest) (*models.Notification, error) {
|
||||
if req.RecipientUserID <= 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0212")
|
||||
}
|
||||
now := time.Now()
|
||||
item := &models.Notification{
|
||||
RecipientUserID: req.RecipientUserID,
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
Content: strings.TrimSpace(req.Content),
|
||||
NotificationType: strings.TrimSpace(req.NotificationType),
|
||||
BizType: strings.TrimSpace(req.BizType),
|
||||
BizID: req.BizID,
|
||||
ActionURL: strings.TrimSpace(req.ActionURL),
|
||||
Status: enums.StatusOk,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := repositories.NotificationRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *notificationService) CreateAndPush(req request.CreateNotificationRequest) (*models.Notification, error) {
|
||||
item, err := s.Create(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
WsService.PublishNotificationCreated(item.RecipientUserID, response.NotificationResponse{
|
||||
ID: item.ID,
|
||||
RecipientUserID: item.RecipientUserID,
|
||||
Title: item.Title,
|
||||
Content: item.Content,
|
||||
NotificationType: item.NotificationType,
|
||||
BizType: item.BizType,
|
||||
BizID: item.BizID,
|
||||
ActionURL: item.ActionURL,
|
||||
ReadAt: utils.FormatTimePtr(item.ReadAt),
|
||||
CreatedAt: utils.FormatTime(item.CreatedAt),
|
||||
})
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *notificationService) FindPageByCnd(cnd *sqls.Cnd) ([]models.Notification, *sqls.Paging) {
|
||||
return repositories.NotificationRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *notificationService) CountUnread(userID int64) int64 {
|
||||
if userID <= 0 {
|
||||
return 0
|
||||
}
|
||||
return repositories.NotificationRepository.Count(sqls.DB(), sqls.NewCnd().
|
||||
Eq("recipient_user_id", userID).
|
||||
Eq("status", enums.StatusOk).
|
||||
Where("read_at IS NULL"))
|
||||
}
|
||||
|
||||
func (s *notificationService) MarkRead(id int64, userID int64) error {
|
||||
if id <= 0 {
|
||||
return errorsx.InvalidParamI18n("error.e0337")
|
||||
}
|
||||
item := repositories.NotificationRepository.Get(sqls.DB(), id)
|
||||
if item == nil || item.RecipientUserID != userID {
|
||||
return errorsx.InvalidParamI18n("error.e0337")
|
||||
}
|
||||
if item.ReadAt != nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
return repositories.NotificationRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"read_at": now,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *notificationService) MarkAllRead(userID int64) error {
|
||||
if userID <= 0 {
|
||||
return errorsx.InvalidParamI18n("error.e0212")
|
||||
}
|
||||
return repositories.NotificationRepository.MarkAllRead(sqls.DB(), userID, time.Now())
|
||||
}
|
||||
126
internal/services/notification_service_test.go
Normal file
126
internal/services/notification_service_test.go
Normal 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
|
||||
}
|
||||
416
internal/services/oidc_login_service.go
Normal file
416
internal/services/oidc_login_service.go
Normal 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)
|
||||
}
|
||||
}
|
||||
153
internal/services/oidc_login_service_test.go
Normal file
153
internal/services/oidc_login_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
98
internal/services/organization_service.go
Normal file
98
internal/services/organization_service.go
Normal 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)
|
||||
}
|
||||
144
internal/services/permission_service.go
Normal file
144
internal/services/permission_service.go
Normal 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,
|
||||
}
|
||||
}
|
||||
100
internal/services/permission_service_test.go
Normal file
100
internal/services/permission_service_test.go
Normal 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
|
||||
}
|
||||
125
internal/services/quick_reply_service.go
Normal file
125
internal/services/quick_reply_service.go
Normal 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
|
||||
}
|
||||
67
internal/services/role_permission_service.go
Normal file
67
internal/services/role_permission_service.go
Normal 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)
|
||||
}
|
||||
204
internal/services/role_service.go
Normal file
204
internal/services/role_service.go
Normal 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
|
||||
})
|
||||
}
|
||||
1630
internal/services/setup_readiness_service.go
Normal file
1630
internal/services/setup_readiness_service.go
Normal file
File diff suppressed because it is too large
Load Diff
436
internal/services/setup_readiness_service_test.go
Normal file
436
internal/services/setup_readiness_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
202
internal/services/skill_definition_service.go
Normal file
202
internal/services/skill_definition_service.go
Normal 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)
|
||||
}
|
||||
53
internal/services/skill_runtime_service.go
Normal file
53
internal/services/skill_runtime_service.go
Normal 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)
|
||||
}
|
||||
180
internal/services/slack_inbound_service.go
Normal file
180
internal/services/slack_inbound_service.go
Normal 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))
|
||||
}
|
||||
368
internal/services/slack_inbound_service_test.go
Normal file
368
internal/services/slack_inbound_service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
187
internal/services/slack_outbound_service.go
Normal file
187
internal/services/slack_outbound_service.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
23
internal/services/storage/dto.go
Normal file
23
internal/services/storage/dto.go
Normal 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
|
||||
}
|
||||
70
internal/services/storage/local.go
Normal file
70
internal/services/storage/local.go
Normal 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)
|
||||
}
|
||||
177
internal/services/storage/oss.go
Normal file
177
internal/services/storage/oss.go
Normal 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), "/")
|
||||
}
|
||||
50
internal/services/storage/provider.go
Normal file
50
internal/services/storage/provider.go
Normal 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")
|
||||
}
|
||||
}
|
||||
218
internal/services/storage/safety.go
Normal file
218
internal/services/storage/safety.go
Normal 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
|
||||
}
|
||||
268
internal/services/storage/safety_test.go
Normal file
268
internal/services/storage/safety_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
78
internal/services/storage/utils.go
Normal file
78
internal/services/storage/utils.go
Normal 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 ""
|
||||
}
|
||||
740
internal/services/support_service.go
Normal file
740
internal/services/support_service.go
Normal 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
|
||||
}
|
||||
360
internal/services/support_service_test.go
Normal file
360
internal/services/support_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
98
internal/services/system_config_branding_validator.go
Normal file
98
internal/services/system_config_branding_validator.go
Normal 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)
|
||||
}
|
||||
307
internal/services/system_config_service.go
Normal file
307
internal/services/system_config_service.go
Normal 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)
|
||||
}
|
||||
139
internal/services/system_config_service_test.go
Normal file
139
internal/services/system_config_service_test.go
Normal 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
|
||||
}
|
||||
170
internal/services/system_config_support_validator.go
Normal file
170
internal/services/system_config_support_validator.go
Normal 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},
|
||||
}
|
||||
}
|
||||
30
internal/services/system_config_support_validator_test.go
Normal file
30
internal/services/system_config_support_validator_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
269
internal/services/tag_service.go
Normal file
269
internal/services/tag_service.go
Normal 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,
|
||||
})
|
||||
}
|
||||
113
internal/services/telegram_inbound_service.go
Normal file
113
internal/services/telegram_inbound_service.go
Normal 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
|
||||
}
|
||||
179
internal/services/telegram_integration_test.go
Normal file
179
internal/services/telegram_integration_test.go
Normal 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
|
||||
}
|
||||
150
internal/services/telegram_outbound_service.go
Normal file
150
internal/services/telegram_outbound_service.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
123
internal/services/ticket_no_service.go
Normal file
123
internal/services/ticket_no_service.go
Normal 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")
|
||||
}
|
||||
67
internal/services/ticket_progress_service.go
Normal file
67
internal/services/ticket_progress_service.go
Normal 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)
|
||||
}
|
||||
690
internal/services/ticket_service.go
Normal file
690
internal/services/ticket_service.go
Normal 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
|
||||
}
|
||||
718
internal/services/ticket_service_test.go
Normal file
718
internal/services/ticket_service_test.go
Normal 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
Reference in New Issue
Block a user