refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
@@ -38,62 +38,50 @@ func (s *agentRevisionService) FindByAgentID(agentID int64) []models.AgentRevisi
|
||||
}
|
||||
|
||||
type agentRevisionDefinition struct {
|
||||
Agent agentRevisionAgent `json:"agent"`
|
||||
Model agentRevisionModel `json:"model"`
|
||||
WorkflowBindings []AgentRevisionWorkflowBinding `json:"workflowBindings"`
|
||||
}
|
||||
|
||||
type AgentRevisionWorkflowBinding struct {
|
||||
WorkflowID int64 `json:"workflowId"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
ToolName string `json:"toolName"`
|
||||
TriggerInstruction string `json:"triggerInstruction"`
|
||||
Priority int `json:"priority"`
|
||||
Agent agentRevisionAgent `json:"agent"`
|
||||
Model agentRevisionModel `json:"model"`
|
||||
}
|
||||
|
||||
// agentRevisionModel deliberately excludes APIKey. A revision must capture
|
||||
// reproducible routing/model parameters without duplicating credentials.
|
||||
type agentRevisionModel struct {
|
||||
ConfigID int64 `json:"configId"`
|
||||
ConfigID int64 `json:"config_id"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
ModelType string `json:"modelType"`
|
||||
ModelName string `json:"modelName"`
|
||||
MaxContextTokens int `json:"maxContextTokens"`
|
||||
MaxOutputTokens int `json:"maxOutputTokens"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
MaxRetryCount int `json:"maxRetryCount"`
|
||||
BaseURL string `json:"base_url"`
|
||||
ModelType string `json:"model_type"`
|
||||
ModelName string `json:"model_name"`
|
||||
MaxContextTokens int `json:"max_context_tokens"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
MaxRetryCount int `json:"max_retry_count"`
|
||||
}
|
||||
|
||||
type agentRevisionAgent struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AIConfigID int64 `json:"aiConfigId"`
|
||||
MaxSteps int `json:"maxSteps"`
|
||||
ContextWindow int `json:"contextWindow"`
|
||||
ToolPolicy string `json:"toolPolicy"`
|
||||
KnowledgePolicy string `json:"knowledgePolicy"`
|
||||
ServiceMode int `json:"serviceMode"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
WelcomeMessage string `json:"welcomeMessage"`
|
||||
ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"`
|
||||
TeamIDs string `json:"teamIds"`
|
||||
HandoffMode int `json:"handoffMode"`
|
||||
FallbackMode int `json:"fallbackMode"`
|
||||
FallbackMessage string `json:"fallbackMessage"`
|
||||
KnowledgeIDs string `json:"knowledgeIds"`
|
||||
SkillIDs string `json:"skillIds"`
|
||||
AllowedMCPTools string `json:"allowedMcpTools"`
|
||||
AIConfigID int64 `json:"ai_config_id"`
|
||||
MaxSteps int `json:"max_steps"`
|
||||
ContextWindow int `json:"context_window"`
|
||||
ToolPolicy string `json:"tool_policy"`
|
||||
KnowledgePolicy string `json:"knowledge_policy"`
|
||||
ServiceMode int `json:"service_mode"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
WelcomeMessage string `json:"welcome_message"`
|
||||
ReplyTimeoutSeconds int `json:"reply_timeout_seconds"`
|
||||
TeamIDs string `json:"team_ids"`
|
||||
HandoffMode int `json:"handoff_mode"`
|
||||
FallbackMode int `json:"fallback_mode"`
|
||||
FallbackMessage string `json:"fallback_message"`
|
||||
KnowledgeIDs string `json:"knowledge_ids"`
|
||||
}
|
||||
|
||||
// AgentRevisionSnapshot is the immutable runtime configuration restored from
|
||||
// a published revision. Model credentials deliberately remain on the current
|
||||
// AIConfig so credential rotation does not require republishing every Agent.
|
||||
type AgentRevisionSnapshot struct {
|
||||
Revision models.AgentRevision
|
||||
Agent models.AIAgent
|
||||
AIConfig models.AIConfig
|
||||
WorkflowBindings []AgentRevisionWorkflowBinding
|
||||
Revision models.AgentRevision
|
||||
Agent models.AIAgent
|
||||
AIConfig models.AIConfig
|
||||
}
|
||||
|
||||
// ResolvePublishedSnapshot restores an immutable published Agent revision.
|
||||
@@ -117,7 +105,7 @@ func (s *agentRevisionService) ResolvePublishedSnapshot(agent models.AIAgent, co
|
||||
if publishedConfigID <= 0 {
|
||||
publishedConfigID = definition.Model.ConfigID
|
||||
}
|
||||
if publishedConfigID > 0 && publishedConfigID != config.ID {
|
||||
if !config.Platform && publishedConfigID > 0 && publishedConfigID != config.ID {
|
||||
publishedConfig := repositories.AIConfigRepository.Get(sqls.DB(), publishedConfigID)
|
||||
if publishedConfig == nil || publishedConfig.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("published agent model config is unavailable")
|
||||
@@ -125,8 +113,9 @@ func (s *agentRevisionService) ResolvePublishedSnapshot(agent models.AIAgent, co
|
||||
snapshot.AIConfig = *publishedConfig
|
||||
}
|
||||
applyRevisionAgentSnapshot(&snapshot.Agent, definition.Agent)
|
||||
snapshot.WorkflowBindings = append([]AgentRevisionWorkflowBinding(nil), definition.WorkflowBindings...)
|
||||
applyRevisionModelSnapshot(&snapshot.AIConfig, definition.Model)
|
||||
if !config.Platform {
|
||||
applyRevisionModelSnapshot(&snapshot.AIConfig, definition.Model)
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
@@ -150,8 +139,6 @@ func applyRevisionAgentSnapshot(agent *models.AIAgent, definition agentRevisionA
|
||||
agent.FallbackMode = enums.AIAgentFallbackMode(definition.FallbackMode)
|
||||
agent.FallbackMessage = definition.FallbackMessage
|
||||
agent.KnowledgeIDs = definition.KnowledgeIDs
|
||||
agent.SkillIDs = definition.SkillIDs
|
||||
agent.AllowedMCPTools = definition.AllowedMCPTools
|
||||
}
|
||||
|
||||
func applyRevisionModelSnapshot(config *models.AIConfig, definition agentRevisionModel) {
|
||||
@@ -188,13 +175,9 @@ func (s *agentRevisionService) publishSnapshot(db *gorm.DB, agent *models.AIAgen
|
||||
ToolPolicy: agent.ToolPolicy, KnowledgePolicy: agent.KnowledgePolicy, ServiceMode: int(agent.ServiceMode), SystemPrompt: agent.SystemPrompt,
|
||||
WelcomeMessage: agent.WelcomeMessage, ReplyTimeoutSeconds: agent.ReplyTimeoutSeconds, TeamIDs: agent.TeamIDs, HandoffMode: int(agent.HandoffMode),
|
||||
FallbackMode: int(agent.FallbackMode), FallbackMessage: agent.FallbackMessage, KnowledgeIDs: agent.KnowledgeIDs,
|
||||
SkillIDs: agent.SkillIDs, AllowedMCPTools: agent.AllowedMCPTools,
|
||||
},
|
||||
Model: model,
|
||||
}
|
||||
for _, binding := range repositories.AIAgentWorkflowBindingRepository.FindEnabledByAgentID(db, agent.ID) {
|
||||
definition.WorkflowBindings = append(definition.WorkflowBindings, AgentRevisionWorkflowBinding{WorkflowID: binding.WorkflowID, WorkflowVersionID: binding.WorkflowVersionID, ToolName: binding.ToolName, TriggerInstruction: binding.TriggerInstruction, Priority: binding.Priority})
|
||||
}
|
||||
data, err := json.Marshal(definition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -28,29 +28,34 @@ func newAgentRunService() *agentRunService {
|
||||
|
||||
type agentRunService struct{}
|
||||
|
||||
type BusinessToolMemory struct {
|
||||
ToolCode string
|
||||
Result string
|
||||
}
|
||||
|
||||
type AgentRunMetrics struct {
|
||||
TotalRuns int `json:"totalRuns"`
|
||||
CompletedRuns int `json:"completedRuns"`
|
||||
FailedRuns int `json:"failedRuns"`
|
||||
InterruptedRuns int `json:"interruptedRuns"`
|
||||
CompletionRate float64 `json:"completionRate"`
|
||||
ToolCalls int `json:"toolCalls"`
|
||||
ToolSuccessRate float64 `json:"toolSuccessRate"`
|
||||
AverageSteps float64 `json:"averageSteps"`
|
||||
AverageDurationMS int64 `json:"averageDurationMs"`
|
||||
P95DurationMS int64 `json:"p95DurationMs"`
|
||||
PromptTokens int64 `json:"promptTokens"`
|
||||
CompletionTokens int64 `json:"completionTokens"`
|
||||
HandoffRate float64 `json:"handoffRate"`
|
||||
KnowledgeFallbackRate float64 `json:"knowledgeFallbackRate"`
|
||||
ResumedInterrupts int `json:"resumedInterrupts"`
|
||||
ResolvedInterrupts int `json:"resolvedInterrupts"`
|
||||
InterruptRecoveryRate float64 `json:"interruptRecoveryRate"`
|
||||
ReviewedRuns int `json:"reviewedRuns"`
|
||||
ResolvedRuns int `json:"resolvedRuns"`
|
||||
ResolutionRate float64 `json:"resolutionRate"`
|
||||
UnsupportedEvidenceRuns int `json:"unsupportedEvidenceRuns"`
|
||||
UnsupportedEvidenceRate float64 `json:"unsupportedEvidenceRate"`
|
||||
TotalRuns int `json:"total_runs"`
|
||||
CompletedRuns int `json:"completed_runs"`
|
||||
FailedRuns int `json:"failed_runs"`
|
||||
InterruptedRuns int `json:"interrupted_runs"`
|
||||
CompletionRate float64 `json:"completion_rate"`
|
||||
ToolCalls int `json:"tool_calls"`
|
||||
ToolSuccessRate float64 `json:"tool_success_rate"`
|
||||
AverageSteps float64 `json:"average_steps"`
|
||||
AverageDurationMS int64 `json:"average_duration_ms"`
|
||||
P95DurationMS int64 `json:"p95_duration_ms"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
HandoffRate float64 `json:"handoff_rate"`
|
||||
KnowledgeFallbackRate float64 `json:"knowledge_fallback_rate"`
|
||||
ResumedInterrupts int `json:"resumed_interrupts"`
|
||||
ResolvedInterrupts int `json:"resolved_interrupts"`
|
||||
InterruptRecoveryRate float64 `json:"interrupt_recovery_rate"`
|
||||
ReviewedRuns int `json:"reviewed_runs"`
|
||||
ResolvedRuns int `json:"resolved_runs"`
|
||||
ResolutionRate float64 `json:"resolution_rate"`
|
||||
UnsupportedEvidenceRuns int `json:"unsupported_evidence_runs"`
|
||||
UnsupportedEvidenceRate float64 `json:"unsupported_evidence_rate"`
|
||||
}
|
||||
|
||||
const maxAgentAuditPreviewChars = 4000
|
||||
@@ -86,6 +91,44 @@ func (s *agentRunService) GetLatestStepID(agentRunID int64) int64 {
|
||||
return step.ID
|
||||
}
|
||||
|
||||
func (s *agentRunService) FindRecentBusinessToolMemory(conversationID int64, limit int) []BusinessToolMemory {
|
||||
if limit <= 0 || limit > 10 {
|
||||
limit = 4
|
||||
}
|
||||
runs := repositories.AgentRunRepository.FindRecentByConversationID(sqls.DB(), conversationID, limit)
|
||||
runIDs := make([]int64, 0, len(runs))
|
||||
for _, run := range runs {
|
||||
runIDs = append(runIDs, run.ID)
|
||||
}
|
||||
items := repositories.AgentToolCallRepository.FindByAgentRunIDs(sqls.DB(), runIDs)
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].ID > items[j].ID })
|
||||
result := make([]BusinessToolMemory, 0, limit)
|
||||
seen := make(map[string]struct{}, limit)
|
||||
cutoff := time.Now().Add(-15 * time.Minute)
|
||||
for i := range items {
|
||||
toolCode := strings.TrimSpace(items[i].ToolCode)
|
||||
value := strings.TrimSpace(items[i].ResultPreview)
|
||||
if items[i].Status != "completed" || !strings.HasPrefix(toolCode, "business/") || items[i].CreatedAt.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
if toolCode == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[toolCode]; exists {
|
||||
continue
|
||||
}
|
||||
seen[toolCode] = struct{}{}
|
||||
result = append(result, BusinessToolMemory{ToolCode: toolCode, Result: value})
|
||||
if len(result) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
for left, right := 0, len(result)-1; left < right; left, right = left+1, right-1 {
|
||||
result[left], result[right] = result[right], result[left]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *agentRunService) GetQualityFeedback(agentRunID int64) *models.AgentRunQualityFeedback {
|
||||
return repositories.AgentRunQualityFeedbackRepository.GetByAgentRunID(sqls.DB(), agentRunID)
|
||||
}
|
||||
@@ -232,7 +275,6 @@ type AgentLoopRunInput struct {
|
||||
AIAgentID int64
|
||||
AgentRevisionID int64
|
||||
SourceMessageID int64
|
||||
WorkflowRunID int64
|
||||
Status string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
@@ -251,7 +293,6 @@ type AgentLoopRunInput struct {
|
||||
type AgentLoopStepInput struct {
|
||||
StepType string
|
||||
StepCode string
|
||||
WorkflowRunID int64
|
||||
Status string
|
||||
InputPreview string
|
||||
OutputPreview string
|
||||
@@ -269,44 +310,6 @@ type AgentLoopToolCallInput struct {
|
||||
DurationMS int
|
||||
}
|
||||
|
||||
// RecordResume closes or re-interrupts the original Agent Loop parent run,
|
||||
// appends a normalized resume step, and records an optional resumed tool call.
|
||||
func (s *agentRunService) RecordResume(db *gorm.DB, agentRunID, workflowRunID int64, status, replyText string, toolCall *AgentLoopToolCallInput) error {
|
||||
if agentRunID <= 0 {
|
||||
return nil
|
||||
}
|
||||
run := repositories.AgentRunRepository.Get(db, agentRunID)
|
||||
if run == nil {
|
||||
return nil
|
||||
}
|
||||
status = firstNonEmptyString(status, "completed")
|
||||
now := time.Now()
|
||||
if err := repositories.AgentRunRepository.Updates(db, run.ID, map[string]any{
|
||||
"status": status, "ended_at": &now, "error_message": "", "updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
step := &models.AgentStep{
|
||||
AgentRunID: run.ID, WorkflowRunID: workflowRunID,
|
||||
StepType: "resume", StepCode: "confirmation_resume", Status: status,
|
||||
InputPreview: "customer confirmation", OutputPreview: sanitizeAgentAuditPreview(replyText),
|
||||
StartedAt: now, EndedAt: &now, CreatedAt: now,
|
||||
}
|
||||
if err := repositories.AgentStepRepository.Create(db, step); err != nil {
|
||||
return err
|
||||
}
|
||||
if toolCall == nil {
|
||||
return nil
|
||||
}
|
||||
return repositories.AgentToolCallRepository.Create(db, &models.AgentToolCall{
|
||||
AgentRunID: run.ID, AgentStepID: step.ID, ToolCode: strings.TrimSpace(toolCall.ToolCode),
|
||||
RiskLevel: strings.TrimSpace(toolCall.RiskLevel), RequireConfirm: toolCall.RequireConfirm,
|
||||
Status: firstNonEmptyString(toolCall.Status, status), ArgumentsPreview: sanitizeAgentAuditPreview(toolCall.ArgumentsPreview),
|
||||
ResultPreview: sanitizeAgentAuditPreview(toolCall.ResultPreview), ErrorMessage: sanitizeAgentAuditPreview(toolCall.ErrorMessage),
|
||||
DurationMS: toolCall.DurationMS, CreatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// RecordAgentLoopRun writes the Agent Loop parent audit run and its normalized
|
||||
// root step in one transaction owned by the caller.
|
||||
func (s *agentRunService) RecordAgentLoopRun(db *gorm.DB, input AgentLoopRunInput) (int64, error) {
|
||||
@@ -321,7 +324,7 @@ func (s *agentRunService) RecordAgentLoopRun(db *gorm.DB, input AgentLoopRunInpu
|
||||
}
|
||||
run := &models.AgentRun{
|
||||
ConversationID: input.ConversationID, AIAgentID: input.AIAgentID, AgentRevisionID: input.AgentRevisionID,
|
||||
SourceMessageID: input.SourceMessageID, WorkflowRunID: input.WorkflowRunID, Status: status,
|
||||
SourceMessageID: input.SourceMessageID, Status: status,
|
||||
PromptTokens: input.PromptTokens, CompletionTokens: input.CompletionTokens, StartedAt: startedAt, EndedAt: input.EndedAt,
|
||||
ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage), TraceData: sanitizeAgentAuditPreview(input.TraceData), CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
@@ -345,7 +348,7 @@ func (s *agentRunService) RecordAgentLoopRun(db *gorm.DB, input AgentLoopRunInpu
|
||||
}
|
||||
for _, extra := range input.AdditionalSteps {
|
||||
extraStep := &models.AgentStep{
|
||||
AgentRunID: run.ID, WorkflowRunID: extra.WorkflowRunID, StepType: strings.TrimSpace(extra.StepType), StepCode: strings.TrimSpace(extra.StepCode),
|
||||
AgentRunID: run.ID, StepType: strings.TrimSpace(extra.StepType), StepCode: strings.TrimSpace(extra.StepCode),
|
||||
Status: firstNonEmptyString(extra.Status, status), InputPreview: sanitizeAgentAuditPreview(extra.InputPreview), OutputPreview: sanitizeAgentAuditPreview(extra.OutputPreview),
|
||||
ErrorMessage: sanitizeAgentAuditPreview(extra.ErrorMessage), StartedAt: startedAt, EndedAt: input.EndedAt, DurationMS: durationMS, CreatedAt: now,
|
||||
}
|
||||
|
||||
@@ -17,25 +17,23 @@ import (
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestAgentRunServiceFindsWorkflowAuditDetail(t *testing.T) {
|
||||
func TestAgentRunServiceFindsAuditDetail(t *testing.T) {
|
||||
db := setupAgentRunServiceTestDB(t)
|
||||
now := time.Now()
|
||||
endedAt := now.Add(time.Second)
|
||||
run := &models.AgentRun{
|
||||
ConversationID: 11,
|
||||
AIAgentID: 12,
|
||||
WorkflowRunID: 13,
|
||||
|
||||
Status: "completed",
|
||||
StartedAt: now,
|
||||
EndedAt: &endedAt,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Status: "completed",
|
||||
StartedAt: now,
|
||||
EndedAt: &endedAt,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(run).Error; err != nil {
|
||||
t.Fatalf("create agent run: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.AgentStep{AgentRunID: run.ID, StepType: "workflow", Status: "completed", StartedAt: now, EndedAt: &endedAt, CreatedAt: now}).Error; err != nil {
|
||||
if err := db.Create(&models.AgentStep{AgentRunID: run.ID, StepType: "tool", Status: "completed", StartedAt: now, EndedAt: &endedAt, CreatedAt: now}).Error; err != nil {
|
||||
t.Fatalf("create agent step: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.AgentToolCall{AgentRunID: run.ID, ToolCode: "knowledge.retrieve", Status: "completed", CreatedAt: now}).Error; err != nil {
|
||||
@@ -75,26 +73,30 @@ func TestAgentRunServiceRecordsAgentLoopToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRunServiceRecordsResumedToolCall(t *testing.T) {
|
||||
func TestAgentRunServiceRecallsLatestBusinessToolResultPerConversation(t *testing.T) {
|
||||
db := setupAgentRunServiceTestDB(t)
|
||||
now := time.Now()
|
||||
run := &models.AgentRun{Status: "interrupted", StartedAt: now, CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(run).Error; err != nil {
|
||||
t.Fatalf("create interrupted run: %v", err)
|
||||
runs := []models.AgentRun{
|
||||
{ConversationID: 21, Status: "completed", StartedAt: now, CreatedAt: now, UpdatedAt: now},
|
||||
{ConversationID: 21, Status: "completed", StartedAt: now.Add(time.Second), CreatedAt: now, UpdatedAt: now},
|
||||
{ConversationID: 22, Status: "completed", StartedAt: now, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
err := AgentRunService.RecordResume(db, run.ID, 0, "completed", "操作已执行", &AgentLoopToolCallInput{
|
||||
ToolCode: "crm/update_customer", RiskLevel: "write", RequireConfirm: true,
|
||||
Status: "completed", ArgumentsPreview: `{"name":"Ada"}`, ResultPreview: "updated",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RecordResume returned error: %v", err)
|
||||
if err := db.Create(&runs).Error; err != nil {
|
||||
t.Fatalf("create runs: %v", err)
|
||||
}
|
||||
item, steps, toolCalls := AgentRunService.GetDetail(run.ID)
|
||||
if item == nil || item.Status != "completed" || len(steps) != 1 || steps[0].StepType != "resume" {
|
||||
t.Fatalf("unexpected resumed run audit: item=%#v steps=%#v", item, steps)
|
||||
calls := []models.AgentToolCall{
|
||||
{AgentRunID: runs[0].ID, ToolCode: "business/card_package_catalog", Status: "completed", ResultPreview: `[{"sequence":1,"current_start_at":"old"}]`, CreatedAt: now},
|
||||
{AgentRunID: runs[1].ID, ToolCode: "business/card_package_catalog", Status: "completed", ResultPreview: `[{"sequence":1,"current_start_at":"new"}]`, CreatedAt: now},
|
||||
{AgentRunID: runs[1].ID, ToolCode: "builtin/conversation_context", Status: "completed", ResultPreview: "ignore", CreatedAt: now},
|
||||
{AgentRunID: runs[1].ID, ToolCode: "business/card_auto_renewal_catalog", Status: "completed", ResultPreview: "stale", CreatedAt: now.Add(-20 * time.Minute)},
|
||||
{AgentRunID: runs[2].ID, ToolCode: "business/card_package_catalog", Status: "completed", ResultPreview: "other conversation", CreatedAt: now},
|
||||
}
|
||||
if len(toolCalls) != 1 || toolCalls[0].AgentStepID != steps[0].ID || !toolCalls[0].RequireConfirm || toolCalls[0].Status != "completed" {
|
||||
t.Fatalf("unexpected resumed tool audit: %#v", toolCalls)
|
||||
if err := db.Create(&calls).Error; err != nil {
|
||||
t.Fatalf("create calls: %v", err)
|
||||
}
|
||||
memory := AgentRunService.FindRecentBusinessToolMemory(21, 4)
|
||||
if len(memory) != 1 || memory[0].ToolCode != "business/card_package_catalog" || !strings.Contains(memory[0].Result, "new") {
|
||||
t.Fatalf("unexpected business memory: %#v", memory)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
const (
|
||||
agentToolInvocationStatusRunning = "running"
|
||||
agentToolInvocationStatusCompleted = "completed"
|
||||
agentToolInvocationStatusFailed = "failed"
|
||||
agentToolInvocationStatusRunning = "running"
|
||||
agentToolInvocationStatusCompleted = "completed"
|
||||
agentToolInvocationStatusRetryableFailed = "retryable_failed"
|
||||
agentToolInvocationStatusUnknownOutcome = "unknown_outcome"
|
||||
agentToolInvocationStatusLegacyFailed = "failed"
|
||||
)
|
||||
|
||||
var AgentToolInvocationService = newAgentToolInvocationService()
|
||||
|
||||
type AgentToolInvocationClaim struct {
|
||||
Item *models.AgentToolInvocation
|
||||
Completed bool
|
||||
Acquired bool
|
||||
Item *models.AgentToolInvocation
|
||||
Completed bool
|
||||
Acquired bool
|
||||
UnknownOutcome bool
|
||||
Recovered bool
|
||||
}
|
||||
|
||||
type agentToolInvocationService struct{}
|
||||
@@ -33,6 +41,17 @@ func newAgentToolInvocationService() *agentToolInvocationService {
|
||||
// Claim obtains the persistent idempotency boundary. A completed invocation
|
||||
// can be returned to callers; an in-flight invocation is never executed again.
|
||||
func (s *agentToolInvocationService) Claim(conversationID, aiAgentID int64, toolCode, idempotencyKey string) (*AgentToolInvocationClaim, error) {
|
||||
return s.claim(conversationID, aiAgentID, toolCode, idempotencyKey, time.Time{})
|
||||
}
|
||||
|
||||
// ClaimRecoverable is reserved for side-effect-free orchestration runs. A
|
||||
// stale running claim may be recovered after the caller's lease expires. It
|
||||
// must never be used for an external write operation whose outcome is unknown.
|
||||
func (s *agentToolInvocationService) ClaimRecoverable(conversationID, aiAgentID int64, toolCode, idempotencyKey string, staleBefore time.Time) (*AgentToolInvocationClaim, error) {
|
||||
return s.claim(conversationID, aiAgentID, toolCode, idempotencyKey, staleBefore)
|
||||
}
|
||||
|
||||
func (s *agentToolInvocationService) claim(conversationID, aiAgentID int64, toolCode, idempotencyKey string, staleBefore time.Time) (*AgentToolInvocationClaim, error) {
|
||||
toolCode = strings.TrimSpace(toolCode)
|
||||
idempotencyKey = strings.TrimSpace(idempotencyKey)
|
||||
if conversationID <= 0 || toolCode == "" || idempotencyKey == "" {
|
||||
@@ -42,20 +61,49 @@ func (s *agentToolInvocationService) Claim(conversationID, aiAgentID int64, tool
|
||||
if item.Status == agentToolInvocationStatusCompleted {
|
||||
return &AgentToolInvocationClaim{Item: item, Completed: true}, nil
|
||||
}
|
||||
if item.Status == agentToolInvocationStatusUnknownOutcome {
|
||||
return &AgentToolInvocationClaim{Item: item, UnknownOutcome: true}, nil
|
||||
}
|
||||
if item.Status == agentToolInvocationStatusRunning {
|
||||
if !staleBefore.IsZero() && item.UpdatedAt.Before(staleBefore) {
|
||||
leaseToken := uuid.NewString()
|
||||
values := map[string]any{"error_message": "", "result_data": leaseToken, "updated_at": time.Now()}
|
||||
acquired, err := repositories.AgentToolInvocationRepository.RecoverStaleRunning(sqls.DB(), item.ID, staleBefore, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acquired {
|
||||
item.ErrorMessage, item.ResultData, item.UpdatedAt = "", leaseToken, values["updated_at"].(time.Time)
|
||||
return &AgentToolInvocationClaim{Item: item, Acquired: true, Recovered: true}, nil
|
||||
}
|
||||
}
|
||||
return &AgentToolInvocationClaim{Item: item}, nil
|
||||
}
|
||||
if err := repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusRunning, "error_message": "", "updated_at": time.Now()}); err != nil {
|
||||
if item.Status != agentToolInvocationStatusRetryableFailed && item.Status != agentToolInvocationStatusLegacyFailed {
|
||||
return &AgentToolInvocationClaim{Item: item, UnknownOutcome: true}, nil
|
||||
}
|
||||
leaseToken := uuid.NewString()
|
||||
values := map[string]any{"status": agentToolInvocationStatusRunning, "error_message": "", "result_data": leaseToken, "updated_at": time.Now()}
|
||||
acquired, err := repositories.AgentToolInvocationRepository.TransitionStatus(sqls.DB(), item.ID, item.Status, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Status, item.ErrorMessage = agentToolInvocationStatusRunning, ""
|
||||
if !acquired {
|
||||
current := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(sqls.DB(), conversationID, toolCode, idempotencyKey)
|
||||
return &AgentToolInvocationClaim{Item: current, Completed: current != nil && current.Status == agentToolInvocationStatusCompleted, UnknownOutcome: current != nil && current.Status == agentToolInvocationStatusUnknownOutcome}, nil
|
||||
}
|
||||
item.Status, item.ErrorMessage, item.ResultData = agentToolInvocationStatusRunning, "", leaseToken
|
||||
return &AgentToolInvocationClaim{Item: item, Acquired: true}, nil
|
||||
}
|
||||
item := &models.AgentToolInvocation{ConversationID: conversationID, AIAgentID: aiAgentID, ToolCode: toolCode, IdempotencyKey: idempotencyKey, Status: agentToolInvocationStatusRunning}
|
||||
item := &models.AgentToolInvocation{ConversationID: conversationID, AIAgentID: aiAgentID, ToolCode: toolCode, IdempotencyKey: idempotencyKey, Status: agentToolInvocationStatusRunning, ResultData: uuid.NewString()}
|
||||
if err := repositories.AgentToolInvocationRepository.Create(sqls.DB(), item); err != nil {
|
||||
// A concurrent caller may have created the unique invocation first.
|
||||
if existing := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(sqls.DB(), conversationID, toolCode, idempotencyKey); existing != nil {
|
||||
return &AgentToolInvocationClaim{Item: existing, Completed: existing.Status == agentToolInvocationStatusCompleted}, nil
|
||||
return &AgentToolInvocationClaim{
|
||||
Item: existing,
|
||||
Completed: existing.Status == agentToolInvocationStatusCompleted,
|
||||
UnknownOutcome: existing.Status == agentToolInvocationStatusUnknownOutcome,
|
||||
}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -66,16 +114,48 @@ func (s *agentToolInvocationService) Complete(item *models.AgentToolInvocation,
|
||||
if item == nil || item.ID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusCompleted, "result_data": resultData, "error_message": "", "updated_at": time.Now()})
|
||||
updated, err := repositories.AgentToolInvocationRepository.TransitionLease(sqls.DB(), item.ID, item.ResultData, map[string]any{"status": agentToolInvocationStatusCompleted, "result_data": resultData, "error_message": "", "updated_at": time.Now()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !updated {
|
||||
return fmt.Errorf("agent tool invocation lease lost: %d", item.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *agentToolInvocationService) FailRetryable(item *models.AgentToolInvocation, cause error) error {
|
||||
return s.failWithStatus(item, cause, agentToolInvocationStatusRetryableFailed)
|
||||
}
|
||||
|
||||
func (s *agentToolInvocationService) MarkUnknownOutcome(item *models.AgentToolInvocation, cause error) error {
|
||||
return s.failWithStatus(item, cause, agentToolInvocationStatusUnknownOutcome)
|
||||
}
|
||||
|
||||
// Fail is kept as a compatibility alias for failures known to have occurred
|
||||
// before side effects. New write paths should call the explicit method.
|
||||
func (s *agentToolInvocationService) Fail(item *models.AgentToolInvocation, cause error) error {
|
||||
return s.FailRetryable(item, cause)
|
||||
}
|
||||
|
||||
func (s *agentToolInvocationService) failWithStatus(item *models.AgentToolInvocation, cause error, status string) error {
|
||||
if item == nil || item.ID <= 0 {
|
||||
return nil
|
||||
}
|
||||
message := ""
|
||||
if cause != nil {
|
||||
message = cause.Error()
|
||||
var actionErr *contract.BusinessActionError
|
||||
if errors.As(cause, &actionErr) && actionErr.Cause != nil {
|
||||
message = actionErr.Cause.Error()
|
||||
}
|
||||
}
|
||||
return repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusFailed, "error_message": message, "updated_at": time.Now()})
|
||||
updated, err := repositories.AgentToolInvocationRepository.TransitionLease(sqls.DB(), item.ID, item.ResultData, map[string]any{"status": status, "error_message": message, "updated_at": time.Now()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !updated {
|
||||
return fmt.Errorf("agent tool invocation lease lost: %d", item.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
|
||||
@@ -22,20 +23,20 @@ func TestAgentToolInvocationServiceReusesCompletedInvocation(t *testing.T) {
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
|
||||
first, err := AgentToolInvocationService.Claim(10, 20, "graph/create_ticket_with_confirmation", "message:30:node:create")
|
||||
first, err := AgentToolInvocationService.Claim(10, 20, "graph/handoff_to_human", "message:30:node:handoff")
|
||||
if err != nil || first == nil || first.Item == nil || first.Completed {
|
||||
t.Fatalf("first claim = %#v, err=%v", first, err)
|
||||
}
|
||||
if err := AgentToolInvocationService.Complete(first.Item, `{"ticketId":40}`); err != nil {
|
||||
if err := AgentToolInvocationService.Complete(first.Item, `{"handoff":true}`); err != nil {
|
||||
t.Fatalf("complete invocation: %v", err)
|
||||
}
|
||||
second, err := AgentToolInvocationService.Claim(10, 20, "graph/create_ticket_with_confirmation", "message:30:node:create")
|
||||
if err != nil || second == nil || !second.Completed || second.Item.ResultData != `{"ticketId":40}` {
|
||||
second, err := AgentToolInvocationService.Claim(10, 20, "graph/handoff_to_human", "message:30:node:handoff")
|
||||
if err != nil || second == nil || !second.Completed || second.Item.ResultData != `{"handoff":true}` {
|
||||
t.Fatalf("second claim = %#v, err=%v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentToolInvocationServiceAllowsFailedInvocationRetry(t *testing.T) {
|
||||
func TestAgentToolInvocationServiceAllowsExplicitRetryableFailure(t *testing.T) {
|
||||
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: %v", err)
|
||||
@@ -49,7 +50,7 @@ func TestAgentToolInvocationServiceAllowsFailedInvocationRetry(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("first claim: %v", err)
|
||||
}
|
||||
if err := AgentToolInvocationService.Fail(first.Item, errTestToolInvocation); err != nil {
|
||||
if err := AgentToolInvocationService.FailRetryable(first.Item, errTestToolInvocation); err != nil {
|
||||
t.Fatalf("fail invocation: %v", err)
|
||||
}
|
||||
second, err := AgentToolInvocationService.Claim(11, 21, "graph/handoff_to_human", "message:31:node:handoff")
|
||||
@@ -58,6 +59,70 @@ func TestAgentToolInvocationServiceAllowsFailedInvocationRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentToolInvocationServiceNeverReclaimsUnknownOutcome(t *testing.T) {
|
||||
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: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentToolInvocation{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
|
||||
first, err := AgentToolInvocationService.Claim(12, 22, "business/order", "confirm-unknown")
|
||||
if err != nil {
|
||||
t.Fatalf("first claim: %v", err)
|
||||
}
|
||||
if err := AgentToolInvocationService.MarkUnknownOutcome(first.Item, errTestToolInvocation); err != nil {
|
||||
t.Fatalf("mark unknown outcome: %v", err)
|
||||
}
|
||||
second, err := AgentToolInvocationService.Claim(12, 22, "business/order", "confirm-unknown")
|
||||
if err != nil || second == nil || second.Acquired || !second.UnknownOutcome || second.Item.Status != agentToolInvocationStatusUnknownOutcome {
|
||||
t.Fatalf("unknown outcome claim = %#v, err=%v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentToolInvocationServiceRecoversOnlyStaleOrchestrationRun(t *testing.T) {
|
||||
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: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentToolInvocation{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
|
||||
first, err := AgentToolInvocationService.Claim(13, 23, "runtime/ai_reply", "message:33:revision:2")
|
||||
if err != nil {
|
||||
t.Fatalf("first claim: %v", err)
|
||||
}
|
||||
oldUpdatedAt := time.Now().Add(-10 * time.Minute)
|
||||
if err := db.Model(&models.AgentToolInvocation{}).Where("id = ?", first.Item.ID).Update("updated_at", oldUpdatedAt).Error; err != nil {
|
||||
t.Fatalf("age running claim: %v", err)
|
||||
}
|
||||
|
||||
second, err := AgentToolInvocationService.ClaimRecoverable(13, 23, "runtime/ai_reply", "message:33:revision:2", time.Now().Add(-time.Minute))
|
||||
if err != nil || second == nil || !second.Acquired || second.Item.Status != agentToolInvocationStatusRunning {
|
||||
t.Fatalf("recovered claim = %#v, err=%v", second, err)
|
||||
}
|
||||
if !second.Recovered || first.Item.ResultData == second.Item.ResultData {
|
||||
t.Fatalf("recovered claim did not receive a new lease: first=%q second=%q", first.Item.ResultData, second.Item.ResultData)
|
||||
}
|
||||
if err := AgentToolInvocationService.Complete(first.Item, `{"owner":"stale"}`); err == nil {
|
||||
t.Fatal("stale owner unexpectedly completed the recovered invocation")
|
||||
}
|
||||
var running models.AgentToolInvocation
|
||||
if err := db.First(&running, second.Item.ID).Error; err != nil {
|
||||
t.Fatalf("reload active lease: %v", err)
|
||||
}
|
||||
if running.Status != agentToolInvocationStatusRunning || running.ResultData != second.Item.ResultData {
|
||||
t.Fatalf("stale completion changed the active lease: %#v", running)
|
||||
}
|
||||
if err := AgentToolInvocationService.Complete(second.Item, `{"owner":"current"}`); err != nil {
|
||||
t.Fatalf("current owner complete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var errTestToolInvocation = &toolInvocationTestError{}
|
||||
|
||||
type toolInvocationTestError struct{}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
)
|
||||
|
||||
func TestValidateMCPToolRiskPolicyRejectsTrustedToolOverride(t *testing.T) {
|
||||
_, err := validateMCPToolRiskPolicy(request.AIAgentMCPToolRequest{
|
||||
ToolCode: "system/server_time",
|
||||
RiskLevel: "write",
|
||||
RequireConfirmation: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected trusted system tool policy override to be rejected")
|
||||
}
|
||||
|
||||
item, err := validateMCPToolRiskPolicy(request.AIAgentMCPToolRequest{
|
||||
ToolCode: "system/server_time",
|
||||
RiskLevel: "read",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("validate trusted system tool policy: %v", err)
|
||||
}
|
||||
if item.Title != "获取当前时间" || item.RiskLevel != "read" || item.RequireConfirmation {
|
||||
t.Fatalf("unexpected normalized trusted policy: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMCPToolRiskPolicyRequiresWriteConfirmation(t *testing.T) {
|
||||
_, err := validateMCPToolRiskPolicy(request.AIAgentMCPToolRequest{
|
||||
ToolCode: "crm/update_customer",
|
||||
RiskLevel: "write",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected write tool without confirmation to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
aitooling "code.tczkiot.com/wlw/ai-agent/internal/ai/tooling"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/toolx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
|
||||
@@ -79,13 +78,7 @@ func (s *aIAgentService) CreateAIAgent(req request.CreateAIAgentRequest, operato
|
||||
item.Status = enums.StatusOk
|
||||
item.SortNo = 0
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := repositories.AIAgentRepository.Create(ctx.Tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.replaceWorkflowBindings(ctx.Tx, item.ID, req.WorkflowBindings, operator)
|
||||
return err
|
||||
}); err != nil {
|
||||
if err := repositories.AIAgentRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
@@ -105,6 +98,7 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato
|
||||
}
|
||||
columns := map[string]any{
|
||||
"name": item.Name,
|
||||
"avatar": item.Avatar,
|
||||
"description": item.Description,
|
||||
"ai_config_id": item.AIConfigID,
|
||||
"max_steps": item.MaxSteps,
|
||||
@@ -121,8 +115,6 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato
|
||||
"fallback_mode": item.FallbackMode,
|
||||
"fallback_message": item.FallbackMessage,
|
||||
"knowledge_ids": item.KnowledgeIDs,
|
||||
"skill_ids": item.SkillIDs,
|
||||
"allowed_mcp_tools": item.AllowedMCPTools,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
@@ -130,13 +122,7 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato
|
||||
if item.RolloutPercent != current.RolloutPercent {
|
||||
columns["previous_rollout_percent"] = current.RolloutPercent
|
||||
}
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := repositories.AIAgentRepository.Updates(ctx.Tx, req.ID, columns); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.replaceWorkflowBindings(ctx.Tx, req.ID, req.WorkflowBindings, operator)
|
||||
return err
|
||||
})
|
||||
return repositories.AIAgentRepository.Updates(sqls.DB(), req.ID, columns)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) error {
|
||||
@@ -189,42 +175,25 @@ func (s *aIAgentService) PublishAIAgent(id int64, operator *dto.AuthPrincipal) (
|
||||
}
|
||||
|
||||
func (s *aIAgentService) validatePublishableAgent(db *gorm.DB, agent *models.AIAgent) error {
|
||||
if agent == nil || agent.AIConfigID <= 0 {
|
||||
if agent == nil {
|
||||
return errorsx.InvalidParam("ai agent is required before publishing")
|
||||
}
|
||||
platform, err := PlatformAIService.IsPlatform(context.Background())
|
||||
if err != nil {
|
||||
return errorsx.InvalidParam("failed to resolve AI model source")
|
||||
}
|
||||
if !platform && agent.AIConfigID <= 0 {
|
||||
return errorsx.InvalidParam("ai agent model configuration is required before publishing")
|
||||
}
|
||||
config := repositories.AIConfigRepository.Get(db, agent.AIConfigID)
|
||||
if config == nil || config.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParam("ai agent model configuration is unavailable")
|
||||
if !platform {
|
||||
config := repositories.AIConfigRepository.Get(db, agent.AIConfigID)
|
||||
if config == nil || config.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParam("ai agent model configuration is unavailable")
|
||||
}
|
||||
}
|
||||
if _, err := s.normalizeToolPolicy(agent.ToolPolicy); err != nil {
|
||||
return err
|
||||
}
|
||||
var mcpTools []request.AIAgentMCPToolRequest
|
||||
if raw := strings.TrimSpace(agent.AllowedMCPTools); raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &mcpTools); err != nil {
|
||||
return errorsx.InvalidParam("ai agent MCP tools are invalid")
|
||||
}
|
||||
}
|
||||
for _, id := range utils.SplitInt64s(agent.SkillIDs) {
|
||||
skill := repositories.SkillDefinitionRepository.Get(db, id)
|
||||
if skill == nil || skill.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParam("bound Skill is unavailable")
|
||||
}
|
||||
}
|
||||
for _, item := range mcpTools {
|
||||
definition, err := aitooling.DefaultRegistry.Resolve(item.ToolCode)
|
||||
if err != nil || definition.InputSchema == nil {
|
||||
return errorsx.InvalidParam("ai agent MCP tool definition is unavailable")
|
||||
}
|
||||
if _, err := validateMCPToolRiskPolicy(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, binding := range s.ListEnabledWorkflowBindings(db, agent.ID) {
|
||||
if binding.Version == nil || binding.Version.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParam("bound workflow version is unavailable")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -292,15 +261,30 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe
|
||||
if exists := s.Take("name = ? AND id <> ?", name, id); exists != nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0006")
|
||||
}
|
||||
if req.AIConfigID <= 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0010")
|
||||
avatar := strings.TrimSpace(req.Avatar)
|
||||
if len(avatar) > 1024 {
|
||||
return nil, errorsx.InvalidParam("ai agent avatar URL must not exceed 1024 characters")
|
||||
}
|
||||
aiConfig := AIConfigService.Get(req.AIConfigID)
|
||||
if aiConfig == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0009")
|
||||
platform, err := PlatformAIService.IsPlatform(context.Background())
|
||||
if err != nil {
|
||||
return nil, errorsx.InvalidParam("failed to resolve AI model source")
|
||||
}
|
||||
if aiConfig.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0011")
|
||||
if platform && req.AIConfigID <= 0 && id > 0 {
|
||||
if current := s.Get(id); current != nil {
|
||||
req.AIConfigID = current.AIConfigID
|
||||
}
|
||||
}
|
||||
if !platform {
|
||||
if req.AIConfigID <= 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0010")
|
||||
}
|
||||
aiConfig := AIConfigService.Get(req.AIConfigID)
|
||||
if aiConfig == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0009")
|
||||
}
|
||||
if aiConfig.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0011")
|
||||
}
|
||||
}
|
||||
if req.MaxSteps == 0 {
|
||||
req.MaxSteps = 6
|
||||
@@ -344,28 +328,13 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe
|
||||
return nil, errorsx.InvalidParam("ai agent rollout percent must be between 1 and 100")
|
||||
}
|
||||
|
||||
skillIDs, err := s.normalizeSkillIDs(req.SkillIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
knowledgeBaseIDs, err := s.normalizeKnowledgeBaseIDs(req.KnowledgeBaseIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mcpTools, err := s.normalizeMCPTools(req.MCPTools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mcpToolsJSON := ""
|
||||
if len(mcpTools) > 0 {
|
||||
buf, marshalErr := json.Marshal(mcpTools)
|
||||
if marshalErr != nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0021")
|
||||
}
|
||||
mcpToolsJSON = string(buf)
|
||||
}
|
||||
return &models.AIAgent{
|
||||
Name: name,
|
||||
Avatar: avatar,
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
AIConfigID: req.AIConfigID,
|
||||
MaxSteps: req.MaxSteps,
|
||||
@@ -382,15 +351,13 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe
|
||||
FallbackMode: req.FallbackMode,
|
||||
FallbackMessage: strings.TrimSpace(req.FallbackMessage),
|
||||
KnowledgeIDs: utils.JoinInt64s(knowledgeBaseIDs),
|
||||
SkillIDs: utils.JoinInt64s(skillIDs),
|
||||
AllowedMCPTools: mcpToolsJSON,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type normalizedAIAgentToolPolicy struct {
|
||||
MaxTotalCalls int `json:"maxTotalCalls,omitempty"`
|
||||
MaxArgumentBytes int `json:"maxArgumentBytes,omitempty"`
|
||||
AllowedRiskLevels []string `json:"allowedRiskLevels,omitempty"`
|
||||
MaxTotalCalls int `json:"max_total_calls,omitempty"`
|
||||
MaxArgumentBytes int `json:"max_argument_bytes,omitempty"`
|
||||
AllowedRiskLevels []string `json:"allowed_risk_levels,omitempty"`
|
||||
}
|
||||
|
||||
func (s *aIAgentService) normalizeToolPolicy(raw string) (string, error) {
|
||||
@@ -403,10 +370,10 @@ func (s *aIAgentService) normalizeToolPolicy(raw string) (string, error) {
|
||||
return "", errorsx.InvalidParam("ai agent tool policy must be valid JSON")
|
||||
}
|
||||
if policy.MaxTotalCalls < 0 || policy.MaxTotalCalls > 8 {
|
||||
return "", errorsx.InvalidParam("ai agent tool policy maxTotalCalls must be between 1 and 8")
|
||||
return "", errorsx.InvalidParam("ai agent tool policy max_total_calls must be between 1 and 8")
|
||||
}
|
||||
if policy.MaxArgumentBytes < 0 || policy.MaxArgumentBytes > 64*1024 {
|
||||
return "", errorsx.InvalidParam("ai agent tool policy maxArgumentBytes must be between 1 and 65536")
|
||||
return "", errorsx.InvalidParam("ai agent tool policy max_argument_bytes must be between 1 and 65536")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(policy.AllowedRiskLevels))
|
||||
riskLevels := make([]string, 0, len(policy.AllowedRiskLevels))
|
||||
@@ -477,78 +444,6 @@ func (s *aIAgentService) normalizeTeamIDs(input []int64) ([]int64, error) {
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) normalizeSkillIDs(input []int64) ([]int64, error) {
|
||||
ret := make([]int64, 0, len(input))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, id := range input {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
skill := SkillDefinitionService.Get(id)
|
||||
if skill == nil || skill.Status == enums.StatusDeleted {
|
||||
continue
|
||||
}
|
||||
if skill.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0056")
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ret = append(ret, id)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) normalizeMCPTools(input []request.AIAgentMCPToolRequest) ([]request.AIAgentMCPToolRequest, error) {
|
||||
if len(input) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ret := make([]request.AIAgentMCPToolRequest, 0, len(input))
|
||||
seen := make(map[string]struct{})
|
||||
for _, item := range input {
|
||||
normalized, err := toolx.NormalizeMCPToolRequest(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if toolx.ResolveToolSourceType(normalized.ToolCode) != enums.ToolSourceTypeMCP {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0020")
|
||||
}
|
||||
if err := ToolCatalogService.ValidateToolCode(normalized.ToolCode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized.RiskLevel = strings.ToLower(strings.TrimSpace(item.RiskLevel))
|
||||
normalized.RequireConfirmation = item.RequireConfirmation
|
||||
normalized, err = validateMCPToolRiskPolicy(normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := strings.TrimSpace(normalized.ToolCode)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
ret = append(ret, normalized)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func validateMCPToolRiskPolicy(item request.AIAgentMCPToolRequest) (request.AIAgentMCPToolRequest, error) {
|
||||
if policy, ok := toolx.GetTrustedMCPToolPolicy(item.ToolCode); ok {
|
||||
if item.RiskLevel != policy.RiskLevel || item.RequireConfirmation != policy.RequireConfirmation {
|
||||
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("system MCP tool risk policy cannot be changed")
|
||||
}
|
||||
return toolx.ApplyTrustedMCPToolPolicy(item), nil
|
||||
}
|
||||
if item.RiskLevel != aitooling.RiskLevelRead && item.RiskLevel != aitooling.RiskLevelWrite {
|
||||
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("MCP tool risk level must be read or write")
|
||||
}
|
||||
if item.RiskLevel == aitooling.RiskLevelWrite && !item.RequireConfirmation {
|
||||
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("write MCP tools must require confirmation")
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) UpdateSort(ids []int64) error {
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
for i, id := range ids {
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestUpdateAIAgentKeepsPublishedRevisionActive(t *testing.T) {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&models.AIConfig{}, &models.AIAgent{}, &models.AIAgentWorkflowBinding{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.AIConfig{}, &models.AIAgent{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
@@ -46,6 +46,7 @@ func TestUpdateAIAgentKeepsPublishedRevisionActive(t *testing.T) {
|
||||
}
|
||||
createdModel, err := AIAgentService.buildAIAgentModel(0, request.CreateAIAgentRequest{
|
||||
Name: "fixed ai reception agent",
|
||||
Avatar: " https://cdn.example.com/agent.png ",
|
||||
AIConfigID: config.ID,
|
||||
ServiceMode: enums.IMConversationServiceModeHumanOnly,
|
||||
HandoffMode: enums.AIAgentHandoffModeWaitPool,
|
||||
@@ -57,6 +58,9 @@ func TestUpdateAIAgentKeepsPublishedRevisionActive(t *testing.T) {
|
||||
if createdModel.ServiceMode != enums.IMConversationServiceModeAIFirst {
|
||||
t.Fatalf("service mode = %d, want AI first", createdModel.ServiceMode)
|
||||
}
|
||||
if createdModel.Avatar != "https://cdn.example.com/agent.png" {
|
||||
t.Fatalf("avatar = %q, want trimmed avatar URL", createdModel.Avatar)
|
||||
}
|
||||
agent := &models.AIAgent{
|
||||
Name: "published agent", Status: enums.StatusOk, AIConfigID: config.ID,
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst, HandoffMode: enums.AIAgentHandoffModeWaitPool,
|
||||
@@ -69,7 +73,7 @@ func TestUpdateAIAgentKeepsPublishedRevisionActive(t *testing.T) {
|
||||
err = AIAgentService.UpdateAIAgent(request.UpdateAIAgentRequest{
|
||||
ID: agent.ID,
|
||||
CreateAIAgentRequest: request.CreateAIAgentRequest{
|
||||
Name: "updated draft", AIConfigID: config.ID,
|
||||
Name: "updated draft", Avatar: "https://cdn.example.com/updated.png", AIConfigID: config.ID,
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
HandoffMode: enums.AIAgentHandoffModeWaitPool,
|
||||
FallbackMode: enums.AIAgentFallbackModeNoAnswer,
|
||||
@@ -90,4 +94,7 @@ func TestUpdateAIAgentKeepsPublishedRevisionActive(t *testing.T) {
|
||||
if updated.Name != "updated draft" {
|
||||
t.Fatalf("draft name = %q, want updated draft", updated.Name)
|
||||
}
|
||||
if updated.Avatar != "https://cdn.example.com/updated.png" {
|
||||
t.Fatalf("draft avatar = %q, want updated avatar", updated.Avatar)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AIAgentWorkflowBindingContext struct {
|
||||
Binding models.AIAgentWorkflowBinding
|
||||
Workflow *models.AIWorkflow
|
||||
Version *models.AIWorkflowVersion
|
||||
}
|
||||
|
||||
func (s *aIAgentService) ListWorkflowBindings(agentID int64) []AIAgentWorkflowBindingContext {
|
||||
bindings := repositories.AIAgentWorkflowBindingRepository.FindByAgentID(sqls.DB(), agentID)
|
||||
return s.buildWorkflowBindingContexts(sqls.DB(), bindings)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) ListEnabledWorkflowBindings(db *gorm.DB, agentID int64) []AIAgentWorkflowBindingContext {
|
||||
return s.buildWorkflowBindingContexts(db, repositories.AIAgentWorkflowBindingRepository.FindEnabledByAgentID(db, agentID))
|
||||
}
|
||||
|
||||
func (s *aIAgentService) buildWorkflowBindingContexts(db *gorm.DB, bindings []models.AIAgentWorkflowBinding) []AIAgentWorkflowBindingContext {
|
||||
ret := make([]AIAgentWorkflowBindingContext, 0, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
ret = append(ret, AIAgentWorkflowBindingContext{Binding: binding, Workflow: repositories.AIWorkflowRepository.Get(db, binding.WorkflowID), Version: repositories.AIWorkflowVersionRepository.Get(db, binding.WorkflowVersionID)})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *aIAgentService) replaceWorkflowBindings(db *gorm.DB, agentID int64, input []request.AIAgentWorkflowBindingRequest, operator *dto.AuthPrincipal) ([]models.AIAgentWorkflowBinding, error) {
|
||||
seen := make(map[int64]struct{}, len(input))
|
||||
items := make([]models.AIAgentWorkflowBinding, 0, len(input))
|
||||
for index, item := range input {
|
||||
if item.WorkflowVersionID <= 0 {
|
||||
return nil, errorsx.InvalidParam("workflow version is required")
|
||||
}
|
||||
if _, exists := seen[item.WorkflowVersionID]; exists {
|
||||
return nil, errorsx.InvalidParam("workflow version must not be bound more than once")
|
||||
}
|
||||
seen[item.WorkflowVersionID] = struct{}{}
|
||||
version := repositories.AIWorkflowVersionRepository.Get(db, item.WorkflowVersionID)
|
||||
if version == nil || version.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("workflow version is not published")
|
||||
}
|
||||
workflow := repositories.AIWorkflowRepository.Get(db, version.WorkflowID)
|
||||
if workflow == nil || workflow.Status == enums.StatusDeleted {
|
||||
return nil, errorsx.InvalidParam("workflow does not exist")
|
||||
}
|
||||
priority := item.Priority
|
||||
if priority == 0 {
|
||||
priority = index + 1
|
||||
}
|
||||
items = append(items, models.AIAgentWorkflowBinding{AIAgentID: agentID, WorkflowID: version.WorkflowID, WorkflowVersionID: version.ID, ToolName: strings.TrimSpace(item.ToolName), TriggerInstruction: strings.TrimSpace(item.TriggerInstruction), Priority: priority, Enabled: item.Enabled, AuditFields: utils.BuildAuditFields(operator)})
|
||||
}
|
||||
if err := repositories.AIAgentWorkflowBindingRepository.ReplaceByAgentID(db, agentID, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
package services
|
||||
|
||||
import "code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
import (
|
||||
"context"
|
||||
|
||||
var TriggerAIReplyAsyncHook func(conversation models.Conversation, message models.Message)
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
)
|
||||
|
||||
var TriggerAIReplyAsyncHook func(context.Context, models.Conversation, models.Message)
|
||||
|
||||
@@ -1,649 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/workflow/dsl"
|
||||
workflowregistry "code.tczkiot.com/wlw/ai-agent/internal/ai/workflow/registry"
|
||||
workflowvalidator "code.tczkiot.com/wlw/ai-agent/internal/ai/workflow/validator"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/httpx/params"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var AIWorkflowService = newAIWorkflowService()
|
||||
|
||||
func newAIWorkflowService() *aiWorkflowService {
|
||||
return &aiWorkflowService{
|
||||
registry: workflowregistry.DefaultRegistry(),
|
||||
}
|
||||
}
|
||||
|
||||
type aiWorkflowService struct {
|
||||
registry *workflowregistry.Registry
|
||||
}
|
||||
|
||||
type AIWorkflowRunAuditItem struct {
|
||||
Run models.AIWorkflowRun
|
||||
Workflow *models.AIWorkflow
|
||||
Version *models.AIWorkflowVersion
|
||||
Agent *models.AIAgent
|
||||
}
|
||||
|
||||
type AIWorkflowTemplate struct {
|
||||
Code string
|
||||
Name string
|
||||
Description string
|
||||
Definition dsl.Definition
|
||||
}
|
||||
|
||||
type AIWorkflowUsageItem struct {
|
||||
Binding models.AIAgentWorkflowBinding
|
||||
Agent *models.AIAgent
|
||||
Version *models.AIWorkflowVersion
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) Get(id int64) *models.AIWorkflow {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return repositories.AIWorkflowRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) GetVersion(id int64) *models.AIWorkflowVersion {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return repositories.AIWorkflowVersionRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AIWorkflow, paging *sqls.Paging) {
|
||||
return repositories.AIWorkflowRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) FindVersionPageByParams(params *params.QueryParams) (list []models.AIWorkflowVersion, paging *sqls.Paging) {
|
||||
return repositories.AIWorkflowVersionRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) FindRunPageByCnd(cnd *sqls.Cnd) (list []models.AIWorkflowRun, paging *sqls.Paging) {
|
||||
return repositories.AIWorkflowRunRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) BuildRunAuditItems(list []models.AIWorkflowRun) []AIWorkflowRunAuditItem {
|
||||
ret := make([]AIWorkflowRunAuditItem, 0, len(list))
|
||||
if len(list) == 0 {
|
||||
return ret
|
||||
}
|
||||
workflowIDs := make([]int64, 0, len(list))
|
||||
versionIDs := make([]int64, 0, len(list))
|
||||
agentIDs := make([]int64, 0, len(list))
|
||||
for _, item := range list {
|
||||
workflowIDs = appendNonZeroInt64(workflowIDs, item.WorkflowID)
|
||||
versionIDs = appendNonZeroInt64(versionIDs, item.WorkflowVersionID)
|
||||
agentIDs = appendNonZeroInt64(agentIDs, item.AIAgentID)
|
||||
}
|
||||
var workflows []models.AIWorkflow
|
||||
if len(workflowIDs) > 0 {
|
||||
workflows = repositories.AIWorkflowRepository.Find(sqls.DB(), sqls.NewCnd().In("id", workflowIDs))
|
||||
}
|
||||
var versions []models.AIWorkflowVersion
|
||||
if len(versionIDs) > 0 {
|
||||
versions = repositories.AIWorkflowVersionRepository.Find(sqls.DB(), sqls.NewCnd().In("id", versionIDs))
|
||||
}
|
||||
var agents []models.AIAgent
|
||||
if len(agentIDs) > 0 {
|
||||
agents = repositories.AIAgentRepository.Find(sqls.DB(), sqls.NewCnd().In("id", agentIDs))
|
||||
}
|
||||
workflowByID := make(map[int64]*models.AIWorkflow, len(workflows))
|
||||
for i := range workflows {
|
||||
item := workflows[i]
|
||||
workflowByID[item.ID] = &item
|
||||
}
|
||||
versionByID := make(map[int64]*models.AIWorkflowVersion, len(versions))
|
||||
for i := range versions {
|
||||
item := versions[i]
|
||||
versionByID[item.ID] = &item
|
||||
}
|
||||
agentByID := make(map[int64]*models.AIAgent, len(agents))
|
||||
for i := range agents {
|
||||
item := agents[i]
|
||||
agentByID[item.ID] = &item
|
||||
}
|
||||
for _, run := range list {
|
||||
ret = append(ret, AIWorkflowRunAuditItem{
|
||||
Run: run,
|
||||
Workflow: workflowByID[run.WorkflowID],
|
||||
Version: versionByID[run.WorkflowVersionID],
|
||||
Agent: agentByID[run.AIAgentID],
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) GetRunDetail(id int64) (*models.AIWorkflowRun, []models.AIWorkflowNodeRun) {
|
||||
if id <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
run := repositories.AIWorkflowRunRepository.Get(sqls.DB(), id)
|
||||
if run == nil {
|
||||
return nil, nil
|
||||
}
|
||||
nodes := repositories.AIWorkflowNodeRunRepository.Find(sqls.DB(), sqls.NewCnd().Eq("workflow_run_id", id).Asc("id"))
|
||||
return run, nodes
|
||||
}
|
||||
|
||||
func appendNonZeroInt64(list []int64, value int64) []int64 {
|
||||
if value <= 0 {
|
||||
return list
|
||||
}
|
||||
for _, item := range list {
|
||||
if item == value {
|
||||
return list
|
||||
}
|
||||
}
|
||||
return append(list, value)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) ListNodeSpecs() []workflowregistry.NodeSpec {
|
||||
return s.registry.List()
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) DefaultAgentWorkflowDefinition() dsl.Definition {
|
||||
return defaultAgentWorkflowDefinition()
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) ListWorkflowTemplates() []AIWorkflowTemplate {
|
||||
return []AIWorkflowTemplate{
|
||||
{Code: "ticket-with-confirmation", Name: "创建工单", Description: "整理工单草稿,经客户确认后创建工单。", Definition: ticketWithConfirmationWorkflowDefinition()},
|
||||
{Code: "identity-confirmation", Name: "身份确认", Description: "在执行后续业务前收集客户的明确确认。", Definition: identityConfirmationWorkflowDefinition()},
|
||||
{Code: "complaint-escalation", Name: "投诉升级", Description: "投诉场景经客户确认后转入人工客服处理。", Definition: complaintEscalationWorkflowDefinition()},
|
||||
{Code: "refund-request-preparation", Name: "退款申请准备", Description: "整理退款诉求,确认后转人工继续核验和处理。", Definition: refundRequestPreparationWorkflowDefinition()},
|
||||
}
|
||||
}
|
||||
|
||||
func ticketWithConfirmationWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
|
||||
workflowNode("draft_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 600, 180, workflowInputs("issue", "start_1", "userMessage"), nil),
|
||||
workflowNode("ready_route_1", workflowregistry.NodeTypeCondition, "草稿分流", 1020, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("ready", "草稿完整", "prompt_1", "draft_1", "ready", "is_true", nil),
|
||||
{ID: "default", Name: "补充信息", TargetNodeID: "followup_1", Default: true},
|
||||
}}),
|
||||
workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, "建单确认", 1440, 100, map[string]dsl.Value{"userMessage": dsl.RefValue("start_1", "userMessage"), "ticketTitle": dsl.RefValue("draft_1", "title"), "ticketDescription": dsl.RefValue("draft_1", "description")}, map[string]any{"staticReply": "我已整理工单草稿:{{ticketTitle}}。请确认是否创建。"}),
|
||||
workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认建单", 1860, 100, workflowInputs("prompt", "prompt_1", "replyText"), nil),
|
||||
workflowNode("confirm_route_1", workflowregistry.NodeTypeCondition, "确认分流", 2280, 100, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("confirmed", "已确认", "create_1", "confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "取消", TargetNodeID: "cancel_1", Default: true},
|
||||
}}),
|
||||
workflowNode("create_1", workflowregistry.NodeTypeCreateTicket, "创建工单", 2700, 20, map[string]dsl.Value{"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"), "confirmed": dsl.RefValue("confirm_1", "confirmed")}, nil),
|
||||
workflowNode("followup_1", workflowregistry.NodeTypeLLMReply, "补充信息", 1440, 330, map[string]dsl.Value{"userMessage": dsl.RefValue("start_1", "userMessage"), "followUpQuestions": dsl.RefValue("draft_1", "followUpQuestions")}, map[string]any{"staticReply": "创建工单前还需要补充:{{followUpQuestions}}"}),
|
||||
workflowNode("cancel_1", workflowregistry.NodeTypeLLMReply, "取消提示", 2700, 200, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消创建工单。"}),
|
||||
workflowNode("send_result_1", workflowregistry.NodeTypeSendReply, "发送建单结果", 3120, 20, workflowInputs("replyText", "create_1", "message"), nil),
|
||||
workflowNode("send_followup_1", workflowregistry.NodeTypeSendReply, "发送补充提示", 1860, 330, workflowInputs("replyText", "followup_1", "replyText"), nil),
|
||||
workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 3120, 200, workflowInputs("replyText", "cancel_1", "replyText"), nil),
|
||||
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 3540, 180, nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
workflowEdge("start_1", "draft_1"), workflowEdge("draft_1", "ready_route_1"), workflowPortEdge("ready_route_1", "prompt_1", "ready"), workflowPortEdge("ready_route_1", "followup_1", "default"),
|
||||
workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "confirm_route_1"), workflowPortEdge("confirm_route_1", "create_1", "confirmed"), workflowPortEdge("confirm_route_1", "cancel_1", "default"),
|
||||
workflowEdge("create_1", "send_result_1"), workflowEdge("send_result_1", "end_1"), workflowEdge("followup_1", "send_followup_1"), workflowEdge("send_followup_1", "end_1"), workflowEdge("cancel_1", "send_cancel_1"), workflowEdge("send_cancel_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) ValidateDefinition(def dsl.Definition) workflowvalidator.Result {
|
||||
return workflowvalidator.ValidateDefinition(def, s.registry)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) CreateWorkflow(req request.CreateAIWorkflowRequest, operator *dto.AuthPrincipal) (*models.AIWorkflow, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParam("workflow name is required")
|
||||
}
|
||||
definition, err := marshalDefinition(req.Definition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := &models.AIWorkflow{Name: name, Description: strings.TrimSpace(req.Description), Status: enums.StatusOk, DraftDefinition: definition, AuditFields: utils.BuildAuditFields(operator)}
|
||||
if err := repositories.AIWorkflowRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) UpdateWorkflow(req request.UpdateAIWorkflowRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
if s.Get(req.ID) == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return errorsx.InvalidParam("workflow name is required")
|
||||
}
|
||||
definition, err := marshalDefinition(req.Definition)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repositories.AIWorkflowRepository.Updates(sqls.DB(), req.ID, map[string]interface{}{
|
||||
"name": name,
|
||||
"description": strings.TrimSpace(req.Description),
|
||||
"draft_definition": definition,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) DeleteWorkflow(id int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
if s.Get(id) == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
if repositories.AIAgentWorkflowBindingRepository.CountByWorkflowID(sqls.DB(), id) > 0 {
|
||||
return errorsx.InvalidParam("workflow is still associated with an agent")
|
||||
}
|
||||
return repositories.AIWorkflowRepository.Updates(sqls.DB(), id, map[string]interface{}{
|
||||
"status": enums.StatusDeleted,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) RestoreVersion(req request.RestoreAIWorkflowVersionRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
workflow := s.Get(req.WorkflowID)
|
||||
version := s.GetVersion(req.WorkflowVersionID)
|
||||
if workflow == nil || version == nil || version.WorkflowID != workflow.ID {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
return repositories.AIWorkflowRepository.Updates(sqls.DB(), workflow.ID, map[string]any{"draft_definition": version.Definition, "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": time.Now()})
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) ListUsage(workflowID int64) []AIWorkflowUsageItem {
|
||||
bindings := repositories.AIAgentWorkflowBindingRepository.FindByWorkflowID(sqls.DB(), workflowID)
|
||||
ret := make([]AIWorkflowUsageItem, 0, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
ret = append(ret, AIWorkflowUsageItem{Binding: binding, Agent: repositories.AIAgentRepository.Get(sqls.DB(), binding.AIAgentID), Version: repositories.AIWorkflowVersionRepository.Get(sqls.DB(), binding.WorkflowVersionID)})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) PublishWorkflow(req request.PublishAIWorkflowRequest, operator *dto.AuthPrincipal) (*models.AIWorkflowVersion, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
workflow := s.Get(req.WorkflowID)
|
||||
if workflow == nil || workflow.Status == enums.StatusDeleted {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
result := s.ValidateDefinition(req.Definition)
|
||||
if !result.Valid {
|
||||
return nil, errorsx.InvalidParam("workflow definition is invalid")
|
||||
}
|
||||
definition, err := marshalDefinition(req.Definition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
var version *models.AIWorkflowVersion
|
||||
err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
nextVersion := repositories.AIWorkflowVersionRepository.MaxVersionByWorkflowID(ctx.Tx, req.WorkflowID) + 1
|
||||
version = &models.AIWorkflowVersion{
|
||||
WorkflowID: req.WorkflowID,
|
||||
Version: nextVersion,
|
||||
Status: enums.StatusOk,
|
||||
Definition: definition,
|
||||
DefinitionHash: hashDefinition(definition),
|
||||
PublishedAt: &now,
|
||||
PublishedByID: operator.UserID,
|
||||
PublishedByName: operator.Username,
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
if err := repositories.AIWorkflowVersionRepository.Create(ctx.Tx, version); err != nil {
|
||||
return err
|
||||
}
|
||||
return repositories.AIWorkflowRepository.Updates(ctx.Tx, req.WorkflowID, map[string]interface{}{
|
||||
"draft_definition": definition,
|
||||
"published_version_id": version.ID,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func defaultAgentWorkflowDefinition() dsl.Definition {
|
||||
return officialDefaultAgentWorkflowDefinition()
|
||||
}
|
||||
|
||||
func officialDefaultAgentWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
Nodes: []dsl.Node{
|
||||
{
|
||||
ID: "start_0",
|
||||
Type: workflowregistry.NodeTypeStart,
|
||||
Meta: dsl.NodeMeta{Position: dsl.Position{X: 180, Y: 300}},
|
||||
Data: dsl.NodeData{
|
||||
Title: "Start",
|
||||
Outputs: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string","default":"Hello Flow."}}}`),
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "llm_0",
|
||||
Type: workflowregistry.NodeTypeLLM,
|
||||
Meta: dsl.NodeMeta{Position: dsl.Position{X: 640, Y: 220}},
|
||||
Data: dsl.NodeData{
|
||||
Title: "LLM",
|
||||
InputsValues: map[string]dsl.Value{
|
||||
"modelName": dsl.ConstantValue("gpt-3.5-turbo"),
|
||||
"apiKey": dsl.ConstantValue("sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"),
|
||||
"apiHost": dsl.ConstantValue("https://mock-ai-url/api/v3"),
|
||||
"temperature": dsl.ConstantValue(0.5),
|
||||
"systemPrompt": dsl.TemplateValue(
|
||||
"# Role\nYou are an AI assistant.\n",
|
||||
),
|
||||
"prompt": dsl.TemplateValue(""),
|
||||
},
|
||||
Inputs: json.RawMessage(`{"type":"object","required":["modelName","apiKey","apiHost","temperature","prompt"],"properties":{"modelName":{"type":"string"},"apiKey":{"type":"string"},"apiHost":{"type":"string"},"temperature":{"type":"number"},"systemPrompt":{"type":"string","extra":{"formComponent":"prompt-editor"}},"prompt":{"type":"string","extra":{"formComponent":"prompt-editor"}}}}`),
|
||||
Outputs: json.RawMessage(`{"type":"object","properties":{"result":{"type":"string"}}}`),
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "end_0",
|
||||
Type: workflowregistry.NodeTypeEnd,
|
||||
Meta: dsl.NodeMeta{Position: dsl.Position{X: 1100, Y: 300}},
|
||||
Data: dsl.NodeData{
|
||||
Title: "End",
|
||||
InputsValues: map[string]dsl.Value{
|
||||
"result": dsl.RefValue("llm_0", "result"),
|
||||
},
|
||||
Inputs: json.RawMessage(`{"type":"object","properties":{"result":{"type":"string"}}}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{SourceNodeID: "start_0", TargetNodeID: "llm_0"},
|
||||
{SourceNodeID: "llm_0", TargetNodeID: "end_0"},
|
||||
},
|
||||
GlobalVariable: json.RawMessage(`{"type":"object","properties":{}}`),
|
||||
}
|
||||
}
|
||||
|
||||
func legacyDefaultAgentWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 285.5, nil, nil),
|
||||
workflowNode("understanding_1", workflowregistry.NodeTypeConversationUnderstanding, "会话理解", 640, 285.5, workflowInputs("userMessage", "start_1", "userMessage"), nil),
|
||||
workflowNode("policy_1", workflowregistry.NodeTypeReplyPolicy, "回复策略", 1100, 285.5, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"messageIntent": dsl.RefValue("understanding_1", "messageIntent"),
|
||||
"answerScope": dsl.RefValue("understanding_1", "answerScope"),
|
||||
"riskSignals": dsl.RefValue("understanding_1", "riskSignals"),
|
||||
}, nil),
|
||||
workflowNode("policy_route_1", workflowregistry.NodeTypeCondition, "策略分流", 1560, 125.5, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("handoff", "转人工", "handoff_confirm_prompt_1", "policy_1", "action", "eq", "handoff_to_human"),
|
||||
workflowConditionBranch("direct", "直接回复", "policy_reply_1", "policy_1", "action", "eq", "direct_reply"),
|
||||
workflowConditionBranch("clarify", "追问澄清", "policy_reply_1", "policy_1", "action", "eq", "clarify"),
|
||||
workflowConditionBranch("end_conversation", "结束语", "policy_reply_1", "policy_1", "action", "eq", "end_conversation"),
|
||||
workflowConditionBranch("ticket", "创建工单", "draft_ticket_1", "policy_1", "action", "eq", "prepare_ticket"),
|
||||
workflowConditionBranch("knowledge", "知识库回复", "retrieve_1", "policy_1", "action", "eq", "retrieve_knowledge"),
|
||||
{ID: "default", Name: "策略兜底", TargetNodeID: "policy_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("handoff_confirm_prompt_1", workflowregistry.NodeTypeLLMReply, "转人工确认文案", 2020, 0, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "我可以为你转接人工客服处理。请回复“确认”继续转人工,或回复“取消”继续由 AI 协助。"}),
|
||||
workflowNode("handoff_confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认转人工", 2480, 0, workflowInputs("prompt", "handoff_confirm_prompt_1", "replyText"), nil),
|
||||
workflowNode("handoff_confirm_route_1", workflowregistry.NodeTypeCondition, "转人工确认分流", 2940, 0, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("confirmed", "已确认", "handoff_1", "handoff_confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "取消或未确认", TargetNodeID: "handoff_cancel_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工", 3400, 0, map[string]dsl.Value{
|
||||
"reason": dsl.RefValue("start_1", "userMessage"),
|
||||
"confirmed": dsl.RefValue("handoff_confirm_1", "confirmed"),
|
||||
}, nil),
|
||||
workflowNode("handoff_cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消转人工提示", 3400, 480, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消转人工。你可以继续补充问题,我会继续协助。"}),
|
||||
workflowNode("send_handoff_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 3860, 480, workflowInputs("replyText", "handoff_cancel_reply_1", "replyText"), nil),
|
||||
workflowNode("policy_reply_1", workflowregistry.NodeTypeSendReply, "发送策略回复", 4320, 98.5, workflowInputs("replyText", "policy_1", "replyText"), nil),
|
||||
workflowNode("handoff_end_1", workflowregistry.NodeTypeEnd, "结束", 3860, 0, nil, nil),
|
||||
workflowNode("draft_ticket_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 2020, 379, workflowInputs("issue", "start_1", "userMessage"), nil),
|
||||
workflowNode("ticket_draft_route_1", workflowregistry.NodeTypeCondition, "草稿就绪分流", 2480, 329, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("ready", "草稿完整", "ticket_confirm_prompt_1", "draft_ticket_1", "ready", "is_true", nil),
|
||||
{ID: "default", Name: "补充信息", TargetNodeID: "ticket_followup_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("ticket_confirm_prompt_1", workflowregistry.NodeTypeLLMReply, "建单确认文案", 2940, 285.5, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"ticketTitle": dsl.RefValue("draft_ticket_1", "title"),
|
||||
"ticketDescription": dsl.RefValue("draft_ticket_1", "description"),
|
||||
}, map[string]any{"staticReply": "我已整理工单草稿,请确认是否创建:\n标题:{{ticketTitle}}\n描述:{{ticketDescription}}\n请回复“确认”创建工单,或回复“取消”放弃。"}),
|
||||
workflowNode("ticket_confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认建单", 3400, 285.5, workflowInputs("prompt", "ticket_confirm_prompt_1", "replyText"), nil),
|
||||
workflowNode("ticket_confirm_route_1", workflowregistry.NodeTypeCondition, "建单确认分流", 3860, 235.5, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("confirmed", "已确认", "create_ticket_1", "ticket_confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "取消或未确认", TargetNodeID: "ticket_cancel_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("create_ticket_1", workflowregistry.NodeTypeCreateTicket, "创建工单", 4780, 192, map[string]dsl.Value{
|
||||
"ticketDraft": dsl.RefValue("draft_ticket_1", "ticketDraft"),
|
||||
"confirmed": dsl.RefValue("ticket_confirm_1", "confirmed"),
|
||||
}, nil),
|
||||
workflowNode("ticket_result_reply_1", workflowregistry.NodeTypeSendReply, "发送建单结果", 5240, 192, workflowInputs("replyText", "create_ticket_1", "message"), nil),
|
||||
workflowNode("ticket_cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消建单提示", 4320, 379, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消创建工单。你可以继续补充问题,我会继续帮你处理。"}),
|
||||
workflowNode("send_ticket_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 4780, 379, workflowInputs("replyText", "ticket_cancel_reply_1", "replyText"), nil),
|
||||
workflowNode("ticket_followup_reply_1", workflowregistry.NodeTypeLLMReply, "追问工单信息", 3860, 1033.5, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"followUpQuestions": dsl.RefValue("draft_ticket_1", "followUpQuestions"),
|
||||
}, map[string]any{"staticReply": "为了创建工单,还需要补充以下信息:\n{{followUpQuestions}}"}),
|
||||
workflowNode("send_ticket_followup_1", workflowregistry.NodeTypeSendReply, "发送工单追问", 4780, 1033.5, workflowInputs("replyText", "ticket_followup_reply_1", "replyText"), nil),
|
||||
workflowNode("retrieve_1", workflowregistry.NodeTypeKnowledgeRetrieve, "知识检索", 2480, 753, workflowInputs("query", "start_1", "userMessage"), map[string]any{"knowledgeBaseIds": []int64{}}),
|
||||
workflowNode("answerability_1", workflowregistry.NodeTypeAnswerabilityGate, "可回答判断", 2940, 753, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
|
||||
}, nil),
|
||||
workflowNode("answerability_route_1", workflowregistry.NodeTypeCondition, "可回答分流", 3400, 703, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("answerable", "可以回答", "reply_1", "answerability_1", "answerability", "eq", "answerable"),
|
||||
{ID: "default", Name: "兜底追问", TargetNodeID: "fallback_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("reply_1", workflowregistry.NodeTypeLLMReply, "AI 回复", 3860, 659.5, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
|
||||
}, nil),
|
||||
workflowNode("send_1", workflowregistry.NodeTypeSendReply, "发送回复", 4320, 659.5, workflowInputs("replyText", "reply_1", "replyText"), nil),
|
||||
workflowNode("fallback_reply_1", workflowregistry.NodeTypeLLMReply, "兜底追问", 3860, 846.5, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
|
||||
}, nil),
|
||||
workflowNode("send_fallback_1", workflowregistry.NodeTypeSendReply, "发送兜底", 4320, 846.5, workflowInputs("replyText", "fallback_reply_1", "replyText"), nil),
|
||||
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 5700, 472.5, nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
workflowEdge("start_1", "understanding_1"),
|
||||
workflowEdge("understanding_1", "policy_1"),
|
||||
workflowEdge("policy_1", "policy_route_1"),
|
||||
workflowPortEdge("policy_route_1", "handoff_confirm_prompt_1", "handoff"),
|
||||
workflowPortEdge("policy_route_1", "policy_reply_1", "direct"),
|
||||
workflowPortEdge("policy_route_1", "policy_reply_1", "clarify"),
|
||||
workflowPortEdge("policy_route_1", "policy_reply_1", "end_conversation"),
|
||||
workflowPortEdge("policy_route_1", "draft_ticket_1", "ticket"),
|
||||
workflowPortEdge("policy_route_1", "retrieve_1", "knowledge"),
|
||||
workflowPortEdge("policy_route_1", "policy_reply_1", "default"),
|
||||
workflowEdge("policy_reply_1", "end_1"),
|
||||
workflowEdge("handoff_confirm_prompt_1", "handoff_confirm_1"),
|
||||
workflowEdge("handoff_confirm_1", "handoff_confirm_route_1"),
|
||||
workflowPortEdge("handoff_confirm_route_1", "handoff_1", "confirmed"),
|
||||
workflowPortEdge("handoff_confirm_route_1", "handoff_cancel_reply_1", "default"),
|
||||
workflowEdge("handoff_1", "handoff_end_1"),
|
||||
workflowEdge("handoff_cancel_reply_1", "send_handoff_cancel_1"),
|
||||
workflowEdge("send_handoff_cancel_1", "end_1"),
|
||||
workflowEdge("draft_ticket_1", "ticket_draft_route_1"),
|
||||
workflowPortEdge("ticket_draft_route_1", "ticket_confirm_prompt_1", "ready"),
|
||||
workflowPortEdge("ticket_draft_route_1", "ticket_followup_reply_1", "default"),
|
||||
workflowEdge("ticket_confirm_prompt_1", "ticket_confirm_1"),
|
||||
workflowEdge("ticket_confirm_1", "ticket_confirm_route_1"),
|
||||
workflowPortEdge("ticket_confirm_route_1", "create_ticket_1", "confirmed"),
|
||||
workflowPortEdge("ticket_confirm_route_1", "ticket_cancel_reply_1", "default"),
|
||||
workflowEdge("create_ticket_1", "ticket_result_reply_1"),
|
||||
workflowEdge("ticket_result_reply_1", "end_1"),
|
||||
workflowEdge("ticket_cancel_reply_1", "send_ticket_cancel_1"),
|
||||
workflowEdge("send_ticket_cancel_1", "end_1"),
|
||||
workflowEdge("ticket_followup_reply_1", "send_ticket_followup_1"),
|
||||
workflowEdge("send_ticket_followup_1", "end_1"),
|
||||
workflowEdge("retrieve_1", "answerability_1"),
|
||||
workflowEdge("answerability_1", "answerability_route_1"),
|
||||
workflowPortEdge("answerability_route_1", "reply_1", "answerable"),
|
||||
workflowPortEdge("answerability_route_1", "fallback_reply_1", "default"),
|
||||
workflowEdge("reply_1", "send_1"),
|
||||
workflowEdge("send_1", "end_1"),
|
||||
workflowEdge("fallback_reply_1", "send_fallback_1"),
|
||||
workflowEdge("send_fallback_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func identityConfirmationWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
|
||||
workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, "身份确认提示", 600, 180, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "为保护你的账户信息,请确认是否继续身份核验。"}),
|
||||
workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认身份核验", 1020, 180, workflowInputs("prompt", "prompt_1", "replyText"), nil),
|
||||
workflowNode("route_1", workflowregistry.NodeTypeCondition, "确认分流", 1440, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("confirmed", "已确认", "confirmed_reply_1", "confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "取消", TargetNodeID: "cancel_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("confirmed_reply_1", workflowregistry.NodeTypeLLMReply, "确认结果", 1860, 100, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已收到确认,人工客服将继续为你核验身份。"}),
|
||||
workflowNode("cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消提示", 1860, 280, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消身份核验。"}),
|
||||
workflowNode("send_confirmed_1", workflowregistry.NodeTypeSendReply, "发送确认结果", 2280, 100, workflowInputs("replyText", "confirmed_reply_1", "replyText"), nil),
|
||||
workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 2280, 280, workflowInputs("replyText", "cancel_reply_1", "replyText"), nil),
|
||||
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 2700, 180, nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
workflowEdge("start_1", "prompt_1"), workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "route_1"),
|
||||
workflowPortEdge("route_1", "confirmed_reply_1", "confirmed"), workflowPortEdge("route_1", "cancel_reply_1", "default"),
|
||||
workflowEdge("confirmed_reply_1", "send_confirmed_1"), workflowEdge("cancel_reply_1", "send_cancel_1"), workflowEdge("send_confirmed_1", "end_1"), workflowEdge("send_cancel_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func complaintEscalationWorkflowDefinition() dsl.Definition {
|
||||
return confirmationHandoffWorkflowDefinition("投诉升级确认", "我们将把本次投诉升级给人工客服处理。请确认是否继续。", "已为你升级投诉,人工客服会尽快跟进。", "投诉升级已取消。")
|
||||
}
|
||||
|
||||
func confirmationHandoffWorkflowDefinition(title, prompt, confirmedReply, cancelledReply string) dsl.Definition {
|
||||
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
|
||||
workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, title, 600, 180, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": prompt}),
|
||||
workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认升级", 1020, 180, workflowInputs("prompt", "prompt_1", "replyText"), nil),
|
||||
workflowNode("route_1", workflowregistry.NodeTypeCondition, "确认分流", 1440, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("confirmed", "已确认", "handoff_1", "confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "取消", TargetNodeID: "cancel_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工处理", 1860, 100, map[string]dsl.Value{"reason": dsl.RefValue("start_1", "userMessage"), "confirmed": dsl.RefValue("confirm_1", "confirmed")}, nil),
|
||||
workflowNode("cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消提示", 1860, 280, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": cancelledReply}),
|
||||
workflowNode("send_handoff_1", workflowregistry.NodeTypeSendReply, "发送升级结果", 2280, 100, workflowInputs("replyText", "handoff_1", "message"), nil),
|
||||
workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 2280, 280, workflowInputs("replyText", "cancel_reply_1", "replyText"), nil),
|
||||
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 2700, 180, nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
workflowEdge("start_1", "prompt_1"), workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "route_1"),
|
||||
workflowPortEdge("route_1", "handoff_1", "confirmed"), workflowPortEdge("route_1", "cancel_reply_1", "default"),
|
||||
workflowEdge("handoff_1", "send_handoff_1"), workflowEdge("cancel_reply_1", "send_cancel_1"), workflowEdge("send_handoff_1", "end_1"), workflowEdge("send_cancel_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func refundRequestPreparationWorkflowDefinition() dsl.Definition {
|
||||
return confirmationHandoffWorkflowDefinition("退款申请确认", "我会先整理退款申请并转交人工客服核验。请确认是否继续。", "退款申请已准备完成,人工客服将继续核验订单和退款条件。", "退款申请准备已取消。")
|
||||
}
|
||||
|
||||
func workflowNode(id string, nodeType string, title string, x float64, y float64, inputs map[string]dsl.Value, config any) dsl.Node {
|
||||
return dsl.Node{
|
||||
ID: id,
|
||||
Type: nodeType,
|
||||
Meta: dsl.NodeMeta{Position: dsl.Position{X: x, Y: y}},
|
||||
Data: dsl.NodeData{
|
||||
Title: title,
|
||||
Config: mustMarshalWorkflowConfig(config),
|
||||
InputsValues: inputs,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func workflowInputs(name string, nodeID string, field string) map[string]dsl.Value {
|
||||
return map[string]dsl.Value{name: dsl.RefValue(nodeID, field)}
|
||||
}
|
||||
|
||||
func workflowConditionBranch(id string, name string, targetNodeID string, nodeID string, field string, operator string, right any) dsl.ConditionBranch {
|
||||
return dsl.ConditionBranch{
|
||||
ID: id,
|
||||
Name: name,
|
||||
TargetNodeID: targetNodeID,
|
||||
Condition: &dsl.Condition{
|
||||
Left: &dsl.Value{Type: dsl.ValueTypeRef, Content: []string{nodeID, field}},
|
||||
Operator: operator,
|
||||
Right: right,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func workflowEdge(source string, target string) dsl.Edge {
|
||||
return dsl.Edge{SourceNodeID: source, TargetNodeID: target}
|
||||
}
|
||||
|
||||
func workflowPortEdge(source string, target string, sourcePortID string) dsl.Edge {
|
||||
return dsl.Edge{SourceNodeID: source, TargetNodeID: target, SourcePortID: sourcePortID}
|
||||
}
|
||||
|
||||
func mustMarshalWorkflowConfig(value any) json.RawMessage {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func defaultAgentWorkflowName(agentName string) string {
|
||||
agentName = strings.TrimSpace(agentName)
|
||||
if agentName == "" {
|
||||
return "会话流程"
|
||||
}
|
||||
return agentName + " 会话流程"
|
||||
}
|
||||
|
||||
func marshalDefinition(def dsl.Definition) (string, error) {
|
||||
buf, err := json.Marshal(def)
|
||||
if err != nil {
|
||||
return "", errorsx.InvalidParam("invalid workflow definition")
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func hashDefinition(definition string) string {
|
||||
sum := sha256.Sum256([]byte(definition))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/workflow/dsl"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/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",
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
@@ -9,13 +17,6 @@ import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/services/storage"
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
@@ -46,32 +47,58 @@ func (s *assetService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Asset, paging
|
||||
}
|
||||
|
||||
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")
|
||||
provider, err := storage.NewProvider(asset.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.Read(asset.StorageKey)
|
||||
}
|
||||
|
||||
func (s *assetService) UploadBytes(data []byte, prefix, filename string, principal *dto.AuthPrincipal) (*models.Asset, error) {
|
||||
return s.uploadBytes(data, prefix, filename, 0, principal)
|
||||
}
|
||||
|
||||
func (s *assetService) UploadConversationBytes(data []byte, prefix, filename string, conversationID int64, principal *dto.AuthPrincipal) (*models.Asset, error) {
|
||||
if conversationID <= 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0064")
|
||||
}
|
||||
return s.uploadBytes(data, prefix, filename, conversationID, principal)
|
||||
}
|
||||
|
||||
func (s *assetService) uploadBytes(data []byte, prefix, filename string, conversationID int64, principal *dto.AuthPrincipal) (*models.Asset, error) {
|
||||
src := bytes.NewReader(data)
|
||||
return s.Upload(src, storage.UploadInfo{
|
||||
Prefix: prefix,
|
||||
Filename: filename,
|
||||
FileSize: int64(len(data)),
|
||||
MimeType: http.DetectContentType(data),
|
||||
Principal: principal,
|
||||
Prefix: prefix,
|
||||
ConversationID: conversationID,
|
||||
Filename: filename,
|
||||
FileSize: int64(len(data)),
|
||||
MimeType: http.DetectContentType(data),
|
||||
Principal: principal,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *assetService) UploadFile(file *multipart.FileHeader, prefix string, principal *dto.AuthPrincipal) (*models.Asset, error) {
|
||||
return s.uploadFile(file, prefix, 0, false, principal)
|
||||
}
|
||||
|
||||
func (s *assetService) UploadConversationFile(file *multipart.FileHeader, prefix string, conversationID int64, principal *dto.AuthPrincipal) (*models.Asset, error) {
|
||||
if conversationID <= 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0064")
|
||||
}
|
||||
return s.uploadFile(file, prefix, conversationID, false, principal)
|
||||
}
|
||||
|
||||
func (s *assetService) UploadConversationImageFile(file *multipart.FileHeader, prefix string, conversationID int64, principal *dto.AuthPrincipal) (*models.Asset, error) {
|
||||
if conversationID <= 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0064")
|
||||
}
|
||||
return s.uploadFile(file, prefix, conversationID, true, principal)
|
||||
}
|
||||
|
||||
func (s *assetService) uploadFile(file *multipart.FileHeader, prefix string, conversationID int64, imageOnly bool, principal *dto.AuthPrincipal) (*models.Asset, error) {
|
||||
if file == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0323")
|
||||
}
|
||||
@@ -87,12 +114,20 @@ func (s *assetService) UploadFile(file *multipart.FileHeader, prefix string, pri
|
||||
}
|
||||
defer func() { _ = src.Close() }()
|
||||
|
||||
return s.Upload(src, storage.UploadInfo{
|
||||
Prefix: prefix,
|
||||
Filename: file.Filename,
|
||||
FileSize: file.Size,
|
||||
MimeType: file.Header.Get("Content-Type"),
|
||||
Principal: principal,
|
||||
reader := bufio.NewReader(src)
|
||||
header, _ := reader.Peek(512)
|
||||
mimeType := strings.TrimSpace(strings.Split(http.DetectContentType(header), ";")[0])
|
||||
if imageOnly && !isSupportedVisionImageMIME(mimeType) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0090")
|
||||
}
|
||||
|
||||
return s.Upload(reader, storage.UploadInfo{
|
||||
Prefix: prefix,
|
||||
ConversationID: conversationID,
|
||||
Filename: file.Filename,
|
||||
FileSize: file.Size,
|
||||
MimeType: mimeType,
|
||||
Principal: principal,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -104,25 +139,27 @@ func (s *assetService) Upload(reader io.Reader, info storage.UploadInfo) (*model
|
||||
|
||||
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),
|
||||
ConversationID: info.ConversationID,
|
||||
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,
|
||||
Prefix: info.Prefix,
|
||||
ConversationID: info.ConversationID,
|
||||
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
|
||||
@@ -134,6 +171,15 @@ func (s *assetService) Upload(reader io.Reader, info storage.UploadInfo) (*model
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func isSupportedVisionImageMIME(mimeType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) {
|
||||
case "image/jpeg", "image/png", "image/gif", "image/webp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *assetService) GetSignedURL(id int64) (string, error) {
|
||||
item := s.Get(id)
|
||||
if item == nil {
|
||||
|
||||
@@ -36,7 +36,8 @@ func (s *externalPrincipalService) Authenticate(ctx *gin.Context) (*dto.AuthPrin
|
||||
}
|
||||
|
||||
subject, err := SubjectService.Current(ctx.Request.Context())
|
||||
if err != nil || subject == nil || subject.Category != identity.CategorySystem || !subject.Enabled {
|
||||
if err != nil || subject == nil || subject.Type != identity.SubjectAdmin ||
|
||||
subject.Category != identity.CategorySystem || !subject.Enabled {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
|
||||
|
||||
@@ -66,3 +66,68 @@ func TestExternalAuthRejectsHostDeniedOperation(t *testing.T) {
|
||||
t.Fatal("RequirePermission() error = nil, want forbidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalAuthRejectsAgentAsDashboardOperator(t *testing.T) {
|
||||
SetQuerySubjects(func(_ context.Context, query identity.Query) ([]identity.Subject, error) {
|
||||
if !query.Current {
|
||||
return nil, nil
|
||||
}
|
||||
return []identity.Subject{{
|
||||
Type: identity.SubjectAgent, Category: identity.CategorySystem, ID: 10, Enabled: true,
|
||||
}}, nil
|
||||
})
|
||||
SetAuthorize(func(_ context.Context, _ string) error { return nil })
|
||||
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("GET", "/api/dashboard/conversation/list", nil)
|
||||
if _, err := AuthService.Authenticate(ctx); err == nil {
|
||||
t.Fatal("Authenticate() error = nil, want agent dashboard access rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentIdentityActsAsExternalCustomer(t *testing.T) {
|
||||
SetQuerySubjects(func(_ context.Context, query identity.Query) ([]identity.Subject, error) {
|
||||
if !query.Current {
|
||||
return nil, nil
|
||||
}
|
||||
return []identity.Subject{{
|
||||
Type: identity.SubjectAgent, Category: identity.CategoryUser, ID: 12,
|
||||
Name: "Agent Customer", Enabled: true,
|
||||
}}, nil
|
||||
})
|
||||
|
||||
external, err := SubjectService.CurrentExternal(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentExternal() error = %v", err)
|
||||
}
|
||||
if external.ExternalID != "agent:12" || external.ExternalName != "Agent Customer" {
|
||||
t.Fatalf("external = %#v", external)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymousGuestIdentityFallback(t *testing.T) {
|
||||
SetQuerySubjects(func(_ context.Context, query identity.Query) ([]identity.Subject, error) {
|
||||
if query.Current {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
external, err := SubjectService.ResolveExternal(context.Background(), "guest_123", "Web Visitor")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveExternal() error = %v", err)
|
||||
}
|
||||
if external.ExternalSource != "guest" || external.ExternalID != "guest_123" || external.ExternalName != "Web Visitor" {
|
||||
t.Fatalf("external = %#v", external)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymousGuestIdentityRequiresOpaqueID(t *testing.T) {
|
||||
SetQuerySubjects(func(_ context.Context, query identity.Query) ([]identity.Subject, error) {
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
if _, err := SubjectService.ResolveExternal(context.Background(), "", "Web Visitor"); err == nil {
|
||||
t.Fatal("ResolveExternal() error = nil, want missing guest id rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
)
|
||||
|
||||
// businessActionFailureIsRetryable is deliberately conservative. Transport
|
||||
// failures can happen after the host has committed a write, so they always
|
||||
// produce unknown_outcome even when wrapped in a customer-safe error.
|
||||
func businessActionFailureIsRetryable(ctx context.Context, err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
|
||||
errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) ||
|
||||
errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.ECONNABORTED) ||
|
||||
errors.Is(err, syscall.EPIPE) {
|
||||
return false
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && (netErr.Timeout() || netErr.Temporary()) {
|
||||
return false
|
||||
}
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
for _, marker := range []string{
|
||||
"timeout", "timed out", "deadline exceeded", "context canceled",
|
||||
"connection reset", "connection aborted", "broken pipe", "unexpected eof",
|
||||
"server closed idle connection", "transport connection broken",
|
||||
} {
|
||||
if strings.Contains(message, marker) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return contract.BusinessActionErrorOutcome(err) == contract.BusinessActionFailureRetryable
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
)
|
||||
|
||||
var BusinessActionToolService = &businessActionToolService{}
|
||||
|
||||
type businessActionToolService struct {
|
||||
mu sync.RWMutex
|
||||
tools map[string]contract.BusinessActionTool
|
||||
}
|
||||
|
||||
func SetBusinessActionTools(tools []contract.BusinessActionTool) error {
|
||||
registered := make(map[string]contract.BusinessActionTool, len(tools))
|
||||
for _, tool := range tools {
|
||||
tool.Code = strings.TrimSpace(tool.Code)
|
||||
tool.Description = strings.TrimSpace(tool.Description)
|
||||
if tool.Code == "" {
|
||||
return fmt.Errorf("ai-agent: business action tool code is required")
|
||||
}
|
||||
if !strings.HasPrefix(tool.Code, "business/") {
|
||||
return fmt.Errorf("ai-agent: business action tool code must start with business/: %s", tool.Code)
|
||||
}
|
||||
if tool.Description == "" {
|
||||
return fmt.Errorf("ai-agent: business action tool description is required: %s", tool.Code)
|
||||
}
|
||||
if tool.Preview == nil || tool.Execute == nil {
|
||||
return fmt.Errorf("ai-agent: business action tool preview and executor are required: %s", tool.Code)
|
||||
}
|
||||
if _, exists := registered[tool.Code]; exists {
|
||||
return fmt.Errorf("ai-agent: duplicate business action tool code: %s", tool.Code)
|
||||
}
|
||||
if tool.InputSchema == nil {
|
||||
tool.InputSchema = map[string]any{"type": "object", "properties": map[string]any{}}
|
||||
}
|
||||
registered[tool.Code] = tool
|
||||
}
|
||||
|
||||
BusinessActionToolService.mu.Lock()
|
||||
BusinessActionToolService.tools = registered
|
||||
BusinessActionToolService.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *businessActionToolService) ListForCustomerType(customerType string) []contract.BusinessActionTool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
ret := make([]contract.BusinessActionTool, 0, len(s.tools))
|
||||
for _, tool := range s.tools {
|
||||
if businessActionToolSupportsCustomerType(tool, customerType) {
|
||||
ret = append(ret, tool)
|
||||
}
|
||||
}
|
||||
sort.Slice(ret, func(i, j int) bool { return ret[i].Code < ret[j].Code })
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *businessActionToolService) ResolveForCustomerType(code, customerType string) (contract.BusinessActionTool, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
tool, ok := s.tools[strings.TrimSpace(code)]
|
||||
if !ok || !businessActionToolSupportsCustomerType(tool, customerType) {
|
||||
return contract.BusinessActionTool{}, false
|
||||
}
|
||||
return tool, true
|
||||
}
|
||||
|
||||
func (s *businessActionToolService) Resolve(code string) (contract.BusinessActionTool, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
tool, ok := s.tools[strings.TrimSpace(code)]
|
||||
return tool, ok
|
||||
}
|
||||
|
||||
func (s *businessActionToolService) Preview(ctx context.Context, tool contract.BusinessActionTool, businessContext contract.BusinessReadContext, arguments map[string]any) (string, error) {
|
||||
return tool.Preview(ctx, businessContext, arguments)
|
||||
}
|
||||
|
||||
func (s *businessActionToolService) Execute(ctx context.Context, conversationID, aiAgentID int64, idempotencyKey string, tool contract.BusinessActionTool, businessContext contract.BusinessReadContext, arguments map[string]any) (*contract.BusinessActionResult, bool, error) {
|
||||
businessContext.CheckPointID = strings.TrimSpace(idempotencyKey)
|
||||
if tool.AuthorizeConfirmation != nil {
|
||||
if err := tool.AuthorizeConfirmation(ctx, businessContext, arguments, businessContext.CheckPointID); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
claim, err := AgentToolInvocationService.Claim(conversationID, aiAgentID, tool.Code, idempotencyKey)
|
||||
if err != nil {
|
||||
return nil, false, contract.NewBusinessActionError("操作请求记录创建失败,本次操作未执行,请稍后重试。", err)
|
||||
}
|
||||
if claim == nil || claim.Item == nil {
|
||||
err := fmt.Errorf("business action invocation could not be claimed")
|
||||
return nil, false, contract.NewBusinessActionError("操作请求无效,本次操作未执行,请重新发起。", err)
|
||||
}
|
||||
if claim.Completed {
|
||||
result := &contract.BusinessActionResult{}
|
||||
if err := json.Unmarshal([]byte(claim.Item.ResultData), result); err != nil {
|
||||
return nil, true, contract.NewBusinessActionError("操作已完成,但结果读取失败,请勿重复操作并联系人工客服核对。", err)
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
if claim.UnknownOutcome {
|
||||
err := fmt.Errorf("business action outcome requires reconciliation: %s", tool.Code)
|
||||
return nil, true, contract.NewUnknownOutcomeBusinessActionError("上次操作结果尚未确认,请勿重复操作,并联系人工客服核对。", err)
|
||||
}
|
||||
if !claim.Acquired {
|
||||
err := fmt.Errorf("business action is already running: %s", tool.Code)
|
||||
return nil, false, contract.NewBusinessActionError("操作正在处理中,请勿重复提交,请稍后查看结果。", err)
|
||||
}
|
||||
result, err := tool.Execute(ctx, businessContext, arguments)
|
||||
if err != nil {
|
||||
if businessActionFailureIsRetryable(ctx, err) {
|
||||
_ = AgentToolInvocationService.FailRetryable(claim.Item, err)
|
||||
} else {
|
||||
_ = AgentToolInvocationService.MarkUnknownOutcome(claim.Item, err)
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
if result == nil || strings.TrimSpace(result.Message) == "" {
|
||||
err = fmt.Errorf("business action returned an empty result: %s", tool.Code)
|
||||
_ = AgentToolInvocationService.MarkUnknownOutcome(claim.Item, err)
|
||||
return nil, false, contract.NewBusinessActionError("业务系统未返回操作结果,本次操作未完成,请联系人工客服核对。", err)
|
||||
}
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
// The host operation has already completed. Persist at least the customer
|
||||
// message and never mark the invocation retryable, which could execute the
|
||||
// same paid action twice.
|
||||
encoded, _ = json.Marshal(&contract.BusinessActionResult{Message: result.Message})
|
||||
}
|
||||
if err := AgentToolInvocationService.Complete(claim.Item, string(encoded)); err != nil {
|
||||
// The external write may already have committed. Never leave the invocation
|
||||
// eligible for replay; persist an explicit reconciliation state whenever the
|
||||
// result cannot be durably recorded.
|
||||
_ = AgentToolInvocationService.MarkUnknownOutcome(claim.Item, err)
|
||||
return nil, false, contract.NewBusinessActionError("业务操作可能已经成功,但结果记录失败。请勿重复操作,并联系人工客服核对。", err)
|
||||
}
|
||||
return result, false, nil
|
||||
}
|
||||
|
||||
func businessActionToolSupportsCustomerType(tool contract.BusinessActionTool, customerType string) bool {
|
||||
if len(tool.CustomerTypes) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range tool.CustomerTypes {
|
||||
if strings.EqualFold(strings.TrimSpace(candidate), strings.TrimSpace(customerType)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestBusinessActionToolRequiresMatchingCustomerAndReusesConfirmedExecution(t *testing.T) {
|
||||
t.Cleanup(func() { _ = SetBusinessActionTools(nil) })
|
||||
database, 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: %v", err)
|
||||
}
|
||||
if err := database.AutoMigrate(&models.AgentToolInvocation{}); err != nil {
|
||||
t.Fatalf("migrate invocation: %v", err)
|
||||
}
|
||||
sqls.SetDB(database)
|
||||
executions := 0
|
||||
if err := SetBusinessActionTools([]contract.BusinessActionTool{{
|
||||
Code: "business/card_resume", Description: "resume card", CustomerTypes: []string{"card"},
|
||||
Preview: func(context.Context, contract.BusinessReadContext, map[string]any) (string, error) {
|
||||
return "confirm resume", nil
|
||||
},
|
||||
Execute: func(context.Context, contract.BusinessReadContext, map[string]any) (*contract.BusinessActionResult, error) {
|
||||
executions++
|
||||
return &contract.BusinessActionResult{Message: "resumed"}, nil
|
||||
},
|
||||
}}); err != nil {
|
||||
t.Fatalf("register action: %v", err)
|
||||
}
|
||||
tool, ok := BusinessActionToolService.ResolveForCustomerType("business/card_resume", "card")
|
||||
if !ok {
|
||||
t.Fatal("card action was not resolved")
|
||||
}
|
||||
if _, ok := BusinessActionToolService.ResolveForCustomerType("business/card_resume", "mall_user"); ok {
|
||||
t.Fatal("card action leaked to mall user")
|
||||
}
|
||||
ctx := contract.BusinessReadContext{ConversationID: 10, CustomerType: "card", CustomerID: 20}
|
||||
first, reused, err := BusinessActionToolService.Execute(context.Background(), 10, 30, "confirm-1", tool, ctx, nil)
|
||||
if err != nil || reused || first == nil || first.Message != "resumed" {
|
||||
t.Fatalf("first execution = %#v, reused=%t, err=%v", first, reused, err)
|
||||
}
|
||||
second, reused, err := BusinessActionToolService.Execute(context.Background(), 10, 30, "confirm-1", tool, ctx, nil)
|
||||
if err != nil || !reused || second == nil || second.Message != "resumed" || executions != 1 {
|
||||
t.Fatalf("reused execution = %#v, reused=%t, executions=%d, err=%v", second, reused, executions, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessActionToolReauthorizesCurrentConfirmationBeforeIdempotencyClaim(t *testing.T) {
|
||||
authorized := 0
|
||||
executed := 0
|
||||
tool := contract.BusinessActionTool{
|
||||
Code: "business/device_network_switch", Description: "switch network", CustomerTypes: []string{"device"},
|
||||
Preview: func(context.Context, contract.BusinessReadContext, map[string]any) (string, error) {
|
||||
return "confirm", nil
|
||||
},
|
||||
AuthorizeConfirmation: func(_ context.Context, businessContext contract.BusinessReadContext, arguments map[string]any, checkPointID string) error {
|
||||
authorized++
|
||||
if businessContext.RequestMessageID != 202 || businessContext.RequestID != "request-303" ||
|
||||
checkPointID != "checkpoint-404" || arguments["slot"] != "backup" {
|
||||
return errors.New("current confirmation proof does not match")
|
||||
}
|
||||
return errors.New("current confirmation request is not authorized")
|
||||
},
|
||||
Execute: func(context.Context, contract.BusinessReadContext, map[string]any) (*contract.BusinessActionResult, error) {
|
||||
executed++
|
||||
return &contract.BusinessActionResult{Message: "switched"}, nil
|
||||
},
|
||||
}
|
||||
_, reused, err := BusinessActionToolService.Execute(
|
||||
context.Background(), 101, 1, "checkpoint-404", tool,
|
||||
contract.BusinessReadContext{ConversationID: 101, RequestMessageID: 202, RequestID: "request-303"},
|
||||
map[string]any{"slot": "backup"},
|
||||
)
|
||||
if err == nil || reused {
|
||||
t.Fatalf("unauthorized confirmation should fail before claiming: reused=%v err=%v", reused, err)
|
||||
}
|
||||
if authorized != 1 || executed != 0 {
|
||||
t.Fatalf("authorize=%d execute=%d; action must not execute without current confirmation proof", authorized, executed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessActionToolPersistsUnclassifiedFailureAsUnknownOutcome(t *testing.T) {
|
||||
database, 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: %v", err)
|
||||
}
|
||||
if err := database.AutoMigrate(&models.AgentToolInvocation{}); err != nil {
|
||||
t.Fatalf("migrate invocation: %v", err)
|
||||
}
|
||||
sqls.SetDB(database)
|
||||
internalErr := errors.New("upstream rejected package order")
|
||||
tool := contract.BusinessActionTool{
|
||||
Code: "business/card_package_order", Description: "order package", CustomerTypes: []string{"card"},
|
||||
Preview: func(context.Context, contract.BusinessReadContext, map[string]any) (string, error) {
|
||||
return "confirm", nil
|
||||
},
|
||||
Execute: func(context.Context, contract.BusinessReadContext, map[string]any) (*contract.BusinessActionResult, error) {
|
||||
return nil, contract.NewBusinessActionError("套餐已达到购买次数限制", internalErr)
|
||||
},
|
||||
}
|
||||
_, _, err = BusinessActionToolService.Execute(context.Background(), 12, 32, "confirm-failed", tool, contract.BusinessReadContext{}, nil)
|
||||
if err == nil || err.Error() != "套餐已达到购买次数限制" {
|
||||
t.Fatalf("execute err = %v", err)
|
||||
}
|
||||
item := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(database, 12, tool.Code, "confirm-failed")
|
||||
if item == nil || item.Status != agentToolInvocationStatusUnknownOutcome || item.ErrorMessage != internalErr.Error() {
|
||||
t.Fatalf("stored invocation = %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessActionToolDoesNotReplayAfterResponseTimeout(t *testing.T) {
|
||||
database, 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: %v", err)
|
||||
}
|
||||
if err := database.AutoMigrate(&models.AgentToolInvocation{}); err != nil {
|
||||
t.Fatalf("migrate invocation: %v", err)
|
||||
}
|
||||
sqls.SetDB(database)
|
||||
executions := 0
|
||||
tool := contract.BusinessActionTool{
|
||||
Code: "business/order", Description: "create order",
|
||||
Preview: func(context.Context, contract.BusinessReadContext, map[string]any) (string, error) {
|
||||
return "confirm", nil
|
||||
},
|
||||
Execute: func(context.Context, contract.BusinessReadContext, map[string]any) (*contract.BusinessActionResult, error) {
|
||||
executions++
|
||||
return nil, context.DeadlineExceeded
|
||||
},
|
||||
}
|
||||
if _, _, err := BusinessActionToolService.Execute(context.Background(), 40, 50, "confirm-timeout", tool, contract.BusinessReadContext{}, nil); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("first execute err = %v", err)
|
||||
}
|
||||
if _, reused, err := BusinessActionToolService.Execute(context.Background(), 40, 50, "confirm-timeout", tool, contract.BusinessReadContext{}, nil); err == nil || !reused {
|
||||
t.Fatalf("second execute reused=%t err=%v", reused, err)
|
||||
}
|
||||
if executions != 1 {
|
||||
t.Fatalf("host operation executed %d times", executions)
|
||||
}
|
||||
item := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(database, 40, tool.Code, "confirm-timeout")
|
||||
if item == nil || item.Status != agentToolInvocationStatusUnknownOutcome {
|
||||
t.Fatalf("stored invocation = %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessActionToolMarksUnknownWhenCompletionPersistenceFails(t *testing.T) {
|
||||
database, 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: %v", err)
|
||||
}
|
||||
if err := database.AutoMigrate(&models.AgentToolInvocation{}); err != nil {
|
||||
t.Fatalf("migrate invocation: %v", err)
|
||||
}
|
||||
sqls.SetDB(database)
|
||||
var failFirstUpdate atomic.Bool
|
||||
if err := database.Callback().Update().Before("gorm:update").Register("test:fail_completed_persistence", func(tx *gorm.DB) {
|
||||
if !failFirstUpdate.Swap(true) {
|
||||
tx.AddError(errors.New("completion persistence unavailable"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatalf("register update callback: %v", err)
|
||||
}
|
||||
executions := 0
|
||||
tool := contract.BusinessActionTool{
|
||||
Code: "business/provision", Description: "provision service",
|
||||
Preview: func(context.Context, contract.BusinessReadContext, map[string]any) (string, error) {
|
||||
return "confirm", nil
|
||||
},
|
||||
Execute: func(context.Context, contract.BusinessReadContext, map[string]any) (*contract.BusinessActionResult, error) {
|
||||
executions++
|
||||
return &contract.BusinessActionResult{Message: "provisioned"}, nil
|
||||
},
|
||||
}
|
||||
if _, _, err := BusinessActionToolService.Execute(context.Background(), 42, 52, "confirm-persist-failed", tool, contract.BusinessReadContext{}, nil); err == nil {
|
||||
t.Fatal("expected completion persistence failure")
|
||||
}
|
||||
item := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(database, 42, tool.Code, "confirm-persist-failed")
|
||||
if item == nil || item.Status != agentToolInvocationStatusUnknownOutcome {
|
||||
t.Fatalf("stored invocation = %#v", item)
|
||||
}
|
||||
if _, reused, err := BusinessActionToolService.Execute(context.Background(), 42, 52, "confirm-persist-failed", tool, contract.BusinessReadContext{}, nil); err == nil || !reused {
|
||||
t.Fatalf("second execute reused=%t err=%v", reused, err)
|
||||
}
|
||||
if executions != 1 {
|
||||
t.Fatalf("external action replayed %d times", executions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessActionToolRetriesExplicitPreSideEffectFailure(t *testing.T) {
|
||||
database, 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: %v", err)
|
||||
}
|
||||
if err := database.AutoMigrate(&models.AgentToolInvocation{}); err != nil {
|
||||
t.Fatalf("migrate invocation: %v", err)
|
||||
}
|
||||
sqls.SetDB(database)
|
||||
executions := 0
|
||||
tool := contract.BusinessActionTool{
|
||||
Code: "business/cancel_order", Description: "cancel order",
|
||||
Preview: func(context.Context, contract.BusinessReadContext, map[string]any) (string, error) {
|
||||
return "confirm", nil
|
||||
},
|
||||
Execute: func(context.Context, contract.BusinessReadContext, map[string]any) (*contract.BusinessActionResult, error) {
|
||||
executions++
|
||||
if executions == 1 {
|
||||
return nil, contract.NewRetryableBusinessActionError("订单状态暂不可办理", errors.New("precondition changed"))
|
||||
}
|
||||
return &contract.BusinessActionResult{Message: "cancelled"}, nil
|
||||
},
|
||||
}
|
||||
if _, _, err := BusinessActionToolService.Execute(context.Background(), 41, 51, "confirm-retry", tool, contract.BusinessReadContext{}, nil); err == nil {
|
||||
t.Fatal("expected first precondition failure")
|
||||
}
|
||||
result, reused, err := BusinessActionToolService.Execute(context.Background(), 41, 51, "confirm-retry", tool, contract.BusinessReadContext{}, nil)
|
||||
if err != nil || reused || result == nil || result.Message != "cancelled" || executions != 2 {
|
||||
t.Fatalf("retry result=%#v reused=%t executions=%d err=%v", result, reused, executions, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
)
|
||||
|
||||
var BusinessReadToolService = &businessReadToolService{}
|
||||
|
||||
type businessReadToolService struct {
|
||||
mu sync.RWMutex
|
||||
tools map[string]contract.BusinessReadTool
|
||||
}
|
||||
|
||||
func SetBusinessReadTools(tools []contract.BusinessReadTool) error {
|
||||
registered := make(map[string]contract.BusinessReadTool, len(tools))
|
||||
for _, tool := range tools {
|
||||
tool.Code = strings.TrimSpace(tool.Code)
|
||||
tool.Description = strings.TrimSpace(tool.Description)
|
||||
if tool.Code == "" {
|
||||
return fmt.Errorf("ai-agent: business read tool code is required")
|
||||
}
|
||||
if !strings.HasPrefix(tool.Code, "business/") {
|
||||
return fmt.Errorf("ai-agent: business read tool code must start with business/: %s", tool.Code)
|
||||
}
|
||||
if tool.Description == "" {
|
||||
return fmt.Errorf("ai-agent: business read tool description is required: %s", tool.Code)
|
||||
}
|
||||
if tool.Execute == nil {
|
||||
return fmt.Errorf("ai-agent: business read tool executor is required: %s", tool.Code)
|
||||
}
|
||||
if _, exists := registered[tool.Code]; exists {
|
||||
return fmt.Errorf("ai-agent: duplicate business read tool code: %s", tool.Code)
|
||||
}
|
||||
if tool.InputSchema == nil {
|
||||
tool.InputSchema = map[string]any{"type": "object", "properties": map[string]any{}}
|
||||
}
|
||||
registered[tool.Code] = tool
|
||||
}
|
||||
|
||||
BusinessReadToolService.mu.Lock()
|
||||
BusinessReadToolService.tools = registered
|
||||
BusinessReadToolService.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *businessReadToolService) ListForCustomerType(customerType string) []contract.BusinessReadTool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
ret := make([]contract.BusinessReadTool, 0, len(s.tools))
|
||||
for _, tool := range s.tools {
|
||||
if businessReadToolSupportsCustomerType(tool, customerType) {
|
||||
ret = append(ret, tool)
|
||||
}
|
||||
}
|
||||
sort.Slice(ret, func(i, j int) bool { return ret[i].Code < ret[j].Code })
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *businessReadToolService) ResolveForCustomerType(code, customerType string) (contract.BusinessReadTool, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
tool, ok := s.tools[strings.TrimSpace(code)]
|
||||
if !ok || !businessReadToolSupportsCustomerType(tool, customerType) {
|
||||
return contract.BusinessReadTool{}, false
|
||||
}
|
||||
return tool, true
|
||||
}
|
||||
|
||||
func (s *businessReadToolService) Resolve(code string) (contract.BusinessReadTool, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
tool, ok := s.tools[strings.TrimSpace(code)]
|
||||
return tool, ok
|
||||
}
|
||||
|
||||
func (s *businessReadToolService) Execute(
|
||||
ctx context.Context,
|
||||
tool contract.BusinessReadTool,
|
||||
businessContext contract.BusinessReadContext,
|
||||
arguments map[string]any,
|
||||
) (any, error) {
|
||||
if tool.Execute == nil {
|
||||
return nil, fmt.Errorf("business read tool executor is unavailable: %s", tool.Code)
|
||||
}
|
||||
return tool.Execute(ctx, businessContext, arguments)
|
||||
}
|
||||
|
||||
func businessReadToolSupportsCustomerType(tool contract.BusinessReadTool, customerType string) bool {
|
||||
if len(tool.CustomerTypes) == 0 {
|
||||
return true
|
||||
}
|
||||
customerType = strings.TrimSpace(customerType)
|
||||
for _, candidate := range tool.CustomerTypes {
|
||||
if strings.EqualFold(strings.TrimSpace(candidate), customerType) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
aitooling "code.tczkiot.com/wlw/ai-agent/internal/ai/tooling"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
@@ -41,7 +40,7 @@ func newBusinessToolExecutor(registry *aitooling.Registry) *businessToolExecutor
|
||||
return &businessToolExecutor{registry: registry}
|
||||
}
|
||||
|
||||
func (e *businessToolExecutor) Execute(_ context.Context, input BusinessToolInput) (*BusinessToolResult, error) {
|
||||
func (e *businessToolExecutor) Execute(ctx context.Context, input BusinessToolInput) (*BusinessToolResult, error) {
|
||||
toolCode := toolx.NormalizeToolCodeAlias(strings.TrimSpace(input.ToolCode))
|
||||
definition, err := e.registry.Resolve(toolCode)
|
||||
if err != nil {
|
||||
@@ -63,16 +62,27 @@ func (e *businessToolExecutor) Execute(_ context.Context, input BusinessToolInpu
|
||||
if claim.Completed {
|
||||
return &BusinessToolResult{Definition: definition, ResultData: claim.Item.ResultData, Reused: true}, nil
|
||||
}
|
||||
if claim.UnknownOutcome {
|
||||
return nil, fmt.Errorf("business tool outcome requires reconciliation; refusing replay: %s", definition.Code)
|
||||
}
|
||||
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)
|
||||
// Built-in write executors may have committed before returning an error.
|
||||
// Unless the host explicitly marks the failure as pre-side-effect, never
|
||||
// replay the same idempotency key automatically.
|
||||
if businessActionFailureIsRetryable(ctx, err) {
|
||||
_ = AgentToolInvocationService.FailRetryable(claim.Item, err)
|
||||
} else {
|
||||
_ = AgentToolInvocationService.MarkUnknownOutcome(claim.Item, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := AgentToolInvocationService.Complete(claim.Item, resultData); err != nil {
|
||||
_ = AgentToolInvocationService.MarkUnknownOutcome(claim.Item, err)
|
||||
return nil, err
|
||||
}
|
||||
return &BusinessToolResult{Definition: definition, ResultData: resultData}, nil
|
||||
@@ -80,24 +90,12 @@ func (e *businessToolExecutor) Execute(_ context.Context, input BusinessToolInpu
|
||||
|
||||
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})
|
||||
return businessToolJSON(map[string]any{"decision": result.Decision, "team_id": result.TeamID, "assignee_id": result.AssigneeID, "message": result.Message})
|
||||
default:
|
||||
return "", fmt.Errorf("business tool is not executable: %s", toolCode)
|
||||
}
|
||||
@@ -108,36 +106,6 @@ func businessToolString(value any) 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 == "" {
|
||||
|
||||
@@ -96,12 +96,12 @@ func (s *channelMessageOutboxService) EnqueueWxWorkKFMessage(conversation *model
|
||||
}
|
||||
|
||||
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,
|
||||
"conversation_id": conversation.ID,
|
||||
"message_id": message.ID,
|
||||
"message_type": message.MessageType,
|
||||
"content": strings.TrimSpace(message.Content),
|
||||
"payload": strings.TrimSpace(message.Payload),
|
||||
"sender_id": message.SenderID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -131,7 +131,7 @@ func setupChannelServiceTestDB(t *testing.T) *gorm.DB {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&models.AIAgent{}, &models.AgentRevision{}, &models.AIAgentWorkflowBinding{}, &models.Channel{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.AIAgent{}, &models.AgentRevision{}, &models.Channel{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/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,
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -49,7 +50,11 @@ type dispatchPoolReport struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
var errConversationDispatchConflict = errors.New("conversation dispatch conflict")
|
||||
var (
|
||||
errConversationDispatchConflict = errors.New("conversation dispatch conflict")
|
||||
errDispatchCandidateUnavailable = errors.New("dispatch candidate unavailable")
|
||||
dispatchAssignmentMu sync.Mutex
|
||||
)
|
||||
|
||||
const pendingDispatchBatchLimit = 50
|
||||
|
||||
@@ -66,11 +71,17 @@ func (s *conversationDispatchService) DispatchConversation(conversationID int64)
|
||||
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
|
||||
if conversation.CurrentTeamID > 0 {
|
||||
return s.dispatchPendingConversationForTeams(conversation, []int64{conversation.CurrentTeamID}, conversation.AIAgentID)
|
||||
}
|
||||
return s.DispatchPendingConversation(conversation, aiAgent)
|
||||
if conversation.AIAgentID > 0 {
|
||||
aiAgent := AIAgentService.Get(conversation.AIAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return nil, nil
|
||||
}
|
||||
return s.DispatchPendingConversation(conversation, aiAgent)
|
||||
}
|
||||
return s.dispatchPendingConversationForTeams(conversation, s.findAllActiveScheduleTeamIDs(time.Now()), 0)
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) DispatchPendingConversation(conversation *models.Conversation, aiAgent *models.AIAgent) (*models.Conversation, error) {
|
||||
@@ -82,10 +93,20 @@ func (s *conversationDispatchService) DispatchPendingConversation(conversation *
|
||||
}
|
||||
|
||||
teamIDs := utils.SplitInt64s(aiAgent.TeamIDs)
|
||||
if conversation.CurrentTeamID > 0 {
|
||||
teamIDs = []int64{conversation.CurrentTeamID}
|
||||
}
|
||||
return s.dispatchPendingConversationForTeams(conversation, teamIDs, aiAgent.ID)
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) dispatchPendingConversationForTeams(conversation *models.Conversation, teamIDs []int64, aiAgentID int64) (*models.Conversation, error) {
|
||||
if conversation == nil || conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(teamIDs) == 0 {
|
||||
slog.Debug("skip auto dispatch due to empty ai agent team ids",
|
||||
slog.Debug("skip auto dispatch due to empty dispatch team ids",
|
||||
"conversation_id", conversation.ID,
|
||||
"ai_agent_id", aiAgent.ID,
|
||||
"ai_agent_id", aiAgentID,
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
@@ -97,7 +118,7 @@ func (s *conversationDispatchService) DispatchPendingConversation(conversation *
|
||||
if len(candidates) == 0 {
|
||||
slog.Debug("no dispatch candidate available",
|
||||
"conversation_id", conversation.ID,
|
||||
"ai_agent_id", aiAgent.ID,
|
||||
"ai_agent_id", aiAgentID,
|
||||
"requested_team_ids", report.RequestedTeamIDs,
|
||||
"active_schedule_team_ids", report.ActiveScheduleTeams,
|
||||
"matched_profiles", report.MatchedProfiles,
|
||||
@@ -110,6 +131,9 @@ func (s *conversationDispatchService) DispatchPendingConversation(conversation *
|
||||
for _, candidate := range candidates {
|
||||
dispatched, err := s.tryAssignConversation(conversation.ID, candidate.profile, "自动分配")
|
||||
if err != nil {
|
||||
if errors.Is(err, errDispatchCandidateUnavailable) {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, errConversationDispatchConflict) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -118,7 +142,7 @@ func (s *conversationDispatchService) DispatchPendingConversation(conversation *
|
||||
if dispatched != nil {
|
||||
slog.Info("conversation auto dispatched",
|
||||
"conversation_id", dispatched.ID,
|
||||
"ai_agent_id", aiAgent.ID,
|
||||
"ai_agent_id", aiAgentID,
|
||||
"assignee_id", dispatched.CurrentAssigneeID,
|
||||
"team_id", dispatched.CurrentTeamID,
|
||||
"candidate_count", report.CandidateCount,
|
||||
@@ -137,7 +161,7 @@ func (s *conversationDispatchService) DispatchPendingConversation(conversation *
|
||||
}
|
||||
slog.Debug("auto dispatch candidate list exhausted without assignment",
|
||||
"conversation_id", conversation.ID,
|
||||
"ai_agent_id", aiAgent.ID,
|
||||
"ai_agent_id", aiAgentID,
|
||||
"candidate_count", report.CandidateCount,
|
||||
)
|
||||
return nil, nil
|
||||
@@ -155,11 +179,18 @@ func (s *conversationDispatchService) DispatchPendingConversations(limit int) (i
|
||||
conversations := ConversationService.Find(sqls.NewCnd().
|
||||
Eq("status", enums.IMConversationStatusPending).
|
||||
Eq("current_assignee_id", 0).
|
||||
Desc("id"))
|
||||
Asc("id"))
|
||||
if len(conversations) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
ConversationQueueService.Sort(conversations, now)
|
||||
poolIDs := make(map[int64]struct{})
|
||||
for _, conversation := range conversations {
|
||||
poolIDs[conversation.CurrentTeamID] = struct{}{}
|
||||
}
|
||||
|
||||
dispatchedCount := 0
|
||||
scannedCount := 0
|
||||
for i, conversation := range conversations {
|
||||
@@ -182,6 +213,9 @@ func (s *conversationDispatchService) DispatchPendingConversations(limit int) (i
|
||||
"limit", limit,
|
||||
)
|
||||
}
|
||||
for teamID := range poolIDs {
|
||||
ConversationQueueService.PublishPoolUpdates(teamID)
|
||||
}
|
||||
return dispatchedCount, nil
|
||||
}
|
||||
|
||||
@@ -379,6 +413,18 @@ func (s *conversationDispatchService) findActiveScheduleTeamIDs(teamIDs []int64,
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) findAllActiveScheduleTeamIDs(now time.Time) []int64 {
|
||||
if !sqls.DB().Migrator().HasTable(&models.AgentTeam{}) || !sqls.DB().Migrator().HasTable(&models.AgentTeamSchedule{}) {
|
||||
return nil
|
||||
}
|
||||
teams := AgentTeamService.Find(sqls.NewCnd().Eq("status", enums.StatusOk).Asc("id"))
|
||||
teamIDs := make([]int64, 0, len(teams))
|
||||
for _, team := range teams {
|
||||
teamIDs = append(teamIDs, team.ID)
|
||||
}
|
||||
return s.findActiveScheduleTeamIDs(teamIDs, now)
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) findActiveConversationCountMap(userIDs []int64) (map[int64]int, error) {
|
||||
ret := make(map[int64]int, len(userIDs))
|
||||
if len(userIDs) == 0 {
|
||||
@@ -404,8 +450,12 @@ func (s *conversationDispatchService) findActiveConversationCountMap(userIDs []i
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) tryAssignConversation(conversationID int64, candidate models.AgentProfile, reason string) (*models.Conversation, error) {
|
||||
dispatchAssignmentMu.Lock()
|
||||
defer dispatchAssignmentMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
operator := systemDispatchPrincipal()
|
||||
var previousTeamID int64
|
||||
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
||||
@@ -415,6 +465,25 @@ func (s *conversationDispatchService) tryAssignConversation(conversationID int64
|
||||
if conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 {
|
||||
return errConversationDispatchConflict
|
||||
}
|
||||
previousTeamID = conversation.CurrentTeamID
|
||||
|
||||
var currentProfile models.AgentProfile
|
||||
if err := ctx.Tx.Where("id = ?", candidate.ID).First(¤tProfile).Error; err != nil {
|
||||
return errDispatchCandidateUnavailable
|
||||
}
|
||||
if currentProfile.Status != enums.StatusOk || !currentProfile.AutoAssignEnabled || currentProfile.ServiceStatus != enums.ServiceStatusIdle || currentProfile.UserID != candidate.UserID {
|
||||
return errDispatchCandidateUnavailable
|
||||
}
|
||||
var activeCount int64
|
||||
if err := ctx.Tx.Model(&models.Conversation{}).
|
||||
Where("status = ? AND current_assignee_id = ?", enums.IMConversationStatusActive, currentProfile.UserID).
|
||||
Count(&activeCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if currentProfile.MaxConcurrentCount > 0 && activeCount >= int64(currentProfile.MaxConcurrentCount) {
|
||||
return errDispatchCandidateUnavailable
|
||||
}
|
||||
candidate = currentProfile
|
||||
|
||||
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil {
|
||||
return err
|
||||
@@ -445,17 +514,19 @@ func (s *conversationDispatchService) tryAssignConversation(conversationID int64
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ConversationService.Get(conversationID), nil
|
||||
dispatched := ConversationService.Get(conversationID)
|
||||
ConversationQueueService.PublishPoolUpdates(previousTeamID)
|
||||
return dispatched, 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),
|
||||
"from_status": enums.IMConversationStatusPending,
|
||||
"to_status": enums.IMConversationStatusActive,
|
||||
"from_assignee_id": fromAssigneeID,
|
||||
"to_assignee_id": toAssigneeID,
|
||||
"to_team_id": toTeamID,
|
||||
"reason": strings.TrimSpace(reason),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -36,14 +36,14 @@ func TestAIHandoffPublishesFinalAssignedConversationEvent(t *testing.T) {
|
||||
}
|
||||
|
||||
event := findHumanDispatchRealtimeEvent(t, session, enums.IMRealtimeEventConversationAssigned)
|
||||
if event.Data["conversationId"] != float64(conversation.ID) {
|
||||
if event.Data["conversation_id"] != 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"])
|
||||
if event.Data["current_assignee_id"] != float64(101) {
|
||||
t.Fatalf("expected assignee 101 in assigned event, got %+v", event.Data["current_assignee_id"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,16 +65,16 @@ func TestAIHandoffPublishesFinalTeamPoolConversationEvent(t *testing.T) {
|
||||
}
|
||||
|
||||
event := findHumanDispatchRealtimeEvent(t, session, enums.IMRealtimeEventConversationUpdated, func(event humanDispatchRealtimeEvent) bool {
|
||||
return event.Data["currentTeamId"] == float64(1)
|
||||
return event.Data["current_team_id"] == float64(1)
|
||||
})
|
||||
if event.Data["conversationId"] != float64(conversation.ID) {
|
||||
if event.Data["conversation_id"] != 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"])
|
||||
if value, ok := event.Data["current_assignee_id"]; ok && value != float64(0) {
|
||||
t.Fatalf("expected no assignee in updated event, got %+v", event.Data["current_assignee_id"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +144,6 @@ func setupHumanDispatchRealtimeTestDB(t *testing.T) *gorm.DB {
|
||||
})
|
||||
if err := db.AutoMigrate(
|
||||
&models.Notification{},
|
||||
&models.Customer{},
|
||||
&models.CustomerIdentity{},
|
||||
&models.Channel{},
|
||||
&models.AIAgent{},
|
||||
&models.AgentTeam{},
|
||||
|
||||
@@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -123,6 +124,19 @@ func (s *conversationHumanDispatchService) ApplyHumanChannelCreate(conversationI
|
||||
if err := s.sendAIText(conversationID, 0, HandoffWaitingMessage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dispatched, err := ConversationDispatchService.DispatchConversation(conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dispatched != nil {
|
||||
WsService.PublishConversationChanged(dispatched, enums.IMRealtimeEventConversationAssigned)
|
||||
return &HandoffDecisionResult{
|
||||
Decision: HandoffDecisionAssigned,
|
||||
TeamID: dispatched.CurrentTeamID,
|
||||
AssigneeID: dispatched.CurrentAssigneeID,
|
||||
Message: HandoffWaitingMessage,
|
||||
}, nil
|
||||
}
|
||||
return &HandoffDecisionResult{Decision: HandoffDecisionGlobalPool, Message: HandoffWaitingMessage}, nil
|
||||
}
|
||||
|
||||
@@ -142,9 +156,15 @@ func (s *conversationHumanDispatchService) DispatchPendingConversation(conversat
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(candidates) > 0 {
|
||||
dispatched, err := ConversationDispatchService.tryAssignConversation(conversationID, candidates[0].profile, "自动分配")
|
||||
for _, candidate := range candidates {
|
||||
dispatched, err := ConversationDispatchService.tryAssignConversation(conversationID, candidate.profile, "自动分配")
|
||||
if err != nil {
|
||||
if errors.Is(err, errDispatchCandidateUnavailable) {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, errConversationDispatchConflict) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0137")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if dispatched != nil {
|
||||
@@ -180,9 +200,15 @@ func (s *conversationHumanDispatchService) dispatchAfterHandoffWithRequestID(con
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(candidates) > 0 {
|
||||
dispatched, err := ConversationDispatchService.tryAssignConversation(conversationID, candidates[0].profile, "自动分配")
|
||||
for _, candidate := range candidates {
|
||||
dispatched, err := ConversationDispatchService.tryAssignConversation(conversationID, candidate.profile, "自动分配")
|
||||
if err != nil {
|
||||
if errors.Is(err, errDispatchCandidateUnavailable) {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, errConversationDispatchConflict) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0137")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if dispatched != nil {
|
||||
@@ -220,9 +246,14 @@ func (s *conversationHumanDispatchService) markHandoff(conversationID int64, aiA
|
||||
now := time.Now()
|
||||
trimmedReason := strings.TrimSpace(reason)
|
||||
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{
|
||||
"handoff_at": now,
|
||||
"handoff_reason": trimmedReason,
|
||||
"queue_entered_at": queueEnteredAtForTransition(conversation, now),
|
||||
"status": enums.IMConversationStatusPending,
|
||||
"current_team_id": 0,
|
||||
"current_assignee_id": 0,
|
||||
@@ -255,6 +286,7 @@ func (s *conversationHumanDispatchService) moveToTeamPoolWithRequestID(conversat
|
||||
"status": enums.IMConversationStatusPending,
|
||||
"current_team_id": teamID,
|
||||
"current_assignee_id": 0,
|
||||
"queue_entered_at": queueEnteredAtForTransition(current, now),
|
||||
"update_user_id": 0,
|
||||
"update_user_name": "system",
|
||||
"updated_at": now,
|
||||
@@ -262,19 +294,21 @@ func (s *conversationHumanDispatchService) moveToTeamPoolWithRequestID(conversat
|
||||
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),
|
||||
"from_status": current.Status,
|
||||
"to_status": enums.IMConversationStatusPending,
|
||||
"from_assignee_id": current.CurrentAssigneeID,
|
||||
"to_assignee_id": int64(0),
|
||||
"to_team_id": teamID,
|
||||
"reason": strings.TrimSpace(reason),
|
||||
"decision": string(HandoffDecisionTeamPool),
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
current.Status = enums.IMConversationStatusPending
|
||||
current.CurrentTeamID = teamID
|
||||
current.CurrentAssigneeID = 0
|
||||
queueEnteredAt := queueEnteredAtForTransition(current, now)
|
||||
current.QueueEnteredAt = &queueEnteredAt
|
||||
current.UpdateUserID = 0
|
||||
current.UpdateUserName = "system"
|
||||
current.UpdatedAt = now
|
||||
@@ -284,12 +318,13 @@ func (s *conversationHumanDispatchService) moveToTeamPoolWithRequestID(conversat
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ConversationQueueService.PublishPoolUpdates(teamID)
|
||||
return conversation, nil
|
||||
}
|
||||
|
||||
func (s *conversationHumanDispatchService) moveToGlobalPool(conversationID int64, operatorName string) error {
|
||||
now := time.Now()
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
||||
if conversation == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0116")
|
||||
@@ -298,6 +333,7 @@ func (s *conversationHumanDispatchService) moveToGlobalPool(conversationID int64
|
||||
"status": enums.IMConversationStatusPending,
|
||||
"current_team_id": 0,
|
||||
"current_assignee_id": 0,
|
||||
"queue_entered_at": queueEnteredAtForTransition(conversation, now),
|
||||
"update_user_id": 0,
|
||||
"update_user_name": operatorName,
|
||||
"updated_at": now,
|
||||
@@ -305,11 +341,16 @@ func (s *conversationHumanDispatchService) moveToGlobalPool(conversationID int64
|
||||
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),
|
||||
"from_status": conversation.Status,
|
||||
"to_status": enums.IMConversationStatusPending,
|
||||
"decision": string(HandoffDecisionGlobalPool),
|
||||
}))
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ConversationQueueService.PublishPoolUpdates(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *conversationHumanDispatchService) createEvent(conversationID int64, eventType enums.IMEventType, senderType enums.IMSenderType, senderID int64, content, payload string) error {
|
||||
|
||||
@@ -2,6 +2,7 @@ package services_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -181,6 +182,120 @@ func TestConversationAutoAssignManualDispatchFallsBackToTeamPool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationQueueOrdersByEffectivePriorityThenFIFO(t *testing.T) {
|
||||
setupConversationHumanDispatchTestDB(t)
|
||||
now := time.Now().Truncate(time.Second)
|
||||
oldest := now.Add(-6 * time.Minute)
|
||||
middle := now.Add(-2 * time.Minute)
|
||||
newest := now.Add(-time.Minute)
|
||||
queue := []models.Conversation{
|
||||
{ID: 1, Status: enums.IMConversationStatusPending, Priority: 0, QueueEnteredAt: &middle},
|
||||
{ID: 2, Status: enums.IMConversationStatusPending, Priority: 1, QueueEnteredAt: &newest},
|
||||
{ID: 3, Status: enums.IMConversationStatusPending, Priority: 0, QueueEnteredAt: &oldest},
|
||||
}
|
||||
|
||||
services.ConversationQueueService.Sort(queue, now)
|
||||
if queue[0].ID != 3 || queue[1].ID != 2 || queue[2].ID != 1 {
|
||||
t.Fatalf("unexpected queue order: %d, %d, %d", queue[0].ID, queue[1].ID, queue[2].ID)
|
||||
}
|
||||
if level := services.ConversationQueueService.EscalationLevel(&queue[0], now); level != 1 {
|
||||
t.Fatalf("expected timeout escalation level 1, got %d", level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationQueueSnapshotSeparatesTeamPools(t *testing.T) {
|
||||
db := setupConversationHumanDispatchTestDB(t)
|
||||
now := time.Now().Truncate(time.Second)
|
||||
firstEnteredAt := now.Add(-3 * time.Minute)
|
||||
secondEnteredAt := now.Add(-2 * time.Minute)
|
||||
otherPoolEnteredAt := now.Add(-10 * time.Minute)
|
||||
first := models.Conversation{Status: enums.IMConversationStatusPending, CurrentTeamID: 1, QueueEnteredAt: &firstEnteredAt}
|
||||
second := models.Conversation{Status: enums.IMConversationStatusPending, CurrentTeamID: 1, QueueEnteredAt: &secondEnteredAt}
|
||||
otherPool := models.Conversation{Status: enums.IMConversationStatusPending, CurrentTeamID: 2, QueueEnteredAt: &otherPoolEnteredAt}
|
||||
for _, item := range []*models.Conversation{&first, &second, &otherPool} {
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatalf("create queued conversation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
firstSnapshot := services.ConversationQueueService.GetSnapshotAt(&first, now)
|
||||
secondSnapshot := services.ConversationQueueService.GetSnapshotAt(&second, now)
|
||||
otherSnapshot := services.ConversationQueueService.GetSnapshotAt(&otherPool, now)
|
||||
if firstSnapshot.Position != 1 || firstSnapshot.WaitingCount != 2 {
|
||||
t.Fatalf("unexpected first snapshot: %+v", firstSnapshot)
|
||||
}
|
||||
if secondSnapshot.Position != 2 || secondSnapshot.AheadCount != 1 {
|
||||
t.Fatalf("unexpected second snapshot: %+v", secondSnapshot)
|
||||
}
|
||||
if otherSnapshot.Position != 1 || otherSnapshot.WaitingCount != 1 {
|
||||
t.Fatalf("unexpected other-pool snapshot: %+v", otherSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationPureHumanGlobalQueueDispatchesFIFOAndHonorsCapacity(t *testing.T) {
|
||||
db := setupConversationHumanDispatchTestDB(t)
|
||||
createHumanDispatchTeam(t, db, 1, "售后支持组")
|
||||
createHumanDispatchActiveSchedule(t, db, 1)
|
||||
createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 1, true, enums.StatusOk)
|
||||
now := time.Now()
|
||||
olderEnteredAt := now.Add(-2 * time.Minute)
|
||||
newerEnteredAt := now.Add(-time.Minute)
|
||||
older := createHumanDispatchConversation(t, db, 0, enums.IMConversationStatusPending)
|
||||
newer := createHumanDispatchConversation(t, db, 0, enums.IMConversationStatusPending)
|
||||
if err := db.Model(&models.Conversation{}).Where("id = ?", older.ID).Update("queue_entered_at", olderEnteredAt).Error; err != nil {
|
||||
t.Fatalf("set older queue time: %v", err)
|
||||
}
|
||||
if err := db.Model(&models.Conversation{}).Where("id = ?", newer.ID).Update("queue_entered_at", newerEnteredAt).Error; err != nil {
|
||||
t.Fatalf("set newer queue time: %v", err)
|
||||
}
|
||||
|
||||
count, err := services.ConversationDispatchService.DispatchPendingConversations(10)
|
||||
if err != nil {
|
||||
t.Fatalf("DispatchPendingConversations() error = %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected one dispatch at capacity, got %d", count)
|
||||
}
|
||||
olderCurrent := services.ConversationService.Get(older.ID)
|
||||
newerCurrent := services.ConversationService.Get(newer.ID)
|
||||
if olderCurrent.Status != enums.IMConversationStatusActive || olderCurrent.CurrentAssigneeID != 101 {
|
||||
t.Fatalf("expected oldest conversation assigned first, got %+v", olderCurrent)
|
||||
}
|
||||
if newerCurrent.Status != enums.IMConversationStatusPending || newerCurrent.CurrentAssigneeID != 0 {
|
||||
t.Fatalf("expected newer conversation to remain queued, got %+v", newerCurrent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationConcurrentAutoDispatchCreatesSingleAssignment(t *testing.T) {
|
||||
db := setupConversationHumanDispatchTestDB(t)
|
||||
createHumanDispatchTeam(t, db, 1, "售后支持组")
|
||||
createHumanDispatchActiveSchedule(t, db, 1)
|
||||
createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 3, true, enums.StatusOk)
|
||||
conversation := createHumanDispatchConversation(t, db, 0, enums.IMConversationStatusPending)
|
||||
now := time.Now()
|
||||
if err := db.Model(&models.Conversation{}).Where("id = ?", conversation.ID).Update("queue_entered_at", now).Error; err != nil {
|
||||
t.Fatalf("set queue time: %v", err)
|
||||
}
|
||||
|
||||
var waitGroup sync.WaitGroup
|
||||
for range 8 {
|
||||
waitGroup.Add(1)
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
_, _ = services.ConversationDispatchService.DispatchConversation(conversation.ID)
|
||||
}()
|
||||
}
|
||||
waitGroup.Wait()
|
||||
|
||||
var assignmentCount int64
|
||||
if err := db.Model(&models.ConversationAssignment{}).Where("conversation_id = ?", conversation.ID).Count(&assignmentCount).Error; err != nil {
|
||||
t.Fatalf("count assignments: %v", err)
|
||||
}
|
||||
if assignmentCount != 1 {
|
||||
t.Fatalf("expected exactly one assignment, got %d", assignmentCount)
|
||||
}
|
||||
}
|
||||
|
||||
func setupConversationHumanDispatchTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
|
||||
@@ -200,8 +315,6 @@ func setupConversationHumanDispatchTestDB(t *testing.T) *gorm.DB {
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(
|
||||
&models.Customer{},
|
||||
&models.CustomerIdentity{},
|
||||
&models.AIAgent{},
|
||||
&models.AgentTeam{},
|
||||
&models.AgentTeamSchedule{},
|
||||
|
||||
@@ -98,8 +98,6 @@ func (s *conversationInterruptService) mergeForCheckpointUpdate(current, next *m
|
||||
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
|
||||
@@ -125,8 +123,6 @@ func (s *conversationInterruptService) mergeForPendingUpdate(current, next *mode
|
||||
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
|
||||
|
||||
@@ -74,7 +74,7 @@ func (s *conversationParticipantService) CreateCustomerParticipant(ctx *sqls.TxC
|
||||
return repositories.ConversationParticipantRepository.Create(ctx.Tx, &models.ConversationParticipant{
|
||||
ConversationID: conversationID,
|
||||
ParticipantType: string(enums.IMParticipantTypeCustomer),
|
||||
ParticipantID: 0,
|
||||
ParticipantID: externalUser.SubjectID,
|
||||
ExternalParticipantID: externalUser.ExternalID,
|
||||
JoinedAt: new(time.Now()),
|
||||
Status: enums.StatusOk,
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var ConversationQueueService = newConversationQueueService()
|
||||
|
||||
const (
|
||||
queueEscalationInterval = 5 * time.Minute
|
||||
queueEscalationMaxLevel = 6
|
||||
queueAverageHandleTime = 8 * time.Minute
|
||||
)
|
||||
|
||||
type ConversationQueueSnapshot struct {
|
||||
Queued bool
|
||||
EnteredAt *time.Time
|
||||
Position int
|
||||
AheadCount int
|
||||
WaitingCount int
|
||||
WaitSeconds int64
|
||||
EstimatedWaitSeconds int64
|
||||
EscalationLevel int
|
||||
EffectivePriority int
|
||||
ServiceOnline bool
|
||||
}
|
||||
|
||||
type queueSnapshotCacheEntry struct {
|
||||
expiresAt time.Time
|
||||
snapshots map[int64]ConversationQueueSnapshot
|
||||
}
|
||||
|
||||
type conversationQueueService struct {
|
||||
mu sync.Mutex
|
||||
cache map[int64]queueSnapshotCacheEntry
|
||||
}
|
||||
|
||||
func newConversationQueueService() *conversationQueueService {
|
||||
return &conversationQueueService{cache: make(map[int64]queueSnapshotCacheEntry)}
|
||||
}
|
||||
|
||||
func (s *conversationQueueService) GetSnapshot(conversation *models.Conversation) ConversationQueueSnapshot {
|
||||
if conversation == nil || conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 {
|
||||
return ConversationQueueSnapshot{}
|
||||
}
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
entry, found := s.cache[conversation.CurrentTeamID]
|
||||
s.mu.Unlock()
|
||||
if found && now.Before(entry.expiresAt) {
|
||||
return entry.snapshots[conversation.ID]
|
||||
}
|
||||
snapshots := s.buildPoolSnapshotsAt(conversation.CurrentTeamID, now)
|
||||
s.mu.Lock()
|
||||
s.cache[conversation.CurrentTeamID] = queueSnapshotCacheEntry{
|
||||
expiresAt: now.Add(time.Second),
|
||||
snapshots: snapshots,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return snapshots[conversation.ID]
|
||||
}
|
||||
|
||||
func (s *conversationQueueService) GetSnapshotAt(conversation *models.Conversation, now time.Time) ConversationQueueSnapshot {
|
||||
if conversation == nil || conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 {
|
||||
return ConversationQueueSnapshot{}
|
||||
}
|
||||
return s.buildPoolSnapshotsAt(conversation.CurrentTeamID, now)[conversation.ID]
|
||||
}
|
||||
|
||||
func (s *conversationQueueService) buildPoolSnapshotsAt(teamID int64, now time.Time) map[int64]ConversationQueueSnapshot {
|
||||
queue := s.findPoolQueue(teamID)
|
||||
s.Sort(queue, now)
|
||||
capacity, freeSlots := s.poolCapacity(teamID, now)
|
||||
snapshots := make(map[int64]ConversationQueueSnapshot, len(queue))
|
||||
for index := range queue {
|
||||
conversation := &queue[index]
|
||||
snapshot := ConversationQueueSnapshot{
|
||||
Queued: true,
|
||||
EnteredAt: queueEnteredAt(conversation),
|
||||
Position: index + 1,
|
||||
AheadCount: index,
|
||||
WaitingCount: len(queue),
|
||||
EffectivePriority: s.EffectivePriority(conversation, now),
|
||||
EscalationLevel: s.EscalationLevel(conversation, now),
|
||||
ServiceOnline: capacity > 0,
|
||||
}
|
||||
if snapshot.EnteredAt != nil && now.After(*snapshot.EnteredAt) {
|
||||
snapshot.WaitSeconds = int64(now.Sub(*snapshot.EnteredAt) / time.Second)
|
||||
}
|
||||
if capacity > 0 {
|
||||
remainingBeforeService := snapshot.AheadCount - freeSlots
|
||||
if remainingBeforeService >= 0 {
|
||||
waves := int64(math.Ceil(float64(remainingBeforeService+1) / float64(capacity)))
|
||||
snapshot.EstimatedWaitSeconds = waves * int64(queueAverageHandleTime/time.Second)
|
||||
}
|
||||
}
|
||||
snapshots[conversation.ID] = snapshot
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
func (s *conversationQueueService) Sort(conversations []models.Conversation, now time.Time) {
|
||||
slices.SortFunc(conversations, func(a, b models.Conversation) int {
|
||||
aPriority := s.EffectivePriority(&a, now)
|
||||
bPriority := s.EffectivePriority(&b, now)
|
||||
switch {
|
||||
case aPriority > bPriority:
|
||||
return -1
|
||||
case aPriority < bPriority:
|
||||
return 1
|
||||
}
|
||||
|
||||
aEnteredAt := queueEnteredAtValue(&a)
|
||||
bEnteredAt := queueEnteredAtValue(&b)
|
||||
switch {
|
||||
case aEnteredAt.Before(bEnteredAt):
|
||||
return -1
|
||||
case aEnteredAt.After(bEnteredAt):
|
||||
return 1
|
||||
case a.ID < b.ID:
|
||||
return -1
|
||||
case a.ID > b.ID:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *conversationQueueService) EffectivePriority(conversation *models.Conversation, now time.Time) int {
|
||||
if conversation == nil {
|
||||
return 0
|
||||
}
|
||||
return conversation.Priority + s.EscalationLevel(conversation, now)
|
||||
}
|
||||
|
||||
func (s *conversationQueueService) EscalationLevel(conversation *models.Conversation, now time.Time) int {
|
||||
enteredAt := queueEnteredAt(conversation)
|
||||
if enteredAt == nil || !now.After(*enteredAt) {
|
||||
return 0
|
||||
}
|
||||
level := int(now.Sub(*enteredAt) / queueEscalationInterval)
|
||||
if level > queueEscalationMaxLevel {
|
||||
return queueEscalationMaxLevel
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
func (s *conversationQueueService) PublishPoolUpdates(teamID int64) {
|
||||
s.mu.Lock()
|
||||
delete(s.cache, teamID)
|
||||
s.mu.Unlock()
|
||||
queue := s.findPoolQueue(teamID)
|
||||
for index := range queue {
|
||||
conversation := queue[index]
|
||||
WsService.PublishConversationChanged(&conversation, enums.IMRealtimeEventConversationQueueUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *conversationQueueService) findPoolQueue(teamID int64) []models.Conversation {
|
||||
return ConversationService.Find(sqls.NewCnd().
|
||||
Eq("status", enums.IMConversationStatusPending).
|
||||
Eq("current_assignee_id", 0).
|
||||
Eq("current_team_id", teamID).
|
||||
Asc("id"))
|
||||
}
|
||||
|
||||
func (s *conversationQueueService) poolCapacity(teamID int64, now time.Time) (int, int) {
|
||||
if !sqls.DB().Migrator().HasTable(&models.AgentTeam{}) ||
|
||||
!sqls.DB().Migrator().HasTable(&models.AgentTeamSchedule{}) ||
|
||||
!sqls.DB().Migrator().HasTable(&models.AgentProfile{}) {
|
||||
return 0, 0
|
||||
}
|
||||
teamIDs := []int64{teamID}
|
||||
if teamID <= 0 {
|
||||
teamIDs = ConversationDispatchService.findAllActiveScheduleTeamIDs(now)
|
||||
}
|
||||
activeTeamIDs := ConversationDispatchService.findActiveScheduleTeamIDs(teamIDs, now)
|
||||
if len(activeTeamIDs) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
profiles := AgentProfileService.GetDispatchAgents(activeTeamIDs)
|
||||
profiles, userIDs, _ := ConversationDispatchService.filterEnabledDispatchProfiles(profiles)
|
||||
if len(profiles) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
activeCounts, err := ConversationDispatchService.findActiveConversationCountMap(userIDs)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
totalCapacity := 0
|
||||
freeSlots := 0
|
||||
for _, profile := range profiles {
|
||||
capacity := profile.MaxConcurrentCount
|
||||
if capacity <= 0 {
|
||||
capacity = 1
|
||||
}
|
||||
totalCapacity += capacity
|
||||
available := capacity - activeCounts[profile.UserID]
|
||||
if available > 0 {
|
||||
freeSlots += available
|
||||
}
|
||||
}
|
||||
return totalCapacity, freeSlots
|
||||
}
|
||||
|
||||
func queueEnteredAt(conversation *models.Conversation) *time.Time {
|
||||
if conversation == nil {
|
||||
return nil
|
||||
}
|
||||
if conversation.QueueEnteredAt != nil {
|
||||
return conversation.QueueEnteredAt
|
||||
}
|
||||
if conversation.HandoffAt != nil {
|
||||
return conversation.HandoffAt
|
||||
}
|
||||
if !conversation.CreatedAt.IsZero() {
|
||||
return &conversation.CreatedAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func queueEnteredAtValue(conversation *models.Conversation) time.Time {
|
||||
if enteredAt := queueEnteredAt(conversation); enteredAt != nil {
|
||||
return *enteredAt
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func queueEnteredAtForTransition(conversation *models.Conversation, now time.Time) time.Time {
|
||||
if conversation != nil && conversation.Status == enums.IMConversationStatusPending && conversation.QueueEnteredAt != nil {
|
||||
return *conversation.QueueEnteredAt
|
||||
}
|
||||
return now
|
||||
}
|
||||
@@ -84,12 +84,15 @@ func (s *conversationService) Updates(id int64, columns map[string]interface{})
|
||||
return repositories.ConversationRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *conversationService) getLatestNotFinishedByCustomerID(db *gorm.DB, customerID int64) *models.Conversation {
|
||||
if customerID <= 0 {
|
||||
func (s *conversationService) getLatestNotFinishedByExternalUser(db *gorm.DB, externalUser openidentity.ExternalUser, channelID int64) *models.Conversation {
|
||||
externalID := strings.TrimSpace(externalUser.ExternalID)
|
||||
if externalID == "" || channelID <= 0 {
|
||||
return nil
|
||||
}
|
||||
cnd := sqls.NewCnd()
|
||||
cnd.Eq("customer_id", customerID)
|
||||
cnd.Eq("channel_id", channelID)
|
||||
cnd.Eq("customer_type", externalCustomerType(externalUser))
|
||||
cnd.Eq("customer_external_id", externalID)
|
||||
cnd.In("status", []enums.IMConversationStatus{
|
||||
enums.IMConversationStatusAIServing,
|
||||
enums.IMConversationStatusPending,
|
||||
@@ -113,40 +116,67 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha
|
||||
var conversation *models.Conversation
|
||||
var welcomeMessage *models.Message
|
||||
created := false
|
||||
reconfigured := false
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
customerID, err := CustomerService.EnsureExternalCustomer(ctx, externalUser)
|
||||
if err != nil {
|
||||
return err
|
||||
customerType := externalCustomerType(externalUser)
|
||||
customerID := externalUser.SubjectID
|
||||
customerName := strings.TrimSpace(externalUser.ExternalName)
|
||||
existing := s.getLatestNotFinishedByExternalUser(ctx.Tx, externalUser, channelID)
|
||||
// A conversation already being handled by a human keeps its original
|
||||
// service contract. If the channel was switched to another Agent/mode,
|
||||
// start a new conversation with the latest config instead of silently
|
||||
// reusing the stale human conversation.
|
||||
if existing != nil && (existing.CurrentAssigneeID > 0 || existing.HandoffAt != nil) &&
|
||||
(existing.AIAgentID != aiAgentID || existing.ServiceMode != serviceMode) {
|
||||
existing = nil
|
||||
}
|
||||
customerName := s.getCustomerName(ctx.Tx, customerID)
|
||||
if existing := s.getLatestNotFinishedByCustomerID(ctx.Tx, customerID); existing != nil {
|
||||
if existing != nil {
|
||||
conversation = existing
|
||||
updates := make(map[string]any)
|
||||
if customerName != "" && existing.CustomerName != customerName {
|
||||
if err := repositories.ConversationRepository.Updates(ctx.Tx, existing.ID, map[string]any{
|
||||
"customer_name": customerName,
|
||||
"updated_at": time.Now(),
|
||||
}); err != nil {
|
||||
updates["customer_name"] = customerName
|
||||
conversation.CustomerName = customerName
|
||||
}
|
||||
// A channel binding may change after a conversation was created. Keep an
|
||||
// unassigned conversation aligned with the latest channel/Agent config,
|
||||
// while never taking a conversation away from a human or a handoff flow.
|
||||
if existing.CurrentAssigneeID == 0 && existing.HandoffAt == nil &&
|
||||
(existing.ChannelID != channelID || existing.AIAgentID != aiAgentID || existing.ServiceMode != serviceMode) {
|
||||
updates["channel_id"] = channelID
|
||||
updates["ai_agent_id"] = aiAgentID
|
||||
updates["service_mode"] = serviceMode
|
||||
updates["status"] = s.resolveInitialStatus(serviceMode)
|
||||
conversation.ChannelID = channelID
|
||||
conversation.AIAgentID = aiAgentID
|
||||
conversation.ServiceMode = serviceMode
|
||||
conversation.Status = s.resolveInitialStatus(serviceMode)
|
||||
reconfigured = true
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
updates["updated_at"] = time.Now()
|
||||
if err := repositories.ConversationRepository.Updates(ctx.Tx, existing.ID, updates); err != nil {
|
||||
return err
|
||||
}
|
||||
conversation.CustomerName = customerName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
created = true
|
||||
now := time.Now()
|
||||
conversation = &models.Conversation{
|
||||
AIAgentID: aiAgentID,
|
||||
ChannelID: channelID,
|
||||
CustomerID: customerID,
|
||||
CustomerName: customerName,
|
||||
Status: s.resolveInitialStatus(serviceMode),
|
||||
ServiceMode: serviceMode,
|
||||
Priority: 0,
|
||||
CurrentAssigneeID: 0,
|
||||
CurrentTeamID: 0,
|
||||
LastMessageAt: now,
|
||||
LastActiveAt: now,
|
||||
AuditFields: utils.BuildAuditFields(nil),
|
||||
AIAgentID: aiAgentID,
|
||||
ChannelID: channelID,
|
||||
CustomerType: customerType,
|
||||
CustomerID: customerID,
|
||||
CustomerExternalID: strings.TrimSpace(externalUser.ExternalID),
|
||||
CustomerName: customerName,
|
||||
Status: s.resolveInitialStatus(serviceMode),
|
||||
ServiceMode: serviceMode,
|
||||
Priority: 0,
|
||||
CurrentAssigneeID: 0,
|
||||
CurrentTeamID: 0,
|
||||
LastMessageAt: now,
|
||||
LastActiveAt: now,
|
||||
AuditFields: utils.BuildAuditFields(nil),
|
||||
}
|
||||
if err := ctx.Tx.Create(conversation).Error; err != nil {
|
||||
return err
|
||||
@@ -158,8 +188,9 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha
|
||||
return err
|
||||
}
|
||||
if aiAgent != nil {
|
||||
welcomeMessage, err = MessageService.createAIWelcomeMessage(ctx, conversation, aiAgent, now)
|
||||
return err
|
||||
var welcomeErr error
|
||||
welcomeMessage, welcomeErr = MessageService.createAIWelcomeMessage(ctx, conversation, aiAgent, now)
|
||||
return welcomeErr
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -169,7 +200,10 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha
|
||||
return nil, errorsx.BusinessErrorI18n(1, "error.conversation.createFailed")
|
||||
}
|
||||
if !created {
|
||||
return conversation, nil
|
||||
if reconfigured {
|
||||
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationUpdated)
|
||||
}
|
||||
return s.Get(conversation.ID), nil
|
||||
}
|
||||
|
||||
// 推送会话创建事件
|
||||
@@ -204,6 +238,7 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
|
||||
return errorsx.InvalidParamI18n("error.e0276")
|
||||
}
|
||||
var assignedEvent events.ConversationAssignedEvent
|
||||
var previousTeamID int64
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
conversation := repositories.ConversationRepository.Get(ctx.Tx, req.ConversationID)
|
||||
if conversation == nil {
|
||||
@@ -212,6 +247,7 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
|
||||
if conversation.Status != enums.IMConversationStatusPending {
|
||||
return errorsx.InvalidParamI18n("error.e0135")
|
||||
}
|
||||
previousTeamID = conversation.CurrentTeamID
|
||||
now := time.Now()
|
||||
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, req.ConversationID, now); err != nil {
|
||||
return err
|
||||
@@ -221,6 +257,7 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
|
||||
}
|
||||
if err := repositories.ConversationRepository.Updates(ctx.Tx, req.ConversationID, map[string]any{
|
||||
"current_assignee_id": req.AssigneeID,
|
||||
"current_team_id": targetProfile.TeamID,
|
||||
"status": enums.IMConversationStatusActive,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
@@ -229,11 +266,12 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
|
||||
return err
|
||||
}
|
||||
if err := ConversationEventLogService.CreateEvent(ctx, req.ConversationID, enums.IMEventTypeAssign, enums.IMSenderTypeAgent, operator.UserID, "会话已分配", s.buildEventPayload(map[string]any{
|
||||
"fromStatus": conversation.Status,
|
||||
"toStatus": enums.IMConversationStatusActive,
|
||||
"fromAssigneeId": conversation.CurrentAssigneeID,
|
||||
"toAssigneeId": req.AssigneeID,
|
||||
"reason": strings.TrimSpace(req.Reason),
|
||||
"from_status": conversation.Status,
|
||||
"to_status": enums.IMConversationStatusActive,
|
||||
"from_assignee_id": conversation.CurrentAssigneeID,
|
||||
"to_assignee_id": req.AssigneeID,
|
||||
"to_team_id": targetProfile.TeamID,
|
||||
"reason": strings.TrimSpace(req.Reason),
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -252,6 +290,7 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
|
||||
if conversation := s.Get(req.ConversationID); conversation != nil {
|
||||
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationAssigned)
|
||||
}
|
||||
ConversationQueueService.PublishPoolUpdates(previousTeamID)
|
||||
eventbus.PublishAsync(context.Background(), assignedEvent)
|
||||
return nil
|
||||
}
|
||||
@@ -272,15 +311,25 @@ func (s *conversationService) AutoAssignConversation(conversationID int64, opera
|
||||
return errorsx.InvalidParamI18n("error.e0190")
|
||||
}
|
||||
|
||||
aiAgent := AIAgentService.Get(conversation.AIAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParamI18n("error.e0003")
|
||||
if conversation.AIAgentID > 0 {
|
||||
aiAgent := AIAgentService.Get(conversation.AIAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParamI18n("error.e0003")
|
||||
}
|
||||
result, err := ConversationHumanDispatchService.DispatchPendingConversation(conversationID, *aiAgent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result == nil || result.Decision == HandoffDecisionOffHours {
|
||||
return errorsx.InvalidParamI18n("error.e0194")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
result, err := ConversationHumanDispatchService.DispatchPendingConversation(conversationID, *aiAgent)
|
||||
result, err := ConversationDispatchService.DispatchConversation(conversationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result == nil || result.Decision == HandoffDecisionOffHours {
|
||||
if result == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0194")
|
||||
}
|
||||
return nil
|
||||
@@ -332,11 +381,11 @@ func (s *conversationService) TransferConversation(conversationID, toUserID int6
|
||||
return err
|
||||
}
|
||||
if err := ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAgent, operator.UserID, "会话已转接", s.buildEventPayload(map[string]any{
|
||||
"fromStatus": conversation.Status,
|
||||
"toStatus": enums.IMConversationStatusActive,
|
||||
"fromAssigneeId": conversation.CurrentAssigneeID,
|
||||
"toAssigneeId": toUserID,
|
||||
"reason": strings.TrimSpace(reason),
|
||||
"from_status": conversation.Status,
|
||||
"to_status": enums.IMConversationStatusActive,
|
||||
"from_assignee_id": conversation.CurrentAssigneeID,
|
||||
"to_assignee_id": toUserID,
|
||||
"reason": strings.TrimSpace(reason),
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -416,6 +465,8 @@ func (s *conversationService) CloseCustomerConversation(conversationID int64, ex
|
||||
}
|
||||
|
||||
func (s *conversationService) closeConversation(conversationID int64, senderType enums.IMSenderType, closeReason string, operator *dto.AuthPrincipal) error {
|
||||
var queuedTeamID int64
|
||||
var wasQueued bool
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
||||
if conversation == nil {
|
||||
@@ -429,6 +480,8 @@ func (s *conversationService) closeConversation(conversationID int64, senderType
|
||||
conversation.Status != enums.IMConversationStatusActive {
|
||||
return errorsx.InvalidParamI18n("error.e0197")
|
||||
}
|
||||
wasQueued = conversation.Status == enums.IMConversationStatusPending && conversation.CurrentAssigneeID == 0
|
||||
queuedTeamID = conversation.CurrentTeamID
|
||||
var (
|
||||
now = time.Now()
|
||||
eventDesc = "会话已关闭"
|
||||
@@ -466,11 +519,11 @@ func (s *conversationService) closeConversation(conversationID int64, senderType
|
||||
return err
|
||||
}
|
||||
return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeClose, senderType, operatorID, eventDesc, s.buildEventPayload(map[string]any{
|
||||
"fromStatus": conversation.Status,
|
||||
"toStatus": enums.IMConversationStatusClosed,
|
||||
"fromAssigneeId": conversation.CurrentAssigneeID,
|
||||
"toAssigneeId": conversation.CurrentAssigneeID,
|
||||
"closeReason": closeReason,
|
||||
"from_status": conversation.Status,
|
||||
"to_status": enums.IMConversationStatusClosed,
|
||||
"from_assignee_id": conversation.CurrentAssigneeID,
|
||||
"to_assignee_id": conversation.CurrentAssigneeID,
|
||||
"close_reason": closeReason,
|
||||
}))
|
||||
}); err != nil {
|
||||
return err
|
||||
@@ -478,6 +531,9 @@ func (s *conversationService) closeConversation(conversationID int64, senderType
|
||||
if conversation := s.Get(conversationID); conversation != nil {
|
||||
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationClosed)
|
||||
}
|
||||
if wasQueued {
|
||||
ConversationQueueService.PublishPoolUpdates(queuedTeamID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -681,14 +737,11 @@ func (s *conversationService) IsCustomerConversationOwner(conversation *models.C
|
||||
return false
|
||||
}
|
||||
extID := strings.TrimSpace(externalUser.ExternalID)
|
||||
if extID == "" || strings.TrimSpace(string(externalUser.ExternalSource)) == "" || conversation.CustomerID <= 0 {
|
||||
if extID == "" || strings.TrimSpace(string(externalUser.ExternalSource)) == "" {
|
||||
return false
|
||||
}
|
||||
identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), externalUser.ExternalSource, extID)
|
||||
if identity == nil {
|
||||
return false
|
||||
}
|
||||
return identity.CustomerID == conversation.CustomerID
|
||||
return conversation.CustomerType == externalCustomerType(externalUser) &&
|
||||
strings.TrimSpace(conversation.CustomerExternalID) == extID
|
||||
}
|
||||
|
||||
func (s *conversationService) BuildConversationSummary(conversation *models.Conversation) string {
|
||||
@@ -701,14 +754,11 @@ func (s *conversationService) BuildConversationSummary(conversation *models.Conv
|
||||
return strings.TrimSpace(conversation.CustomerName)
|
||||
}
|
||||
|
||||
func (s *conversationService) getCustomerName(db *gorm.DB, customerID int64) string {
|
||||
if customerID <= 0 {
|
||||
return ""
|
||||
func externalCustomerType(externalUser openidentity.ExternalUser) string {
|
||||
if externalUser.SubjectType != "" {
|
||||
return string(externalUser.SubjectType)
|
||||
}
|
||||
if customer := repositories.CustomerRepository.Get(db, customerID); customer != nil {
|
||||
return strings.TrimSpace(customer.Name)
|
||||
}
|
||||
return ""
|
||||
return string(externalUser.ExternalSource)
|
||||
}
|
||||
|
||||
func (s *conversationService) canCloseConversation(conversation *models.Conversation, operator *dto.AuthPrincipal) bool {
|
||||
@@ -751,101 +801,23 @@ func (s *conversationService) buildEventPayload(payload map[string]any) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// LinkConversationCustomer 将会话绑定到指定客户。
|
||||
func (s *conversationService) LinkConversationCustomer(conversationID, customerID int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
if conversationID <= 0 || customerID <= 0 {
|
||||
return errorsx.InvalidParamI18n("error.e0133")
|
||||
}
|
||||
cust := CustomerService.Get(customerID)
|
||||
if cust == nil || cust.Status == enums.StatusDeleted {
|
||||
return errorsx.InvalidParamI18n("error.e0155")
|
||||
}
|
||||
conv := s.Get(conversationID)
|
||||
if conv == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0116")
|
||||
}
|
||||
if conv.Status == enums.IMConversationStatusClosed {
|
||||
return errorsx.InvalidParamI18n("error.e0183")
|
||||
}
|
||||
if !s.canLinkConversationCustomer(conv, operator) {
|
||||
return errorsx.ForbiddenI18n("error.e0224")
|
||||
}
|
||||
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
current := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0116")
|
||||
}
|
||||
now := time.Now()
|
||||
return repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{
|
||||
"customer_id": customerID,
|
||||
"customer_name": strings.TrimSpace(cust.Name),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated := s.Get(conversationID); updated != nil {
|
||||
WsService.PublishConversationChanged(updated, enums.IMRealtimeEventConversationUpdated)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *conversationService) GetConversationExternalIdentity(conversation *models.Conversation) *models.CustomerIdentity {
|
||||
if conversation == nil || conversation.CustomerID <= 0 {
|
||||
func (s *conversationService) GetConversationExternalIdentity(conversation *models.Conversation) *openidentity.ExternalUser {
|
||||
if conversation == nil || strings.TrimSpace(conversation.CustomerExternalID) == "" {
|
||||
return nil
|
||||
}
|
||||
identities := repositories.CustomerIdentityRepository.FindByCustomerID(sqls.DB(), conversation.CustomerID)
|
||||
if len(identities) == 0 {
|
||||
return nil
|
||||
external := &openidentity.ExternalUser{
|
||||
ExternalID: strings.TrimSpace(conversation.CustomerExternalID),
|
||||
ExternalName: strings.TrimSpace(conversation.CustomerName),
|
||||
SubjectID: conversation.CustomerID,
|
||||
}
|
||||
if channel := ChannelService.Get(conversation.ChannelID); channel != nil {
|
||||
expected := externalSourceForChannelType(channel.ChannelType)
|
||||
if strings.TrimSpace(string(expected)) != "" {
|
||||
for i := range identities {
|
||||
if identities[i].ExternalSource == expected {
|
||||
return &identities[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return &identities[0]
|
||||
}
|
||||
|
||||
func externalSourceForChannelType(channelType string) enums.ExternalSource {
|
||||
switch strings.TrimSpace(channelType) {
|
||||
case enums.ChannelTypeWxWorkKF:
|
||||
return enums.ExternalSourceWxWorkKF
|
||||
case enums.ChannelTypeWeb:
|
||||
return enums.ExternalSourceGuest
|
||||
switch conversation.CustomerType {
|
||||
case string(identity.SubjectCard), string(identity.SubjectDevice), string(identity.SubjectMallUser):
|
||||
external.ExternalSource = enums.ExternalSourceUser
|
||||
external.SubjectType = identity.SubjectType(conversation.CustomerType)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *conversationService) canLinkConversationCustomer(conv *models.Conversation, operator *dto.AuthPrincipal) bool {
|
||||
if conv == nil || operator == nil {
|
||||
return false
|
||||
}
|
||||
if s.isAdmin(operator) {
|
||||
return true
|
||||
}
|
||||
switch conv.Status {
|
||||
case enums.IMConversationStatusAIServing:
|
||||
return true
|
||||
case enums.IMConversationStatusPending:
|
||||
return true
|
||||
case enums.IMConversationStatusActive:
|
||||
return conv.CurrentAssigneeID == 0 || conv.CurrentAssigneeID == operator.UserID
|
||||
default:
|
||||
return false
|
||||
external.ExternalSource = enums.ExternalSource(conversation.CustomerType)
|
||||
}
|
||||
return external
|
||||
}
|
||||
|
||||
func (s *conversationService) resolveInitialStatus(serviceMode enums.IMConversationServiceMode) enums.IMConversationStatus {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/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
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
)
|
||||
|
||||
const maxConversationVisionImageBytes int64 = 5 << 20
|
||||
|
||||
// ConversationVisionImage contains only server-resolved, inline image data.
|
||||
// It never trusts or forwards the URL/provider/storage key from message JSON.
|
||||
type ConversationVisionImage struct {
|
||||
AssetID string
|
||||
Filename string
|
||||
MIMEType string
|
||||
Base64Data string
|
||||
FileSize int64
|
||||
}
|
||||
|
||||
// LoadConversationVisionImages resolves explicitly supplied customer image
|
||||
// messages. Callers must pass only the current message (or a future explicitly
|
||||
// authorized quote); this service never queries conversation history itself.
|
||||
// Every asset is checked against the conversation before private storage is
|
||||
// opened. Invalid, deleted, oversized, or malformed images are skipped so a
|
||||
// text-only model reply can still proceed.
|
||||
func (s *assetService) LoadConversationVisionImages(conversationID int64, messages []models.Message, limit int) []ConversationVisionImage {
|
||||
if conversationID <= 0 || limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
if limit > 9 {
|
||||
limit = 9
|
||||
}
|
||||
images := make([]ConversationVisionImage, 0, limit)
|
||||
seenAssets := make(map[string]struct{}, limit)
|
||||
for _, message := range messages {
|
||||
if message.ConversationID != conversationID || message.SenderType != enums.IMSenderTypeCustomer || message.MessageType != enums.IMMessageTypeImage || message.RecalledAt != nil || message.SendStatus == enums.IMMessageStatusRecalled {
|
||||
continue
|
||||
}
|
||||
messageImages := s.loadConversationVisionImagesFromMessage(conversationID, message)
|
||||
for _, image := range messageImages {
|
||||
if _, exists := seenAssets[image.AssetID]; exists {
|
||||
continue
|
||||
}
|
||||
seenAssets[image.AssetID] = struct{}{}
|
||||
images = append(images, image)
|
||||
}
|
||||
}
|
||||
if len(images) > limit {
|
||||
images = images[len(images)-limit:]
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
func (s *assetService) loadConversationVisionImagesFromMessage(conversationID int64, message models.Message) []ConversationVisionImage {
|
||||
payload, err := parseIMMessageAssetPayload(message.Payload)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
images := make([]ConversationVisionImage, 0, len(payload.items()))
|
||||
for _, item := range payload.items() {
|
||||
image, err := s.loadConversationVisionAsset(conversationID, item.AssetID)
|
||||
if err == nil && image != nil {
|
||||
images = append(images, *image)
|
||||
}
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
func (s *assetService) loadConversationVisionAsset(conversationID int64, assetID string) (*ConversationVisionImage, error) {
|
||||
asset := s.GetByAssetID(assetID)
|
||||
if err := validateConversationAsset(asset, conversationID, enums.IMMessageTypeImage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if asset.FileSize <= 0 || asset.FileSize > maxConversationVisionImageBytes {
|
||||
return nil, fmt.Errorf("conversation image size is outside the model input limit")
|
||||
}
|
||||
reader, err := s.OpenReader(asset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(reader, maxConversationVisionImageBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > maxConversationVisionImageBytes {
|
||||
return nil, fmt.Errorf("conversation image exceeds the model input limit")
|
||||
}
|
||||
mimeType := strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])
|
||||
if !isSupportedVisionImageMIME(mimeType) {
|
||||
return nil, fmt.Errorf("conversation asset is not a supported image")
|
||||
}
|
||||
return &ConversationVisionImage{
|
||||
AssetID: asset.AssetID,
|
||||
Filename: strings.TrimSpace(asset.Filename),
|
||||
MIMEType: mimeType,
|
||||
Base64Data: base64.StdEncoding.EncodeToString(data),
|
||||
FileSize: int64(len(data)),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/services/storage"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func setupConversationVisionAssetTest(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
database, 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: %v", err)
|
||||
}
|
||||
if err := database.AutoMigrate(&models.Asset{}); err != nil {
|
||||
t.Fatalf("migrate asset: %v", err)
|
||||
}
|
||||
sqls.SetDB(database)
|
||||
config.SetCurrent(&config.Config{Storage: config.StorageConfig{
|
||||
Default: enums.AssetProviderLocal, MaxUploadSizeMB: 20,
|
||||
Local: config.LocalStorageConfig{Root: t.TempDir(), BaseURL: "/storage"},
|
||||
}})
|
||||
storage.SetHostStorage(nil)
|
||||
t.Cleanup(func() { storage.SetHostStorage(nil) })
|
||||
return database
|
||||
}
|
||||
|
||||
func TestConversationVisionImagesAreInlineAndConversationScoped(t *testing.T) {
|
||||
setupConversationVisionAssetTest(t)
|
||||
png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 520)...)
|
||||
asset, err := AssetService.UploadConversationBytes(png, "images", "device.png", 11, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("upload conversation image: %v", err)
|
||||
}
|
||||
if asset.ConversationID != 11 || asset.MimeType != "image/png" {
|
||||
t.Fatalf("unexpected stored asset: %#v", asset)
|
||||
}
|
||||
payload := fmt.Sprintf(`{"asset_id":%q,"url":"https://attacker.invalid/ssrf.png","provider":"oss","storage_key":"other/customer.png"}`, asset.AssetID)
|
||||
message := models.Message{
|
||||
ID: 9, ConversationID: 11, SenderType: enums.IMSenderTypeCustomer,
|
||||
MessageType: enums.IMMessageTypeImage, Payload: payload,
|
||||
SendStatus: enums.IMMessageStatusSent,
|
||||
}
|
||||
images := AssetService.LoadConversationVisionImages(11, []models.Message{message}, 3)
|
||||
if len(images) != 1 {
|
||||
t.Fatalf("images = %#v, want one trusted image", images)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(images[0].Base64Data)
|
||||
if err != nil || string(decoded) != string(png) {
|
||||
t.Fatalf("inline image mismatch: len=%d err=%v", len(decoded), err)
|
||||
}
|
||||
if images[0].MIMEType != "image/png" {
|
||||
t.Fatalf("mime type = %q", images[0].MIMEType)
|
||||
}
|
||||
if got := AssetService.LoadConversationVisionImages(12, []models.Message{{
|
||||
ID: 10, ConversationID: 12, SenderType: enums.IMSenderTypeCustomer,
|
||||
MessageType: enums.IMMessageTypeImage, Payload: payload, SendStatus: enums.IMMessageStatusSent,
|
||||
}}, 3); len(got) != 0 {
|
||||
t.Fatalf("cross-conversation asset leaked into model input: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationVisionImagesLoadsCompositeMessageInPayloadOrder(t *testing.T) {
|
||||
setupConversationVisionAssetTest(t)
|
||||
firstData := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 520)...)
|
||||
secondData := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 640)...)
|
||||
first, err := AssetService.UploadConversationBytes(firstData, "images", "front.png", 21, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("upload first image: %v", err)
|
||||
}
|
||||
second, err := AssetService.UploadConversationBytes(secondData, "images", "label.png", 21, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("upload second image: %v", err)
|
||||
}
|
||||
payload, err := buildIMMessageAssetBatchPayload([]*models.Asset{first, second})
|
||||
if err != nil {
|
||||
t.Fatalf("build batch payload: %v", err)
|
||||
}
|
||||
images := AssetService.LoadConversationVisionImages(21, []models.Message{{
|
||||
ID: 22, ConversationID: 21, SenderType: enums.IMSenderTypeCustomer,
|
||||
MessageType: enums.IMMessageTypeImage, Payload: payload, SendStatus: enums.IMMessageStatusSent,
|
||||
}}, 6)
|
||||
if len(images) != 2 || images[0].AssetID != first.AssetID || images[1].AssetID != second.AssetID {
|
||||
t.Fatalf("composite image order mismatch: %#v", images)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConversationAssetRejectsCrossConversationAndFakeImage(t *testing.T) {
|
||||
asset := &models.Asset{ConversationID: 7, Status: enums.AssetStatusSuccess, MimeType: "image/png"}
|
||||
if err := validateConversationAsset(asset, 7, enums.IMMessageTypeImage); err != nil {
|
||||
t.Fatalf("valid scoped image rejected: %v", err)
|
||||
}
|
||||
if err := validateConversationAsset(asset, 8, enums.IMMessageTypeImage); err == nil {
|
||||
t.Fatal("cross-conversation asset must be rejected")
|
||||
}
|
||||
asset.MimeType = "text/html"
|
||||
if err := validateConversationAsset(asset, 7, enums.IMMessageTypeImage); err == nil {
|
||||
t.Fatal("non-image asset must not be sent as an image")
|
||||
}
|
||||
}
|
||||
@@ -1,524 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/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)
|
||||
})
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/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)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/identity"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
)
|
||||
|
||||
var quickActionBusinessIdentifierPattern = regexp.MustCompile(`[A-Za-z0-9][A-Za-z0-9:_-]{5,63}`)
|
||||
|
||||
// resolveQuickActionConversation builds the business context used by the H5
|
||||
// quick-service menu without changing the conversation's owner identity. Web
|
||||
// visitors must remain guests for authorization, while an already supplied or
|
||||
// previously recognised card/device number determines which actions are shown.
|
||||
func resolveQuickActionConversation(ctx context.Context, conversation *models.Conversation) *models.Conversation {
|
||||
if conversation == nil || quickActionBoundBusinessType(conversation.CustomerType) {
|
||||
return conversation
|
||||
}
|
||||
|
||||
hints := quickActionIdentityHints(conversation)
|
||||
for _, hint := range hints {
|
||||
subject, ok, err := resolveQuickActionBusinessSubject(ctx, hint)
|
||||
if err != nil || !ok {
|
||||
continue
|
||||
}
|
||||
resolved := *conversation
|
||||
resolved.CustomerType = string(subject.Type)
|
||||
resolved.CustomerID = subject.ID
|
||||
resolved.CustomerExternalID = strings.TrimSpace(subject.Identifier)
|
||||
if subject.Type == identity.SubjectCard && strings.TrimSpace(subject.Username) != "" {
|
||||
resolved.CustomerExternalID = strings.TrimSpace(subject.Username)
|
||||
}
|
||||
resolved.CustomerName = strings.TrimSpace(subject.Name)
|
||||
return &resolved
|
||||
}
|
||||
return conversation
|
||||
}
|
||||
|
||||
type quickActionIdentityHint struct {
|
||||
identifier string
|
||||
types []identity.SubjectType
|
||||
}
|
||||
|
||||
func quickActionIdentityHints(conversation *models.Conversation) []quickActionIdentityHint {
|
||||
if conversation == nil {
|
||||
return nil
|
||||
}
|
||||
hints := make([]quickActionIdentityHint, 0, 4)
|
||||
if value, ok := strings.CutPrefix(strings.TrimSpace(conversation.CustomerExternalID), "card:"); ok && strings.TrimSpace(value) != "" {
|
||||
hints = append(hints, quickActionIdentityHint{identifier: strings.TrimSpace(value), types: []identity.SubjectType{identity.SubjectCard}})
|
||||
}
|
||||
if value, ok := strings.CutPrefix(strings.TrimSpace(conversation.CustomerExternalID), "device:"); ok && strings.TrimSpace(value) != "" {
|
||||
hints = append(hints, quickActionIdentityHint{identifier: strings.TrimSpace(value), types: []identity.SubjectType{identity.SubjectDevice}})
|
||||
}
|
||||
|
||||
history, _, _ := MessageService.FindByConversationIDCursor(
|
||||
conversation.ID, 0, 20, string(enums.IMSenderTypeCustomer), "",
|
||||
)
|
||||
for index := len(history) - 1; index >= 0; index-- {
|
||||
item := history[index]
|
||||
if item.MessageType != enums.IMMessageTypeText && item.MessageType != enums.IMMessageTypeHTML {
|
||||
continue
|
||||
}
|
||||
content := strings.TrimSpace(utils.BuildRuntimeMessageText(item.MessageType, item.Content))
|
||||
candidates, explicit, types := quickActionBusinessCandidates(content)
|
||||
if !explicit {
|
||||
continue
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
hints = append(hints, quickActionIdentityHint{identifier: candidate, types: types})
|
||||
}
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
func quickActionBusinessCandidates(content string) ([]string, bool, []identity.SubjectType) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
candidates := slices.Compact(quickActionBusinessIdentifierPattern.FindAllString(content, -1))
|
||||
lower := strings.ToLower(content)
|
||||
explicit := strings.Contains(content, "卡号") || strings.Contains(content, "卡板") ||
|
||||
strings.Contains(content, "设备号") || strings.Contains(lower, "iccid") || strings.Contains(lower, "imei")
|
||||
if len(candidates) == 1 && candidates[0] == content {
|
||||
explicit = true
|
||||
}
|
||||
types := []identity.SubjectType{identity.SubjectCard, identity.SubjectDevice}
|
||||
switch {
|
||||
case strings.Contains(content, "设备") || strings.Contains(lower, "imei"):
|
||||
types = []identity.SubjectType{identity.SubjectDevice}
|
||||
case strings.Contains(content, "卡号") || strings.Contains(content, "卡板") || strings.Contains(lower, "iccid"):
|
||||
types = []identity.SubjectType{identity.SubjectCard}
|
||||
}
|
||||
return candidates, explicit, types
|
||||
}
|
||||
|
||||
func resolveQuickActionBusinessSubject(ctx context.Context, hint quickActionIdentityHint) (identity.Subject, bool, error) {
|
||||
for _, subjectType := range hint.types {
|
||||
subjects, err := SubjectService.Query(ctx, identity.Query{
|
||||
Types: []identity.SubjectType{subjectType},
|
||||
Keyword: hint.identifier,
|
||||
EnabledOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
return identity.Subject{}, false, err
|
||||
}
|
||||
for _, subject := range subjects {
|
||||
if subject.Type != subjectType || !subject.Enabled {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(subject.Identifier), hint.identifier) ||
|
||||
strings.EqualFold(strings.TrimSpace(subject.Username), hint.identifier) {
|
||||
return subject, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return identity.Subject{}, false, nil
|
||||
}
|
||||
|
||||
func quickActionBoundBusinessType(customerType string) bool {
|
||||
switch identity.SubjectType(strings.TrimSpace(customerType)) {
|
||||
case identity.SubjectCard, identity.SubjectDevice, identity.SubjectMallUser:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
)
|
||||
|
||||
func TestMatchingDeterministicActionsOnlyShortCircuitsSingleSimpleIntent(t *testing.T) {
|
||||
contains := func(marker string) func(string) bool {
|
||||
return func(message string) bool { return strings.Contains(message, marker) }
|
||||
}
|
||||
service := &customerQuickActionService{actions: map[string]contract.CustomerQuickAction{
|
||||
"card/traffic": {
|
||||
Code: "card/traffic", CustomerTypes: []string{"card"}, Sort: 10, MatchIntent: contains("流量"),
|
||||
},
|
||||
"card/balance": {
|
||||
Code: "card/balance", CustomerTypes: []string{"card"}, Sort: 20, MatchIntent: contains("余额"),
|
||||
},
|
||||
}}
|
||||
|
||||
for _, message := range []string{"查流量", "我的流量还剩多少"} {
|
||||
matched := service.matchingDeterministicActions(message, "card")
|
||||
if len(matched) != 1 || matched[0].Code != "card/traffic" {
|
||||
t.Fatalf("simple lookup %q should short-circuit: %#v", message, matched)
|
||||
}
|
||||
}
|
||||
for _, message := range []string{
|
||||
"我不是查余额,我要查流量",
|
||||
"我的流量为什么这么快用完",
|
||||
"怎么查流量",
|
||||
"流量套餐如何选择",
|
||||
"查流量;另外查余额",
|
||||
"顺便查下流量和余额",
|
||||
} {
|
||||
if matched := service.matchingDeterministicActions(message, "card"); len(matched) != 0 {
|
||||
t.Fatalf("ambiguous free text %q must enter the Agent: %#v", message, matched)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/openidentity"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
)
|
||||
|
||||
var CustomerQuickActionService = &customerQuickActionService{}
|
||||
|
||||
type customerQuickActionService struct {
|
||||
mu sync.RWMutex
|
||||
actions map[string]contract.CustomerQuickAction
|
||||
}
|
||||
|
||||
func SetCustomerQuickActions(actions []contract.CustomerQuickAction) error {
|
||||
registered := make(map[string]contract.CustomerQuickAction, len(actions))
|
||||
for _, action := range actions {
|
||||
action.Code = strings.TrimSpace(action.Code)
|
||||
action.Title = strings.TrimSpace(action.Title)
|
||||
action.Description = strings.TrimSpace(action.Description)
|
||||
action.Message = strings.TrimSpace(action.Message)
|
||||
if action.Code == "" || action.Title == "" || action.Message == "" {
|
||||
return fmt.Errorf("ai-agent: customer quick action code, title and message are required")
|
||||
}
|
||||
if action.Execute == nil {
|
||||
return fmt.Errorf("ai-agent: customer quick action executor is required: %s", action.Code)
|
||||
}
|
||||
if _, exists := registered[action.Code]; exists {
|
||||
return fmt.Errorf("ai-agent: duplicate customer quick action code: %s", action.Code)
|
||||
}
|
||||
registered[action.Code] = action
|
||||
}
|
||||
|
||||
CustomerQuickActionService.mu.Lock()
|
||||
CustomerQuickActionService.actions = registered
|
||||
CustomerQuickActionService.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *customerQuickActionService) ListForConversation(ctx context.Context, conversation *models.Conversation) ([]contract.CustomerQuickAction, error) {
|
||||
if conversation == nil {
|
||||
return nil, nil
|
||||
}
|
||||
conversation = resolveQuickActionConversation(ctx, conversation)
|
||||
s.mu.RLock()
|
||||
actions := make([]contract.CustomerQuickAction, 0, len(s.actions))
|
||||
for _, action := range s.actions {
|
||||
if quickActionSupportsCustomerType(action, conversation.CustomerType) {
|
||||
actions = append(actions, action)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
businessContext := quickActionBusinessContext(ctx, conversation)
|
||||
ret := make([]contract.CustomerQuickAction, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
if action.Available != nil {
|
||||
available, err := action.Available(ctx, businessContext)
|
||||
if err != nil {
|
||||
slog.Warn("check customer quick action availability failed", "code", action.Code, "conversation_id", conversation.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if !available {
|
||||
continue
|
||||
}
|
||||
}
|
||||
ret = append(ret, action)
|
||||
}
|
||||
sort.Slice(ret, func(i, j int) bool {
|
||||
if ret[i].Sort == ret[j].Sort {
|
||||
return ret[i].Code < ret[j].Code
|
||||
}
|
||||
return ret[i].Sort < ret[j].Sort
|
||||
})
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *customerQuickActionService) ExecuteAndRecord(
|
||||
ctx context.Context,
|
||||
conversationID int64,
|
||||
code string,
|
||||
clientMsgID string,
|
||||
external openidentity.ExternalUser,
|
||||
requestID string,
|
||||
) (*models.Message, *models.Message, error) {
|
||||
conversation := ConversationService.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return nil, nil, errorsx.InvalidParamI18n("error.e0116")
|
||||
}
|
||||
if !ConversationService.IsCustomerConversationOwner(conversation, external) {
|
||||
return nil, nil, errorsx.ForbiddenI18n("error.e0222")
|
||||
}
|
||||
conversation = resolveQuickActionConversation(ctx, conversation)
|
||||
action, ok := s.resolve(code, conversation.CustomerType)
|
||||
if !ok {
|
||||
return nil, nil, errorsx.InvalidParam("customer quick action is unavailable")
|
||||
}
|
||||
businessContext := quickActionBusinessContext(ctx, conversation)
|
||||
if action.Available != nil {
|
||||
available, err := action.Available(ctx, businessContext)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !available {
|
||||
return nil, nil, errorsx.InvalidParam("customer quick action is currently unavailable")
|
||||
}
|
||||
}
|
||||
if action.TriggerAI {
|
||||
customerMessage, err := MessageService.SendCustomerMessageWithContextAndRequestID(ctx,
|
||||
conversation.ID, clientMsgID, enums.IMMessageTypeText, action.Message, "", external, requestID,
|
||||
)
|
||||
return customerMessage, nil, err
|
||||
}
|
||||
|
||||
reply, err := action.Execute(ctx, businessContext)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
reply = strings.TrimSpace(reply)
|
||||
if reply == "" {
|
||||
return nil, nil, errorsx.InvalidParam("customer quick action returned an empty reply")
|
||||
}
|
||||
|
||||
customerMessage, err := MessageService.SendCustomerMessageWithoutAIReplyWithContextAndRequestID(ctx,
|
||||
conversation.ID,
|
||||
clientMsgID,
|
||||
enums.IMMessageTypeText,
|
||||
action.Message,
|
||||
"",
|
||||
external,
|
||||
requestID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
replyClientMsgID := strs.UUID()
|
||||
if value := strings.TrimSpace(clientMsgID); value != "" {
|
||||
if len(value) > 96 {
|
||||
value = value[:96]
|
||||
}
|
||||
replyClientMsgID = value + "_auto_reply"
|
||||
}
|
||||
replyMessage, err := MessageService.SendAutomaticServiceMessageWithRequestID(
|
||||
conversation.ID,
|
||||
replyClientMsgID,
|
||||
reply,
|
||||
requestID,
|
||||
)
|
||||
if err != nil {
|
||||
return customerMessage, nil, err
|
||||
}
|
||||
return customerMessage, replyMessage, nil
|
||||
}
|
||||
|
||||
// ExecuteMatchedReply executes a deterministic quick action for an already
|
||||
// recorded customer message. It is used by the AI reply pipeline so explicit
|
||||
// read commands do not depend on a model deciding whether to call a tool.
|
||||
func (s *customerQuickActionService) ExecuteMatchedReply(
|
||||
ctx context.Context,
|
||||
conversation *models.Conversation,
|
||||
content string,
|
||||
requestID string,
|
||||
sourceMessageID int64,
|
||||
) (bool, error) {
|
||||
if conversation == nil || strings.TrimSpace(content) == "" {
|
||||
return false, nil
|
||||
}
|
||||
actions := s.matchingDeterministicActions(content, conversation.CustomerType)
|
||||
if len(actions) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return s.executeDeterministicReplies(ctx, conversation, actions, requestID, sourceMessageID)
|
||||
}
|
||||
|
||||
func (s *customerQuickActionService) ExecuteSelectedReply(
|
||||
ctx context.Context,
|
||||
conversation *models.Conversation,
|
||||
selection int,
|
||||
requestID string,
|
||||
sourceMessageID int64,
|
||||
) (matched bool, aiMessage string, err error) {
|
||||
if conversation == nil || selection <= 0 {
|
||||
return false, "", nil
|
||||
}
|
||||
actions, err := s.ListForConversation(ctx, conversation)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if selection > len(actions) {
|
||||
return false, "", nil
|
||||
}
|
||||
action := actions[selection-1]
|
||||
return s.executeActionReply(ctx, conversation, action, requestID, sourceMessageID)
|
||||
}
|
||||
|
||||
// ExecuteActionReply executes a registered quick action by code for an already
|
||||
// recorded customer message. It is used for deterministic conversational
|
||||
// choices whose display order is not the main quick-action menu order.
|
||||
func (s *customerQuickActionService) ExecuteActionReply(
|
||||
ctx context.Context,
|
||||
conversation *models.Conversation,
|
||||
code string,
|
||||
requestID string,
|
||||
sourceMessageID int64,
|
||||
) (matched bool, aiMessage string, err error) {
|
||||
if conversation == nil || strings.TrimSpace(code) == "" {
|
||||
return false, "", nil
|
||||
}
|
||||
actions, err := s.ListForConversation(ctx, conversation)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
for _, action := range actions {
|
||||
if action.Code == strings.TrimSpace(code) {
|
||||
return s.executeActionReply(ctx, conversation, action, requestID, sourceMessageID)
|
||||
}
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
func (s *customerQuickActionService) executeActionReply(
|
||||
ctx context.Context,
|
||||
conversation *models.Conversation,
|
||||
action contract.CustomerQuickAction,
|
||||
requestID string,
|
||||
sourceMessageID int64,
|
||||
) (matched bool, aiMessage string, err error) {
|
||||
if action.TriggerAI {
|
||||
return true, action.Message, nil
|
||||
}
|
||||
matched, err = s.executeDeterministicReplies(
|
||||
ctx, conversation, []contract.CustomerQuickAction{action}, requestID, sourceMessageID,
|
||||
)
|
||||
return matched, "", err
|
||||
}
|
||||
|
||||
func (s *customerQuickActionService) executeDeterministicReplies(
|
||||
ctx context.Context,
|
||||
conversation *models.Conversation,
|
||||
actions []contract.CustomerQuickAction,
|
||||
requestID string,
|
||||
sourceMessageID int64,
|
||||
) (bool, error) {
|
||||
businessContext := quickActionBusinessContext(ctx, conversation)
|
||||
replies := make([]string, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
if action.Available != nil {
|
||||
available, err := action.Available(ctx, businessContext)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if !available {
|
||||
continue
|
||||
}
|
||||
}
|
||||
reply, err := action.Execute(ctx, businessContext)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
reply = strings.TrimSpace(reply)
|
||||
if reply == "" {
|
||||
return true, errorsx.InvalidParam("customer quick action returned an empty reply")
|
||||
}
|
||||
replies = append(replies, reply)
|
||||
}
|
||||
if len(replies) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
clientMsgID := fmt.Sprintf("matched_action_%d", sourceMessageID)
|
||||
_, err := MessageService.SendAutomaticServiceMessageWithRequestID(
|
||||
conversation.ID,
|
||||
clientMsgID,
|
||||
strings.Join(replies, "\n\n"),
|
||||
requestID,
|
||||
)
|
||||
return true, err
|
||||
}
|
||||
|
||||
func (s *customerQuickActionService) matchingDeterministicActions(content, customerType string) []contract.CustomerQuickAction {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
matched := make([]contract.CustomerQuickAction, 0, 1)
|
||||
for _, action := range s.actions {
|
||||
if action.TriggerAI || action.MatchIntent == nil || !quickActionSupportsCustomerType(action, customerType) {
|
||||
continue
|
||||
}
|
||||
if action.MatchIntent(content) {
|
||||
matched = append(matched, action)
|
||||
}
|
||||
}
|
||||
// Free text must only bypass the Agent when it is one short, unambiguous
|
||||
// lookup. Negations, explanations and compound requests need conversational
|
||||
// reasoning; returning one or more keyword templates here would silently
|
||||
// discard the customer's actual intent. Menu selections and explicit action
|
||||
// codes use separate deterministic entry points and are unaffected.
|
||||
if len(matched) != 1 || !isHighConfidenceDeterministicQuickActionMessage(content) {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(matched, func(i, j int) bool {
|
||||
if matched[i].Sort == matched[j].Sort {
|
||||
return matched[i].Code < matched[j].Code
|
||||
}
|
||||
return matched[i].Sort < matched[j].Sort
|
||||
})
|
||||
return matched
|
||||
}
|
||||
|
||||
func isHighConfidenceDeterministicQuickActionMessage(content string) bool {
|
||||
text := strings.TrimSpace(content)
|
||||
if text == "" || len([]rune(text)) > 28 {
|
||||
return false
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"不是", "而是", "不要", "别", "搞错", "说错",
|
||||
"为什么", "怎么", "如何", "能否", "可以吗", "咨询", "原因",
|
||||
"另外", "还有", "顺便", "同时", "并且", "而且", "以及",
|
||||
"\n", ";", ";",
|
||||
} {
|
||||
if strings.Contains(text, marker) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func quickActionBusinessContext(ctx context.Context, conversation *models.Conversation) contract.BusinessReadContext {
|
||||
if conversation == nil {
|
||||
return contract.BusinessReadContext{}
|
||||
}
|
||||
businessContext := contract.BusinessReadContext{
|
||||
ConversationID: conversation.ID,
|
||||
CustomerType: conversation.CustomerType,
|
||||
CustomerID: conversation.CustomerID,
|
||||
CustomerExternalID: conversation.CustomerExternalID,
|
||||
CustomerName: conversation.CustomerName,
|
||||
}
|
||||
if proof, ok := contract.CustomerAccessProofFromContext(ctx); ok {
|
||||
businessContext.AccessProof = &proof
|
||||
businessContext.RequestMessageID = proof.MessageID
|
||||
businessContext.RequestID = proof.RequestID
|
||||
}
|
||||
return businessContext
|
||||
}
|
||||
|
||||
func (s *customerQuickActionService) resolve(code, customerType string) (contract.CustomerQuickAction, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
action, ok := s.actions[strings.TrimSpace(code)]
|
||||
if !ok || !quickActionSupportsCustomerType(action, customerType) {
|
||||
return contract.CustomerQuickAction{}, false
|
||||
}
|
||||
return action, true
|
||||
}
|
||||
|
||||
func quickActionSupportsCustomerType(action contract.CustomerQuickAction, customerType string) bool {
|
||||
if len(action.CustomerTypes) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range action.CustomerTypes {
|
||||
if strings.EqualFold(strings.TrimSpace(candidate), strings.TrimSpace(customerType)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
"code.tczkiot.com/wlw/ai-agent/identity"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
)
|
||||
|
||||
func TestCustomerQuickActionsResolveKnownCardWithoutChangingGuestOwnership(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
external := welcomeTestExternalUser("card:50506783")
|
||||
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
originalCustomerType := conversation.CustomerType
|
||||
SetQuerySubjects(func(_ context.Context, query identity.Query) ([]identity.Subject, error) {
|
||||
if len(query.Types) == 1 && query.Types[0] == identity.SubjectCard && query.Keyword == "50506783" {
|
||||
return []identity.Subject{{
|
||||
Type: identity.SubjectCard, Category: identity.CategoryUser, ID: 17443,
|
||||
Username: "50506783", Identifier: "898608691025D4186783", Name: "卡号 50506783", Enabled: true,
|
||||
}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
t.Cleanup(func() { SetQuerySubjects(nil) })
|
||||
|
||||
var executedContext contract.BusinessReadContext
|
||||
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{
|
||||
{
|
||||
Code: "card/traffic", Title: "查流量", Message: "请查询流量", CustomerTypes: []string{"card"},
|
||||
Execute: func(_ context.Context, businessContext contract.BusinessReadContext) (string, error) {
|
||||
executedContext = businessContext
|
||||
return "剩余流量 30G", nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Code: "device/status", Title: "查设备", Message: "请查询设备", CustomerTypes: []string{"device"},
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) { return "设备正常", nil },
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SetCustomerQuickActions() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
|
||||
|
||||
actions, err := CustomerQuickActionService.ListForConversation(context.Background(), conversation)
|
||||
if err != nil || len(actions) != 1 || actions[0].Code != "card/traffic" {
|
||||
t.Fatalf("known card actions = %#v, err = %v", actions, err)
|
||||
}
|
||||
if conversation.CustomerType != originalCustomerType || !ConversationService.IsCustomerConversationOwner(conversation, external) {
|
||||
t.Fatalf("quick-action resolution changed guest ownership: %#v", conversation)
|
||||
}
|
||||
if _, _, err := CustomerQuickActionService.ExecuteAndRecord(
|
||||
context.Background(), conversation.ID, "card/traffic", "known-card-1", external, "known-card-request-1",
|
||||
); err != nil {
|
||||
t.Fatalf("ExecuteAndRecord() error = %v", err)
|
||||
}
|
||||
if executedContext.CustomerType != "card" || executedContext.CustomerID != 17443 || executedContext.CustomerExternalID != "50506783" {
|
||||
t.Fatalf("unexpected business context: %#v", executedContext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerQuickActionsResolveDeviceFromConversationHistory(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
external := welcomeTestExternalUser("quick-device-history")
|
||||
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
SetQuerySubjects(func(_ context.Context, query identity.Query) ([]identity.Subject, error) {
|
||||
if len(query.Types) == 1 && query.Types[0] == identity.SubjectDevice && query.Keyword == "37012627000987" {
|
||||
return []identity.Subject{{
|
||||
Type: identity.SubjectDevice, Category: identity.CategoryUser, ID: 27,
|
||||
Username: "37012627000987", Identifier: "37012627000987", Name: "设备号 37012627000987", Enabled: true,
|
||||
}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
t.Cleanup(func() { SetQuerySubjects(nil) })
|
||||
if _, err := MessageService.SendCustomerMessageWithoutAIReplyWithRequestID(
|
||||
conversation.ID, "known-device-message", enums.IMMessageTypeHTML,
|
||||
"<p>设备号 37012627000987</p>", "", external, "known-device-request",
|
||||
); err != nil {
|
||||
t.Fatalf("send identity message: %v", err)
|
||||
}
|
||||
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{{
|
||||
Code: "device/wifi", Title: "WiFi 信息", Message: "查询 WiFi", CustomerTypes: []string{"device"},
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) { return "WiFi 正常", nil },
|
||||
}}); err != nil {
|
||||
t.Fatalf("SetCustomerQuickActions() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
|
||||
|
||||
actions, err := CustomerQuickActionService.ListForConversation(context.Background(), conversation)
|
||||
if err != nil || len(actions) != 1 || actions[0].Code != "device/wifi" {
|
||||
t.Fatalf("known device actions = %#v, err = %v", actions, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerQuickActionRecordsReplyWithoutTriggeringAI(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
external := welcomeTestExternalUser("quick-action-user")
|
||||
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
|
||||
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{{
|
||||
Code: "test/status",
|
||||
Title: "查状态",
|
||||
Message: "请查询状态",
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
|
||||
return "当前状态正常", nil
|
||||
},
|
||||
}}); err != nil {
|
||||
t.Fatalf("SetCustomerQuickActions() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
|
||||
|
||||
previousHook := TriggerAIReplyAsyncHook
|
||||
called := false
|
||||
TriggerAIReplyAsyncHook = func(context.Context, models.Conversation, models.Message) { called = true }
|
||||
t.Cleanup(func() { TriggerAIReplyAsyncHook = previousHook })
|
||||
|
||||
customerMessage, replyMessage, err := CustomerQuickActionService.ExecuteAndRecord(
|
||||
context.Background(), conversation.ID, "test/status", "quick-client-1", external, "quick-request-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteAndRecord() error = %v", err)
|
||||
}
|
||||
if customerMessage.Content != "请查询状态" || customerMessage.SenderType != enums.IMSenderTypeCustomer {
|
||||
t.Fatalf("unexpected customer message: %#v", customerMessage)
|
||||
}
|
||||
if replyMessage.Content != "当前状态正常" || replyMessage.SenderType != enums.IMSenderTypeAI {
|
||||
t.Fatalf("unexpected automatic reply: %#v", replyMessage)
|
||||
}
|
||||
if called {
|
||||
t.Fatalf("quick action customer message must not trigger an AI reply")
|
||||
}
|
||||
|
||||
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 != 2 {
|
||||
t.Fatalf("message count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerQuickActionAvailabilityIsCheckedForListAndExecution(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
external := welcomeTestExternalUser("quick-action-availability-user")
|
||||
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
available := false
|
||||
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{{
|
||||
Code: "test/dynamic", Title: "动态操作", Message: "执行动态操作",
|
||||
Available: func(context.Context, contract.BusinessReadContext) (bool, error) {
|
||||
return available, nil
|
||||
},
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
|
||||
return "执行成功", nil
|
||||
},
|
||||
}}); err != nil {
|
||||
t.Fatalf("SetCustomerQuickActions() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
|
||||
|
||||
actions, err := CustomerQuickActionService.ListForConversation(context.Background(), conversation)
|
||||
if err != nil || len(actions) != 0 {
|
||||
t.Fatalf("unavailable action leaked into list: actions=%#v err=%v", actions, err)
|
||||
}
|
||||
if _, _, err := CustomerQuickActionService.ExecuteAndRecord(context.Background(), conversation.ID, "test/dynamic", "quick-dynamic-1", external, "quick-dynamic-request-1"); err == nil {
|
||||
t.Fatal("unavailable action was executed")
|
||||
}
|
||||
|
||||
available = true
|
||||
actions, err = CustomerQuickActionService.ListForConversation(context.Background(), conversation)
|
||||
if err != nil || len(actions) != 1 || actions[0].Code != "test/dynamic" {
|
||||
t.Fatalf("available action missing from list: actions=%#v err=%v", actions, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerQuickActionExecutesMatchedRecordedMessage(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
external := welcomeTestExternalUser("matched-action-user")
|
||||
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{{
|
||||
Code: "test/traffic", Title: "查流量", Message: "请查询流量",
|
||||
MatchIntent: func(message string) bool { return message == "卡号 50506783,请查询流量" },
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
|
||||
return "剩余流量:58.38G", nil
|
||||
},
|
||||
}}); err != nil {
|
||||
t.Fatalf("SetCustomerQuickActions() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
|
||||
|
||||
message, err := MessageService.SendCustomerMessageWithoutAIReplyWithRequestID(
|
||||
conversation.ID, "matched-customer-1", enums.IMMessageTypeText,
|
||||
"卡号 50506783,请查询流量", "", external, "matched-request-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("send customer message: %v", err)
|
||||
}
|
||||
matched, err := CustomerQuickActionService.ExecuteMatchedReply(
|
||||
context.Background(), conversation, message.Content, message.RequestID, message.ID,
|
||||
)
|
||||
if err != nil || !matched {
|
||||
t.Fatalf("ExecuteMatchedReply() matched=%v err=%v", matched, err)
|
||||
}
|
||||
list, _, _ := MessageService.FindByConversationIDCursor(conversation.ID, 0, 20, "", "")
|
||||
if len(list) != 2 || list[1].Content != "剩余流量:58.38G" || list[1].SenderType != enums.IMSenderTypeAI {
|
||||
t.Fatalf("unexpected messages: %#v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerQuickActionExecutesSelectedMenuItem(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
external := welcomeTestExternalUser("selected-action-user")
|
||||
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{{
|
||||
Code: "test/traffic", Title: "查流量", Message: "请查询流量", Sort: 10,
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
|
||||
return "剩余流量:58.38G", nil
|
||||
},
|
||||
}}); err != nil {
|
||||
t.Fatalf("SetCustomerQuickActions() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
|
||||
|
||||
matched, aiMessage, err := CustomerQuickActionService.ExecuteSelectedReply(
|
||||
context.Background(), conversation, 1, "selected-request-1", 100,
|
||||
)
|
||||
if err != nil || !matched || aiMessage != "" {
|
||||
t.Fatalf("ExecuteSelectedReply() matched=%v aiMessage=%q err=%v", matched, aiMessage, err)
|
||||
}
|
||||
list, _, _ := MessageService.FindByConversationIDCursor(conversation.ID, 0, 20, "", "")
|
||||
if len(list) != 1 || list[0].Content != "剩余流量:58.38G" {
|
||||
t.Fatalf("unexpected selected action messages: %#v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerQuickActionExecutesActionByCode(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
external := welcomeTestExternalUser("coded-action-user")
|
||||
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{
|
||||
{
|
||||
Code: "test/status", Title: "查状态", Message: "请查询状态", Sort: 10,
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
|
||||
return "当前状态正常", nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Code: "test/diagnosis", Title: "智能检测", Message: "请智能检测", Sort: 20,
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
|
||||
return "智能检测结果:网络异常", nil
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SetCustomerQuickActions() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
|
||||
|
||||
matched, aiMessage, err := CustomerQuickActionService.ExecuteActionReply(
|
||||
context.Background(), conversation, "test/diagnosis", "coded-request-1", 101,
|
||||
)
|
||||
if err != nil || !matched || aiMessage != "" {
|
||||
t.Fatalf("ExecuteActionReply() matched=%v aiMessage=%q err=%v", matched, aiMessage, err)
|
||||
}
|
||||
list, _, _ := MessageService.FindByConversationIDCursor(conversation.ID, 0, 20, "", "")
|
||||
if len(list) != 1 || list[0].Content != "智能检测结果:网络异常" {
|
||||
t.Fatalf("unexpected coded action messages: %#v", list)
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/openidentity"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/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 "访客" + 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
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package services_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/openidentity"
|
||||
"code.tczkiot.com/wlw/ai-agent/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 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
|
||||
}
|
||||
@@ -11,49 +11,14 @@ import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/eventbus"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
|
||||
"code.tczkiot.com/wlw/ai-agent/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
|
||||
@@ -80,7 +45,7 @@ func handleConversationAssignedInAppNotification(ctx context.Context, event even
|
||||
NotificationType: "conversation_assigned",
|
||||
BizType: "conversation",
|
||||
BizID: conversation.ID,
|
||||
ActionURL: fmt.Sprintf("/dashboard/conversations?conversationId=%d", conversation.ID),
|
||||
ActionURL: fmt.Sprintf("/dashboard/conversations?conversation_id=%d", conversation.ID),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("create conversation assigned in-app notification failed", "error", err, "conversationId", event.ConversationID, "toUserId", event.ToUserID)
|
||||
|
||||
@@ -16,47 +16,6 @@ import (
|
||||
"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)
|
||||
|
||||
@@ -92,7 +51,7 @@ func TestConversationAssignedInAppNotification(t *testing.T) {
|
||||
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" {
|
||||
if got.ActionURL != "/dashboard/conversations?conversation_id=1" {
|
||||
t.Fatalf("unexpected action url: %q", got.ActionURL)
|
||||
}
|
||||
}
|
||||
@@ -115,7 +74,7 @@ func setupNotificationEventHandlerTestDB(t *testing.T) *gorm.DB {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&models.Notification{}, &models.Ticket{}, &models.Conversation{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Notification{}, &models.Conversation{}); err != nil {
|
||||
t.Fatalf("auto migrate error = %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package event_handlers
|
||||
|
||||
import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/events"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/eventbus"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/services"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
)
|
||||
|
||||
func init() {
|
||||
eventbus.
|
||||
Register[events.TicketAssignedEvent]().
|
||||
Subscribe(handleTicketAssignedNotify)
|
||||
}
|
||||
|
||||
func handleTicketAssignedNotify(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 := buildTicketAssignedNotifyBody(ticket, event.ToUserID, event.Reason)
|
||||
return services.WxWorkNotifyService.SendTextToAssigneeOrDefault(event.ToUserID, i18nx.Get("notification.ticketAssigned.title"), content)
|
||||
}
|
||||
|
||||
func buildTicketAssignedNotifyBody(ticket *models.Ticket, assigneeID int64, reason string) string {
|
||||
if ticket == nil {
|
||||
return ""
|
||||
}
|
||||
lines := []string{
|
||||
i18nx.Getf(i18nx.DefaultLocale, "notification.ticketAssigned.wxwork.no", strs.DefaultIfBlank(ticket.TicketNo, fmt.Sprintf("#%d", ticket.ID))),
|
||||
i18nx.Getf(i18nx.DefaultLocale, "notification.ticketAssigned.wxwork.title", strs.DefaultIfBlank(ticket.Title, "-")),
|
||||
i18nx.Getf(i18nx.DefaultLocale, "notification.ticketAssigned.wxwork.status", enums.GetTicketStatusLabel(ticket.Status)),
|
||||
i18nx.Getf(i18nx.DefaultLocale, "notification.assignee", resolveNotifyUserLabel(assigneeID)),
|
||||
}
|
||||
if strings.TrimSpace(reason) != "" {
|
||||
lines = append(lines, i18nx.Getf(i18nx.DefaultLocale, "notification.ticketAssigned.reason", strings.TrimSpace(reason)))
|
||||
}
|
||||
lines = append(lines, i18nx.Getf(i18nx.DefaultLocale, "notification.time", time.Now().Format("2006-01-02 15:04:05")))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package event_handlers
|
||||
|
||||
import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/events"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/eventbus"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/services"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
)
|
||||
|
||||
func init() {
|
||||
eventbus.
|
||||
Register[events.TicketCreatedEvent]().
|
||||
Subscribe(handleTicketCreatedNotify)
|
||||
}
|
||||
|
||||
func handleTicketCreatedNotify(ctx context.Context, event events.TicketCreatedEvent) error {
|
||||
if event.TicketID <= 0 {
|
||||
return nil
|
||||
}
|
||||
ticket := services.TicketService.Get(event.TicketID)
|
||||
if ticket == nil {
|
||||
return nil
|
||||
}
|
||||
content := buildTicketCreatedNotifyBody(ticket)
|
||||
return services.WxWorkNotifyService.SendTextToAssigneeOrDefault(ticket.CurrentAssigneeID, "工单创建提醒", content)
|
||||
}
|
||||
|
||||
func buildTicketCreatedNotifyBody(ticket *models.Ticket) string {
|
||||
if ticket == nil {
|
||||
return ""
|
||||
}
|
||||
lines := []string{
|
||||
fmt.Sprintf("工单号: %s", strs.DefaultIfBlank(ticket.TicketNo, fmt.Sprintf("#%d", ticket.ID))),
|
||||
fmt.Sprintf("工单标题: %s", strs.DefaultIfBlank(ticket.Title, "-")),
|
||||
fmt.Sprintf("工单来源: %s", strs.DefaultIfBlank(string(ticket.Source), "-")),
|
||||
fmt.Sprintf("当前状态: %s", enums.GetTicketStatusLabel(ticket.Status)),
|
||||
}
|
||||
if ticket.CurrentAssigneeID > 0 {
|
||||
lines = append(lines, fmt.Sprintf("处理人: %s", resolveNotifyUserLabel(ticket.CurrentAssigneeID)))
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("时间: %s", time.Now().Format("2006-01-02 15:04:05")))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
var testExternalSubjects sync.Map
|
||||
|
||||
func registerTestExternalSubject(id int64, username, name string, status enums.Status) {
|
||||
testExternalSubjects.Store(id, identity.Subject{
|
||||
Type: identity.SubjectAgent,
|
||||
registerTestSubject(identity.Subject{
|
||||
Type: identity.SubjectAdmin,
|
||||
Category: identity.CategorySystem,
|
||||
ID: id,
|
||||
Username: username,
|
||||
@@ -22,10 +22,17 @@ func registerTestExternalSubject(id int64, username, name string, status enums.S
|
||||
Identifier: username,
|
||||
Enabled: status == enums.StatusOk,
|
||||
})
|
||||
}
|
||||
|
||||
func registerTestSubject(subject identity.Subject) {
|
||||
testExternalSubjects.Store(string(subject.Type)+":"+subject.Identifier, subject)
|
||||
services.SetQuerySubjects(func(_ context.Context, query identity.Query) ([]identity.Subject, error) {
|
||||
results := make([]identity.Subject, 0)
|
||||
testExternalSubjects.Range(func(_, value any) bool {
|
||||
subject := value.(identity.Subject)
|
||||
if len(query.Types) > 0 && !slices.Contains(query.Types, subject.Type) {
|
||||
return true
|
||||
}
|
||||
if len(query.IDs) > 0 && !slices.Contains(query.IDs, subject.ID) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -10,13 +10,28 @@ import (
|
||||
)
|
||||
|
||||
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"`
|
||||
AssetID string `json:"asset_id,omitempty"`
|
||||
Provider enums.AssetProvider `json:"provider,omitempty"`
|
||||
StorageKey string `json:"storage_key,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
FileSize int64 `json:"file_size,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Assets []imMessageAssetPayload `json:"assets,omitempty"`
|
||||
}
|
||||
|
||||
func (p *imMessageAssetPayload) items() []*imMessageAssetPayload {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if len(p.Assets) == 0 {
|
||||
return []*imMessageAssetPayload{p}
|
||||
}
|
||||
items := make([]*imMessageAssetPayload, 0, len(p.Assets))
|
||||
for index := range p.Assets {
|
||||
items = append(items, &p.Assets[index])
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func parseIMMessageAssetPayload(payload string) (*imMessageAssetPayload, error) {
|
||||
@@ -28,12 +43,18 @@ func parseIMMessageAssetPayload(payload string) (*imMessageAssetPayload, error)
|
||||
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 == "" {
|
||||
items := ret.items()
|
||||
if len(items) == 0 || len(items) > 9 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0345")
|
||||
}
|
||||
for _, item := range items {
|
||||
item.AssetID = strings.TrimSpace(item.AssetID)
|
||||
item.Provider = enums.AssetProvider(strings.TrimSpace(string(item.Provider)))
|
||||
item.StorageKey = strings.TrimSpace(item.StorageKey)
|
||||
if item.AssetID == "" {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0345")
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
@@ -55,15 +76,38 @@ func buildIMMessageAssetPayload(asset *models.Asset) (string, error) {
|
||||
return string(payload), nil
|
||||
}
|
||||
|
||||
func buildIMMessageAssetBatchPayload(assets []*models.Asset) (string, error) {
|
||||
if len(assets) == 0 || len(assets) > 9 {
|
||||
return "", errorsx.InvalidParamI18n("error.e0342")
|
||||
}
|
||||
payload := imMessageAssetPayload{Assets: make([]imMessageAssetPayload, 0, len(assets))}
|
||||
for _, asset := range assets {
|
||||
if asset == nil {
|
||||
return "", errorsx.InvalidParamI18n("error.e0342")
|
||||
}
|
||||
payload.Assets = append(payload.Assets, imMessageAssetPayload{
|
||||
AssetID: asset.AssetID, Provider: asset.Provider, StorageKey: asset.StorageKey,
|
||||
Filename: asset.Filename, FileSize: asset.FileSize, MimeType: asset.MimeType,
|
||||
})
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), 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)
|
||||
for _, item := range assetPayload.items() {
|
||||
hydrateIMMessageAssetPayload(item)
|
||||
if item.Provider != "" && item.StorageKey != "" {
|
||||
if provider, err := storage.NewProvider(item.Provider); err == nil {
|
||||
item.URL = provider.GetSignedURL(item.StorageKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(assetPayload)
|
||||
@@ -112,5 +156,13 @@ func validateConversationAsset(asset *models.Asset, conversationID int64, messag
|
||||
if asset.Status != enums.AssetStatusSuccess {
|
||||
return errorsx.InvalidParamI18n("error.e0343")
|
||||
}
|
||||
if conversationID <= 0 || asset.ConversationID != conversationID {
|
||||
// Deliberately use the same error as a missing asset so callers cannot
|
||||
// probe whether an asset belongs to another customer's conversation.
|
||||
return errorsx.InvalidParamI18n("error.e0342")
|
||||
}
|
||||
if messageType == enums.IMMessageTypeImage && !isSupportedVisionImageMIME(asset.MimeType) {
|
||||
return errorsx.InvalidParamI18n("error.e0090")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,14 +2,9 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/rag"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/workflow/dsl"
|
||||
workflowregistry "code.tczkiot.com/wlw/ai-agent/internal/ai/workflow/registry"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
@@ -133,14 +128,6 @@ func (s *knowledgeBaseService) DeleteKnowledgeBase(id int64) error {
|
||||
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
|
||||
@@ -156,86 +143,6 @@ func (s *knowledgeBaseService) DeleteKnowledgeBase(id int64) error {
|
||||
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 {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/workflow/dsl"
|
||||
workflowregistry "code.tczkiot.com/wlw/ai-agent/internal/ai/workflow/registry"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
@@ -27,51 +23,6 @@ func TestBuildKnowledgeBaseModelUsesLowerDefaultScoreThreshold(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -119,67 +70,12 @@ func setupKnowledgeBaseServiceTestDB(t *testing.T) {
|
||||
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 {
|
||||
if err := db.AutoMigrate(&models.KnowledgeBase{}, &models.KnowledgeDocument{}, &models.KnowledgeFAQ{}, &models.KnowledgeChunk{}, &models.AIAgent{}); 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{
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/mcps"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
||||
"code.tczkiot.com/wlw/ai-agent/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)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/tracex"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -132,11 +134,11 @@ func (s *messageService) GetConversationReadTarget(conversationID, messageID int
|
||||
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)
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAgent, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, "")
|
||||
case enums.IMSenderTypeAI:
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAI, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, "", 0)
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAI, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, "")
|
||||
case enums.IMSenderTypeCustomer:
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, external, "", 0)
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, external, "")
|
||||
default:
|
||||
return nil, errorsx.InvalidParamI18n("error.e0080")
|
||||
}
|
||||
@@ -147,7 +149,7 @@ func (s *messageService) SendAgentMessage(conversationID int64, reqSenderID int6
|
||||
}
|
||||
|
||||
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)
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAgent, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, requestID)
|
||||
}
|
||||
|
||||
func (s *messageService) RecallAgentMessage(messageID int64, operator *dto.AuthPrincipal) (*models.Message, error) {
|
||||
@@ -250,11 +252,7 @@ func (s *messageService) SendAIMessage(conversationID int64, aiAgentID int64, cl
|
||||
}
|
||||
|
||||
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)
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAI, aiAgentID, clientMsgID, messageType, content, payload, operator, nil, requestID)
|
||||
}
|
||||
|
||||
func (s *messageService) SendAIServiceNotice(conversationID int64, aiAgentID int64, content string) (*models.Message, error) {
|
||||
@@ -269,11 +267,11 @@ func (s *messageService) SendAIServiceNoticeWithRequestID(conversationID int64,
|
||||
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{
|
||||
return s.sendValidatedMessage(context.Background(), conversation, enums.IMSenderTypeAI, aiAgentID, strs.UUID(), enums.IMMessageTypeText, content, "", &dto.AuthPrincipal{
|
||||
UserID: 0,
|
||||
Username: "system",
|
||||
Nickname: "system",
|
||||
}, nil, requestID, 0)
|
||||
}, nil, requestID)
|
||||
}
|
||||
|
||||
func (s *messageService) createAIWelcomeMessage(ctx *sqls.TxContext, conversation *models.Conversation, aiAgent *models.AIAgent, now time.Time) (*models.Message, error) {
|
||||
@@ -372,12 +370,53 @@ func (s *messageService) SendCustomerMessage(conversationID int64, clientMsgID s
|
||||
}
|
||||
|
||||
func (s *messageService) SendCustomerMessageWithRequestID(conversationID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, external openidentity.ExternalUser, requestID string) (*models.Message, error) {
|
||||
return s.SendCustomerMessageWithContextAndRequestID(context.Background(), conversationID, clientMsgID, messageType, content, payload, external, requestID)
|
||||
}
|
||||
|
||||
func (s *messageService) SendCustomerMessageWithContextAndRequestID(ctx context.Context, 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)
|
||||
return s.sendMessageWithContext(ctx, conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, &ext, requestID)
|
||||
}
|
||||
|
||||
func (s *messageService) SendCustomerMessageWithoutAIReplyWithRequestID(conversationID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, external openidentity.ExternalUser, requestID string) (*models.Message, error) {
|
||||
return s.SendCustomerMessageWithoutAIReplyWithContextAndRequestID(context.Background(), conversationID, clientMsgID, messageType, content, payload, external, requestID)
|
||||
}
|
||||
|
||||
func (s *messageService) SendCustomerMessageWithoutAIReplyWithContextAndRequestID(ctx context.Context, conversationID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, external openidentity.ExternalUser, requestID string) (*models.Message, error) {
|
||||
ext := external
|
||||
return s.sendMessageWithAITrigger(ctx, conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, &ext, requestID, false)
|
||||
}
|
||||
|
||||
func (s *messageService) SendAutomaticServiceMessageWithRequestID(conversationID int64, clientMsgID, content, 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(context.Background(), conversation, enums.IMSenderTypeAI, 0, clientMsgID, enums.IMMessageTypeText, content, "", &dto.AuthPrincipal{
|
||||
UserID: 0,
|
||||
Username: "system",
|
||||
Nickname: "system",
|
||||
}, nil, requestID, false)
|
||||
}
|
||||
|
||||
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) {
|
||||
messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string) (*models.Message, error) {
|
||||
return s.sendMessageWithContext(context.Background(), conversationID, senderType, reqSenderID, clientMsgID, messageType, content, payload, operator, external, requestID)
|
||||
}
|
||||
|
||||
func (s *messageService) sendMessageWithContext(ctx context.Context, conversationID int64, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string,
|
||||
messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string) (*models.Message, error) {
|
||||
return s.sendMessageWithAITrigger(ctx, conversationID, senderType, reqSenderID, clientMsgID, messageType, content, payload, operator, external, requestID, true)
|
||||
}
|
||||
|
||||
func (s *messageService) sendMessageWithAITrigger(requestContext context.Context, conversationID int64, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string,
|
||||
messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string, triggerAIReply bool) (*models.Message, error) {
|
||||
if requestContext == nil {
|
||||
requestContext = context.Background()
|
||||
}
|
||||
|
||||
if senderType == enums.IMSenderTypeCustomer {
|
||||
if external == nil || strings.TrimSpace(external.ExternalID) == "" {
|
||||
@@ -394,11 +433,11 @@ func (s *messageService) sendMessage(conversationID int64, senderType enums.IMSe
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.sendValidatedMessage(conversation, senderType, reqSenderID, clientMsgID, messageType, content, payload, operator, external, requestID, workflowRunID)
|
||||
return s.sendValidatedMessage(requestContext, conversation, senderType, reqSenderID, clientMsgID, messageType, content, payload, operator, external, requestID, triggerAIReply)
|
||||
}
|
||||
|
||||
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) {
|
||||
func (s *messageService) sendValidatedMessage(requestContext context.Context, conversation *models.Conversation, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string,
|
||||
messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string, triggerAIReply ...bool) (*models.Message, error) {
|
||||
|
||||
var err error
|
||||
var summary string
|
||||
@@ -434,7 +473,6 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation,
|
||||
message := &models.Message{
|
||||
ConversationID: conversation.ID,
|
||||
RequestID: traceID,
|
||||
WorkflowRunID: workflowRunID,
|
||||
ClientMsgID: clientMsgID,
|
||||
SenderType: senderType,
|
||||
SenderID: reqSenderID,
|
||||
@@ -525,6 +563,14 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation,
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// The preflight lookup and INSERT are intentionally not one atomic step.
|
||||
// If another sender wins the unique client-message key, treat its committed
|
||||
// message as this idempotent send's successful result.
|
||||
if strs.IsNotBlank(clientMsgID) {
|
||||
if existing := repositories.MessageRepository.GetByClientMsgID(sqls.DB(), conversation.ID, clientMsgID); existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -542,9 +588,10 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation,
|
||||
}
|
||||
|
||||
// 客户发送消息,触发AI回复
|
||||
if senderType == enums.IMSenderTypeCustomer {
|
||||
shouldTriggerAIReply := len(triggerAIReply) == 0 || triggerAIReply[0]
|
||||
if senderType == enums.IMSenderTypeCustomer && shouldTriggerAIReply {
|
||||
if TriggerAIReplyAsyncHook != nil {
|
||||
TriggerAIReplyAsyncHook(*conversation, *message)
|
||||
TriggerAIReplyAsyncHook(requestContext, *conversation, *message)
|
||||
}
|
||||
}
|
||||
return message, err
|
||||
@@ -624,20 +671,45 @@ func (s *messageService) normalizeMessageContent(conversationID int64, messageTy
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
asset := AssetService.GetByAssetID(assetPayload.AssetID)
|
||||
if err := validateConversationAsset(asset, conversationID, messageType); err != nil {
|
||||
return "", "", "", err
|
||||
items := assetPayload.items()
|
||||
if messageType == enums.IMMessageTypeAttachment && len(items) != 1 {
|
||||
return "", "", "", errorsx.InvalidParamI18n("error.e0345")
|
||||
}
|
||||
assets := make([]*models.Asset, 0, len(items))
|
||||
for _, item := range items {
|
||||
asset := AssetService.GetByAssetID(item.AssetID)
|
||||
if err := validateConversationAsset(asset, conversationID, messageType); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
assets = append(assets, asset)
|
||||
}
|
||||
var canonicalPayload string
|
||||
if len(assetPayload.Assets) > 0 {
|
||||
canonicalPayload, err = buildIMMessageAssetBatchPayload(assets)
|
||||
} else {
|
||||
canonicalPayload, err = buildIMMessageAssetPayload(assets[0])
|
||||
}
|
||||
canonicalPayload, err := buildIMMessageAssetPayload(asset)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
summary := "[附件]"
|
||||
if messageType == enums.IMMessageTypeImage {
|
||||
summary = "[图片]"
|
||||
if len(assets) > 1 {
|
||||
summary += fmt.Sprintf("×%d", len(assets))
|
||||
}
|
||||
content = utils.SanitizeMessageHTML(content)
|
||||
content, err = utils.NormalizeMessageHTMLAssets(content)
|
||||
if err != nil {
|
||||
return "", "", "", errorsx.InvalidParamI18n("error.e0030")
|
||||
}
|
||||
if text := utils.BuildHTMLSummary(content); text != "" {
|
||||
summary += " " + text
|
||||
}
|
||||
return content, canonicalPayload, summary, nil
|
||||
}
|
||||
content = strings.TrimSpace(asset.Filename)
|
||||
return content, canonicalPayload, summary + s.suffixFilenameForSummary(asset.Filename), nil
|
||||
content = strings.TrimSpace(assets[0].Filename)
|
||||
return content, canonicalPayload, summary + s.suffixFilenameForSummary(assets[0].Filename), nil
|
||||
default:
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" && strings.TrimSpace(payload) == "" {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -64,8 +65,6 @@ func setupMessageWelcomeTestDB(t *testing.T) *gorm.DB {
|
||||
&models.AIAgent{},
|
||||
&models.Channel{},
|
||||
&models.ChannelMessageOutbox{},
|
||||
&models.Customer{},
|
||||
&models.CustomerIdentity{},
|
||||
&models.Conversation{},
|
||||
&models.ConversationParticipant{},
|
||||
&models.ConversationReadState{},
|
||||
@@ -378,38 +377,6 @@ func TestUnreadCountUsesLastReadMessageID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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, "欢迎咨询")
|
||||
@@ -436,6 +403,88 @@ func TestConversationCreateDoesNotDuplicateWelcomeMessageForExistingConversation
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationCreateDoesNotReuseConversationFromAnotherChannel(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
external := welcomeTestExternalUser("channel-isolation-1")
|
||||
|
||||
first, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create first channel conversation: %v", err)
|
||||
}
|
||||
second, err := ConversationService.Create(external, 12, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create second channel conversation: %v", err)
|
||||
}
|
||||
if first.ID == second.ID {
|
||||
t.Fatalf("conversation %d was incorrectly reused across channels", first.ID)
|
||||
}
|
||||
if first.ChannelID != 11 || second.ChannelID != 12 {
|
||||
t.Fatalf("unexpected channel ids: first=%d second=%d", first.ChannelID, second.ChannelID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationCreateSynchronizesLatestAIAgentForUnassignedExistingConversation(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
external := welcomeTestExternalUser("sync-agent-1")
|
||||
|
||||
first, err := ConversationService.Create(external, 11, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("create human conversation: %v", err)
|
||||
}
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
aiAgent.ServiceMode = enums.IMConversationServiceModeAIFirst
|
||||
if err := db.Model(aiAgent).Update("service_mode", aiAgent.ServiceMode).Error; err != nil {
|
||||
t.Fatalf("update ai agent service mode: %v", err)
|
||||
}
|
||||
|
||||
second, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("reuse conversation with ai agent: %v", err)
|
||||
}
|
||||
if second.ID != first.ID {
|
||||
t.Fatalf("conversation id = %d, want existing %d", second.ID, first.ID)
|
||||
}
|
||||
if second.ChannelID != 11 || second.AIAgentID != aiAgent.ID {
|
||||
t.Fatalf("channel/agent = %d/%d, want 11/%d", second.ChannelID, second.AIAgentID, aiAgent.ID)
|
||||
}
|
||||
if second.ServiceMode != enums.IMConversationServiceModeAIFirst || second.Status != enums.IMConversationStatusAIServing {
|
||||
t.Fatalf("mode/status = %d/%d, want ai-first/ai-serving", second.ServiceMode, second.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationCreateStartsNewAIConversationWhenAssignedConversationUsesStaleMode(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
external := welcomeTestExternalUser("keep-human-1")
|
||||
|
||||
conversation, err := ConversationService.Create(external, 11, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("create human conversation: %v", err)
|
||||
}
|
||||
if err := db.Model(conversation).Updates(map[string]any{
|
||||
"current_assignee_id": 9,
|
||||
"status": enums.IMConversationStatusActive,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("assign conversation: %v", err)
|
||||
}
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
|
||||
reused, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create ai conversation: %v", err)
|
||||
}
|
||||
if reused.ID == conversation.ID {
|
||||
t.Fatalf("stale assigned conversation %d was reused", conversation.ID)
|
||||
}
|
||||
if reused.AIAgentID != aiAgent.ID || reused.ServiceMode != aiAgent.ServiceMode || reused.Status != enums.IMConversationStatusAIServing {
|
||||
t.Fatalf("new conversation did not use latest ai config: %#v", reused)
|
||||
}
|
||||
preserved := ConversationService.Get(conversation.ID)
|
||||
if preserved == nil || preserved.CurrentAssigneeID != 9 || preserved.Status != enums.IMConversationStatusActive {
|
||||
t.Fatalf("old assigned conversation state changed: %#v", preserved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationCreateSkipsBlankWelcomeMessage(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, " ")
|
||||
@@ -474,7 +523,7 @@ func TestConversationCreateWelcomeMessageDoesNotTriggerAIReplyHook(t *testing.T)
|
||||
|
||||
previousHook := TriggerAIReplyAsyncHook
|
||||
called := false
|
||||
TriggerAIReplyAsyncHook = func(conversation models.Conversation, message models.Message) {
|
||||
TriggerAIReplyAsyncHook = func(_ context.Context, conversation models.Conversation, message models.Message) {
|
||||
called = true
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/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)
|
||||
}
|
||||
@@ -18,12 +18,12 @@ func TestNotificationServiceCreateAndUnreadCount(t *testing.T) {
|
||||
|
||||
item, err := services.NotificationService.Create(request.CreateNotificationRequest{
|
||||
RecipientUserID: 101,
|
||||
Title: "工单指派提醒",
|
||||
Content: "工单 TK-1 已指派给你",
|
||||
NotificationType: "ticket_assigned",
|
||||
BizType: "ticket",
|
||||
Title: "会话分配提醒",
|
||||
Content: "会话 #1 已分配给你",
|
||||
NotificationType: "conversation_assigned",
|
||||
BizType: "conversation",
|
||||
BizID: 1,
|
||||
ActionURL: "/dashboard/tickets/1",
|
||||
ActionURL: "/dashboard/conversations?conversationId=1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
@@ -78,12 +78,12 @@ func TestNotificationServiceMarkAllReadOnlyCurrentUser(t *testing.T) {
|
||||
for _, userID := range []int64{301, 301, 302} {
|
||||
if _, err := services.NotificationService.Create(request.CreateNotificationRequest{
|
||||
RecipientUserID: userID,
|
||||
Title: "工单指派提醒",
|
||||
Content: "工单已指派给你",
|
||||
NotificationType: "ticket_assigned",
|
||||
BizType: "ticket",
|
||||
Title: "会话分配提醒",
|
||||
Content: "会话已分配给你",
|
||||
NotificationType: "conversation_assigned",
|
||||
BizType: "conversation",
|
||||
BizID: userID,
|
||||
ActionURL: "/dashboard/tickets/1",
|
||||
ActionURL: "/dashboard/conversations?conversationId=1",
|
||||
}); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
)
|
||||
|
||||
type platformAIService struct {
|
||||
mu sync.RWMutex
|
||||
provider contract.PlatformAIProvider
|
||||
}
|
||||
|
||||
var PlatformAIService = &platformAIService{}
|
||||
|
||||
func SetPlatformAIProvider(provider contract.PlatformAIProvider) {
|
||||
PlatformAIService.mu.Lock()
|
||||
defer PlatformAIService.mu.Unlock()
|
||||
PlatformAIService.provider = provider
|
||||
}
|
||||
|
||||
func (s *platformAIService) ModelSource(ctx context.Context) (string, error) {
|
||||
provider := s.current()
|
||||
if provider == nil {
|
||||
return contract.ModelSourceCustom, nil
|
||||
}
|
||||
source, err := provider.ModelSource(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(source), contract.ModelSourcePlatform) {
|
||||
return contract.ModelSourcePlatform, nil
|
||||
}
|
||||
return contract.ModelSourceCustom, nil
|
||||
}
|
||||
|
||||
func (s *platformAIService) IsPlatform(ctx context.Context) (bool, error) {
|
||||
source, err := s.ModelSource(ctx)
|
||||
return source == contract.ModelSourcePlatform, err
|
||||
}
|
||||
|
||||
func (s *platformAIService) Config(ctx context.Context) (*contract.PlatformAIConfig, error) {
|
||||
provider := s.current()
|
||||
if provider == nil {
|
||||
return nil, errors.New("platform AI provider is not initialized")
|
||||
}
|
||||
return provider.Config(ctx)
|
||||
}
|
||||
|
||||
func (s *platformAIService) Status(ctx context.Context) (*contract.PlatformAIStatus, error) {
|
||||
provider := s.current()
|
||||
if provider == nil {
|
||||
return nil, errors.New("platform AI provider is not initialized")
|
||||
}
|
||||
return provider.Status(ctx)
|
||||
}
|
||||
|
||||
func (s *platformAIService) current() contract.PlatformAIProvider {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.provider
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Public and persisted customer-service payload maps are part of the frontend
|
||||
// contract. Keep their literal keys in snake_case. Raw enterprise WeChat
|
||||
// inbound payloads retain the provider's original field names.
|
||||
func TestPublicPayloadMapKeysDoNotUseCamelCase(t *testing.T) {
|
||||
dirs := []string{
|
||||
".",
|
||||
"../builders",
|
||||
"../events",
|
||||
"../handlers/api",
|
||||
"../handlers/dashboard",
|
||||
"../pkg/httpx",
|
||||
"../pkg/utils",
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
fset := token.NewFileSet()
|
||||
packages, err := parser.ParseDir(fset, dir, func(info fs.FileInfo) bool {
|
||||
return !strings.HasSuffix(info.Name(), "_test.go")
|
||||
}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", dir, err)
|
||||
}
|
||||
for _, pkg := range packages {
|
||||
for filename, file := range pkg.Files {
|
||||
if filepath.Base(filename) == "wxwork_kf_inbound_service.go" {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
literal, ok := node.(*ast.CompositeLit)
|
||||
if !ok || !isStringKeyedMap(literal.Type) {
|
||||
return true
|
||||
}
|
||||
for _, element := range literal.Elts {
|
||||
pair, ok := element.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, ok := pair.Key.(*ast.BasicLit)
|
||||
if !ok || key.Kind != token.STRING {
|
||||
continue
|
||||
}
|
||||
name, err := strconv.Unquote(key.Value)
|
||||
if err != nil {
|
||||
t.Errorf("%s: invalid map key %s: %v", filename, key.Value, err)
|
||||
continue
|
||||
}
|
||||
// Dotted keys are internal i18n lookup identifiers, not JSON
|
||||
// property names exposed to clients.
|
||||
if !strings.Contains(name, ".") && containsUppercase(name) {
|
||||
t.Errorf("%s: public payload map key %q must use snake_case", filename, name)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isStringKeyedMap(expression ast.Expr) bool {
|
||||
mapType, ok := expression.(*ast.MapType)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
identifier, ok := mapType.Key.(*ast.Ident)
|
||||
return ok && identifier.Name == "string"
|
||||
}
|
||||
|
||||
func containsUppercase(value string) bool {
|
||||
for _, r := range value {
|
||||
if unicode.IsUpper(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/toolx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/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)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/response"
|
||||
"code.tczkiot.com/wlw/ai-agent/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)
|
||||
}
|
||||
@@ -6,11 +6,12 @@ import (
|
||||
)
|
||||
|
||||
type UploadInfo struct {
|
||||
Prefix string
|
||||
Filename string
|
||||
FileSize int64
|
||||
MimeType string
|
||||
Principal *dto.AuthPrincipal
|
||||
Prefix string
|
||||
ConversationID int64
|
||||
Filename string
|
||||
FileSize int64
|
||||
MimeType string
|
||||
Principal *dto.AuthPrincipal
|
||||
}
|
||||
|
||||
type StoredFile struct {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"io"
|
||||
)
|
||||
|
||||
var providers = make(map[enums.AssetProvider]FileStorageProvider)
|
||||
var hostStorage contract.FileStorage
|
||||
|
||||
type FileStorageProvider interface {
|
||||
ProviderType() enums.AssetProvider
|
||||
@@ -18,7 +23,24 @@ type FileStorageProvider interface {
|
||||
Read(key string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// SetHostStorage lets the embedding system own file persistence. Passing nil
|
||||
// preserves the standalone module's built-in local/OSS providers.
|
||||
func SetHostStorage(value contract.FileStorage) {
|
||||
hostStorage = value
|
||||
providers = make(map[enums.AssetProvider]FileStorageProvider)
|
||||
}
|
||||
|
||||
func GetDefault() (FileStorageProvider, error) {
|
||||
if hostStorage != nil {
|
||||
provider, err := hostStorage.DefaultProvider(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if provider == "" {
|
||||
return nil, fmt.Errorf("host file storage returned an empty default provider")
|
||||
}
|
||||
return GetProvider(enums.AssetProvider(provider))
|
||||
}
|
||||
return NewProvider(config.Current().Storage.Default)
|
||||
}
|
||||
|
||||
@@ -37,6 +59,12 @@ func GetProvider(providerType enums.AssetProvider) (FileStorageProvider, error)
|
||||
}
|
||||
|
||||
func NewProvider(provider enums.AssetProvider) (FileStorageProvider, error) {
|
||||
if hostStorage != nil {
|
||||
if provider == "" {
|
||||
return GetDefault()
|
||||
}
|
||||
return &hostFileStorageProvider{provider: provider}, nil
|
||||
}
|
||||
cfg := config.Current().Storage
|
||||
|
||||
switch provider {
|
||||
@@ -48,3 +76,45 @@ func NewProvider(provider enums.AssetProvider) (FileStorageProvider, error) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0082")
|
||||
}
|
||||
}
|
||||
|
||||
type hostFileStorageProvider struct {
|
||||
provider enums.AssetProvider
|
||||
}
|
||||
|
||||
func (p *hostFileStorageProvider) ProviderType() enums.AssetProvider {
|
||||
return p.provider
|
||||
}
|
||||
|
||||
func (p *hostFileStorageProvider) Upload(reader io.Reader, key string, info UploadInfo) (*StoredFile, error) {
|
||||
accessURL, err := hostStorage.Upload(
|
||||
context.Background(), string(p.provider), key, info.Filename, info.MimeType, info.FileSize, reader,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &StoredFile{
|
||||
Provider: p.provider,
|
||||
StorageKey: key,
|
||||
URL: accessURL,
|
||||
Filename: info.Filename,
|
||||
FileSize: info.FileSize,
|
||||
MimeType: info.MimeType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *hostFileStorageProvider) GetURL(key string) string {
|
||||
value, _ := hostStorage.URL(context.Background(), string(p.provider), key)
|
||||
return value
|
||||
}
|
||||
|
||||
func (p *hostFileStorageProvider) GetSignedURL(key string) string {
|
||||
return p.GetURL(key)
|
||||
}
|
||||
|
||||
func (p *hostFileStorageProvider) Delete(key string) error {
|
||||
return hostStorage.Delete(context.Background(), string(p.provider), key)
|
||||
}
|
||||
|
||||
func (p *hostFileStorageProvider) Read(key string) (io.ReadCloser, error) {
|
||||
return hostStorage.Open(context.Background(), string(p.provider), key)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeHostStorage struct {
|
||||
files map[string][]byte
|
||||
}
|
||||
|
||||
func (s *fakeHostStorage) DefaultProvider(context.Context) (string, error) {
|
||||
return "system", nil
|
||||
}
|
||||
|
||||
func (s *fakeHostStorage) Upload(
|
||||
_ context.Context,
|
||||
provider, key, _, _ string,
|
||||
_ int64,
|
||||
reader io.Reader,
|
||||
) (string, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.files[provider+":"+key] = data
|
||||
return "/files/" + key, nil
|
||||
}
|
||||
|
||||
func (s *fakeHostStorage) Open(_ context.Context, provider, key string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(s.files[provider+":"+key])), nil
|
||||
}
|
||||
|
||||
func (*fakeHostStorage) URL(_ context.Context, _ string, key string) (string, error) {
|
||||
return "/files/" + key, nil
|
||||
}
|
||||
|
||||
func (s *fakeHostStorage) Delete(_ context.Context, provider, key string) error {
|
||||
delete(s.files, provider+":"+key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHostStorageProvider(t *testing.T) {
|
||||
host := &fakeHostStorage{files: make(map[string][]byte)}
|
||||
SetHostStorage(host)
|
||||
t.Cleanup(func() { SetHostStorage(nil) })
|
||||
|
||||
provider, err := GetDefault()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDefault() error = %v", err)
|
||||
}
|
||||
if got := string(provider.ProviderType()); got != "system" {
|
||||
t.Fatalf("ProviderType() = %q, want system", got)
|
||||
}
|
||||
|
||||
stored, err := provider.Upload(strings.NewReader("hello"), "chat/a.txt", UploadInfo{
|
||||
Filename: "a.txt",
|
||||
FileSize: 5,
|
||||
MimeType: "text/plain",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upload() error = %v", err)
|
||||
}
|
||||
if stored.URL != "/files/chat/a.txt" {
|
||||
t.Fatalf("stored URL = %q", stored.URL)
|
||||
}
|
||||
|
||||
reader, err := provider.Read("chat/a.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Read() error = %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil || string(data) != "hello" {
|
||||
t.Fatalf("Read() = %q, %v", data, err)
|
||||
}
|
||||
|
||||
if err = provider.Delete("chat/a.txt"); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/identity"
|
||||
@@ -75,12 +76,37 @@ func (s *subjectService) CurrentExternal(ctx context.Context) (*openidentity.Ext
|
||||
ExternalSource: enums.ExternalSourceUser,
|
||||
ExternalID: fmt.Sprintf("%s:%d", subject.Type, subject.ID),
|
||||
ExternalName: subject.Name,
|
||||
SubjectType: subject.Type,
|
||||
SubjectID: subject.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveExternal returns a host-authenticated customer when available and
|
||||
// falls back to the opaque browser identifier used by anonymous Web visitors.
|
||||
// The identifier is not a login token and is never accepted for dashboard APIs.
|
||||
func (s *subjectService) ResolveExternal(ctx context.Context, guestID, guestName string) (*openidentity.ExternalUser, error) {
|
||||
external, err := s.CurrentExternal(ctx)
|
||||
if err == nil {
|
||||
return external, nil
|
||||
}
|
||||
guestID = strings.TrimSpace(guestID)
|
||||
if guestID == "" || len(guestID) > 128 {
|
||||
return nil, err
|
||||
}
|
||||
guestName = strings.TrimSpace(guestName)
|
||||
if len(guestName) > 255 {
|
||||
guestName = guestName[:255]
|
||||
}
|
||||
return &openidentity.ExternalUser{
|
||||
ExternalSource: enums.ExternalSourceGuest,
|
||||
ExternalID: guestID,
|
||||
ExternalName: guestName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *subjectService) Get(id int64) *identity.Subject {
|
||||
items, err := s.Query(context.Background(), identity.Query{
|
||||
Types: []identity.SubjectType{identity.SubjectAgent},
|
||||
Types: []identity.SubjectType{identity.SubjectAdmin},
|
||||
IDs: []int64{id},
|
||||
EnabledOnly: true,
|
||||
})
|
||||
@@ -99,7 +125,7 @@ func (s *subjectService) FindByIDs(ids []int64) []identity.Subject {
|
||||
return nil
|
||||
}
|
||||
items, err := s.Query(context.Background(), identity.Query{
|
||||
Types: []identity.SubjectType{identity.SubjectAgent},
|
||||
Types: []identity.SubjectType{identity.SubjectAdmin},
|
||||
IDs: ids,
|
||||
EnabledOnly: true,
|
||||
})
|
||||
@@ -109,3 +135,24 @@ func (s *subjectService) FindByIDs(ids []int64) []identity.Subject {
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *subjectService) IsUserReference(subjectType identity.SubjectType, id int64) bool {
|
||||
if id <= 0 {
|
||||
return false
|
||||
}
|
||||
switch subjectType {
|
||||
case identity.SubjectCard, identity.SubjectDevice, identity.SubjectMallUser:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
items, err := s.Query(context.Background(), identity.Query{
|
||||
Types: []identity.SubjectType{subjectType},
|
||||
IDs: []int64{id},
|
||||
EnabledOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("query external customer subject failed", "type", subjectType, "id", id, "error", err)
|
||||
return false
|
||||
}
|
||||
return len(items) > 0
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/httpx/params"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var SystemConfigService = newSystemConfigService()
|
||||
|
||||
func newSystemConfigService() *systemConfigService {
|
||||
return &systemConfigService{}
|
||||
}
|
||||
|
||||
type systemConfigService struct {
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Get(id int64) *models.SystemConfig {
|
||||
return repositories.SystemConfigRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Take(where ...interface{}) *models.SystemConfig {
|
||||
return repositories.SystemConfigRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Find(cnd *sqls.Cnd) []models.SystemConfig {
|
||||
return repositories.SystemConfigRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) FindOne(cnd *sqls.Cnd) *models.SystemConfig {
|
||||
return repositories.SystemConfigRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) FindPageByParams(params *params.QueryParams) (list []models.SystemConfig, paging *sqls.Paging) {
|
||||
return repositories.SystemConfigRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) FindPageByCnd(cnd *sqls.Cnd) (list []models.SystemConfig, paging *sqls.Paging) {
|
||||
return repositories.SystemConfigRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.SystemConfigRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Create(t *models.SystemConfig) error {
|
||||
return repositories.SystemConfigRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Update(t *models.SystemConfig) error {
|
||||
return repositories.SystemConfigRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.SystemConfigRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.SystemConfigRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Delete(id int64) {
|
||||
repositories.SystemConfigRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/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,
|
||||
})
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/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")
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/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)
|
||||
}
|
||||
@@ -1,690 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/events"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/eventbus"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/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]*ExternalUser
|
||||
}
|
||||
|
||||
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]*ExternalUser
|
||||
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 *ExternalUser, toUser *ExternalUser, 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 *ExternalUser) 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]*ExternalUser),
|
||||
}
|
||||
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 := UserService.FindByIds(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 := UserService.Get(req.ToUserID)
|
||||
if toUser == nil || toUser.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0334")
|
||||
}
|
||||
var fromUser *ExternalUser
|
||||
if ticket.CurrentAssigneeID > 0 {
|
||||
fromUser = UserService.Get(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]*ExternalUser),
|
||||
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 := UserService.FindByIds(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
|
||||
}
|
||||
@@ -1,703 +0,0 @@
|
||||
package services_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/bootstrap"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/events"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/eventbus"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
"code.tczkiot.com/wlw/ai-agent/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()
|
||||
id := time.Now().UnixNano()
|
||||
username := fmt.Sprintf("%s_%d", prefix, id)
|
||||
registerTestExternalSubject(id, username, prefix, status)
|
||||
return 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
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var TicketTagService = newTicketTagService()
|
||||
|
||||
func newTicketTagService() *ticketTagService {
|
||||
return &ticketTagService{}
|
||||
}
|
||||
|
||||
type ticketTagService struct{}
|
||||
|
||||
func (s *ticketTagService) Get(id int64) *models.TicketTag {
|
||||
return repositories.TicketTagRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *ticketTagService) Take(where ...interface{}) *models.TicketTag {
|
||||
return repositories.TicketTagRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *ticketTagService) Find(cnd *sqls.Cnd) []models.TicketTag {
|
||||
return repositories.TicketTagRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *ticketTagService) Create(db *gorm.DB, item *models.TicketTag) error {
|
||||
return repositories.TicketTagRepository.Create(db, item)
|
||||
}
|
||||
|
||||
func (s *ticketTagService) DeleteByTicketID(db *gorm.DB, ticketID int64) error {
|
||||
return repositories.TicketTagRepository.DeleteByTicketID(db, ticketID)
|
||||
}
|
||||
|
||||
func (s *ticketTagService) NormalizeTagIDs(tagIDs []int64) []int64 {
|
||||
if len(tagIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(tagIDs))
|
||||
result := make([]int64, 0, len(tagIDs))
|
||||
for _, tagID := range tagIDs {
|
||||
if tagID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[tagID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[tagID] = struct{}{}
|
||||
result = append(result, tagID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *ticketTagService) ValidateTagIDs(tagIDs []int64) ([]int64, error) {
|
||||
normalized := s.NormalizeTagIDs(tagIDs)
|
||||
if len(normalized) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
tags := repositories.TagRepository.Find(sqls.DB(), sqls.NewCnd().In("id", normalized))
|
||||
if len(tags) != len(normalized) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0153")
|
||||
}
|
||||
for i := range tags {
|
||||
if tags[i].Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0154")
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (s *ticketTagService) ReplaceTicketTags(db *gorm.DB, ticketID int64, tagIDs []int64, operator *dto.AuthPrincipal) error {
|
||||
if err := s.DeleteByTicketID(db, ticketID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tagIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
for _, tagID := range tagIDs {
|
||||
if err := s.Create(db, &models.TicketTag{
|
||||
TicketID: ticketID,
|
||||
TagID: tagID,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: operator.UserID,
|
||||
CreateUserName: operator.Username,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: operator.UserID,
|
||||
UpdateUserName: operator.Username,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var TicketViewService = newTicketViewService()
|
||||
|
||||
func newTicketViewService() *ticketViewService {
|
||||
return &ticketViewService{}
|
||||
}
|
||||
|
||||
type ticketViewService struct {
|
||||
}
|
||||
|
||||
func (s *ticketViewService) Get(id int64) *models.TicketView {
|
||||
return repositories.TicketViewRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *ticketViewService) Find(cnd *sqls.Cnd) []models.TicketView {
|
||||
return repositories.TicketViewRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *ticketViewService) ListByUser(userID int64) []models.TicketView {
|
||||
if userID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return s.Find(sqls.NewCnd().Eq("user_id", userID).Asc("sort_no").Desc("id"))
|
||||
}
|
||||
|
||||
func (s *ticketViewService) Save(req request.SaveTicketViewRequest, operator *dto.AuthPrincipal) (*models.TicketView, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0303")
|
||||
}
|
||||
filtersJSON, err := json.Marshal(req.Filters)
|
||||
if err != nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0304")
|
||||
}
|
||||
now := time.Now()
|
||||
if req.ID > 0 {
|
||||
item := repositories.TicketViewRepository.Get(sqls.DB(), req.ID)
|
||||
if item == nil || item.UserID != operator.UserID {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0302")
|
||||
}
|
||||
if err := repositories.TicketViewRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"name": name,
|
||||
"filters_json": string(filtersJSON),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repositories.TicketViewRepository.Get(sqls.DB(), req.ID), nil
|
||||
}
|
||||
item := &models.TicketView{
|
||||
UserID: operator.UserID,
|
||||
Name: name,
|
||||
FiltersJSON: string(filtersJSON),
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
if err := repositories.TicketViewRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *ticketViewService) Delete(id int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
item := repositories.TicketViewRepository.Get(sqls.DB(), id)
|
||||
if item == nil || item.UserID != operator.UserID {
|
||||
return errorsx.InvalidParamI18n("error.e0302")
|
||||
}
|
||||
return repositories.TicketViewRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/mcps"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
var ToolCatalogService = newToolCatalogService()
|
||||
|
||||
func newToolCatalogService() *toolCatalogService {
|
||||
return &toolCatalogService{}
|
||||
}
|
||||
|
||||
type toolCatalogService struct{}
|
||||
|
||||
type MCPToolCatalogItem struct {
|
||||
ToolCode string
|
||||
ServerCode string
|
||||
ToolName string
|
||||
SourceType enums.ToolSourceType
|
||||
AutoInjected bool
|
||||
Title string
|
||||
Description string
|
||||
InputSchema any
|
||||
OutputSchema any
|
||||
RiskLevel string
|
||||
RequireConfirmation bool
|
||||
RiskEditable bool
|
||||
}
|
||||
|
||||
func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalogItem, error) {
|
||||
return s.ListMCPToolsWithLocale(ctx, i18nx.DefaultLocale)
|
||||
}
|
||||
|
||||
func (s *toolCatalogService) ListMCPToolsWithLocale(ctx context.Context, locale string) ([]MCPToolCatalogItem, error) {
|
||||
cfg := config.Current()
|
||||
ret := make([]MCPToolCatalogItem, 0, 3)
|
||||
for _, spec := range toolx.ListAgentDirectToolSpecs() {
|
||||
if spec.Code == toolx.BuiltinToolSearch.Code && !cfg.MCP.Enabled {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, MCPToolCatalogItem{
|
||||
ToolCode: spec.Code,
|
||||
ServerCode: spec.ServerCode,
|
||||
ToolName: spec.Name,
|
||||
SourceType: spec.SourceType,
|
||||
AutoInjected: spec.AutoInjected,
|
||||
Title: toolx.GetRegisteredToolTitleLocale(spec.Code, locale),
|
||||
Description: toolx.GetRegisteredToolDescriptionLocale(spec.Code, locale),
|
||||
})
|
||||
}
|
||||
if !cfg.MCP.Enabled {
|
||||
return ret, nil
|
||||
}
|
||||
serverCodes := make([]string, 0, len(cfg.MCP.Servers))
|
||||
for serverCode, server := range cfg.MCP.Servers {
|
||||
if !server.Enabled {
|
||||
continue
|
||||
}
|
||||
serverCodes = append(serverCodes, serverCode)
|
||||
}
|
||||
slices.Sort(serverCodes)
|
||||
for _, serverCode := range serverCodes {
|
||||
tools, err := mcps.Runtime.ListTools(ctx, serverCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range tools {
|
||||
toolCode := toolx.BuildMCPToolCode(serverCode, item.Name)
|
||||
title := strings.TrimSpace(item.Title)
|
||||
riskLevel := toolx.MCPRiskLevelWrite
|
||||
requireConfirmation := true
|
||||
riskEditable := true
|
||||
if item.ReadOnlyHint {
|
||||
riskLevel = toolx.MCPRiskLevelRead
|
||||
requireConfirmation = false
|
||||
}
|
||||
if policy, ok := toolx.GetTrustedMCPToolPolicy(toolCode); ok {
|
||||
title = policy.Title
|
||||
riskLevel = policy.RiskLevel
|
||||
requireConfirmation = policy.RequireConfirmation
|
||||
riskEditable = false
|
||||
}
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(item.Name)
|
||||
}
|
||||
ret = append(ret, MCPToolCatalogItem{
|
||||
ToolCode: toolCode,
|
||||
ServerCode: serverCode,
|
||||
ToolName: strings.TrimSpace(item.Name),
|
||||
SourceType: enums.ToolSourceTypeMCP,
|
||||
AutoInjected: false,
|
||||
Title: title,
|
||||
Description: strings.TrimSpace(item.Description),
|
||||
InputSchema: item.InputSchema,
|
||||
OutputSchema: item.OutputSchema,
|
||||
RiskLevel: riskLevel,
|
||||
RequireConfirmation: requireConfirmation,
|
||||
RiskEditable: riskEditable,
|
||||
})
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *toolCatalogService) ValidateMCPToolCode(toolCode string) error {
|
||||
return s.ValidateToolCode(toolCode)
|
||||
}
|
||||
|
||||
func (s *toolCatalogService) ValidateToolCode(toolCode string) error {
|
||||
cfg := config.Current()
|
||||
toolCode = strings.TrimSpace(toolCode)
|
||||
if toolCode == "" {
|
||||
return errorsx.InvalidParamI18n("error.e0074")
|
||||
}
|
||||
if toolx.IsAgentDirectToolCode(toolCode) {
|
||||
return nil
|
||||
}
|
||||
serverCode, toolName := toolx.SplitMCPToolCode(toolCode)
|
||||
if serverCode == "" || toolName == "" {
|
||||
return errorsx.InvalidParamI18n("error.e0075")
|
||||
}
|
||||
if !cfg.MCP.Enabled {
|
||||
return errorsx.InvalidParamI18n("error.e0035")
|
||||
}
|
||||
server, ok := cfg.MCP.Servers[serverCode]
|
||||
if !ok || !server.Enabled {
|
||||
return errorsx.InvalidParamI18n("error.e0073")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
)
|
||||
|
||||
// ExternalUser is a non-persistent display adapter for a system identity owned
|
||||
// by be-system.
|
||||
// ExternalUser is a non-persistent display adapter for an administrator
|
||||
// identity owned by be-system.
|
||||
type ExternalUser struct {
|
||||
ID int64
|
||||
SubjectType identity.SubjectType
|
||||
@@ -35,7 +35,7 @@ func (s *externalUserService) FindByIds(ids []int64) []ExternalUser {
|
||||
return nil
|
||||
}
|
||||
subjects, err := SubjectService.Query(context.Background(), identity.Query{
|
||||
Types: []identity.SubjectType{identity.SubjectAgent},
|
||||
Types: []identity.SubjectType{identity.SubjectAdmin},
|
||||
IDs: ids,
|
||||
EnabledOnly: true,
|
||||
})
|
||||
@@ -62,7 +62,7 @@ func (s *externalUserService) FindByIds(ids []int64) []ExternalUser {
|
||||
|
||||
func (s *externalUserService) Find(keyword string) []ExternalUser {
|
||||
subjects, err := SubjectService.Query(context.Background(), identity.Query{
|
||||
Types: []identity.SubjectType{identity.SubjectAgent},
|
||||
Types: []identity.SubjectType{identity.SubjectAdmin},
|
||||
Keyword: keyword,
|
||||
EnabledOnly: true,
|
||||
})
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/response"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/openidentity"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
@@ -37,7 +40,7 @@ const (
|
||||
)
|
||||
|
||||
type RealtimeEvent struct {
|
||||
EventID string `json:"eventId"`
|
||||
EventID string `json:"event_id"`
|
||||
Type string `json:"type"`
|
||||
Topic string `json:"topic,omitempty"`
|
||||
Data RealtimeEventPayload `json:"data,omitempty"`
|
||||
@@ -54,11 +57,11 @@ type RealtimeEventPayload interface {
|
||||
}
|
||||
|
||||
type RealtimeConnectedPayload struct {
|
||||
ConnID string `json:"connId,omitempty"`
|
||||
UserID int64 `json:"userId,omitempty"`
|
||||
GuestID string `json:"guestId,omitempty"`
|
||||
ConnID string `json:"conn_id,omitempty"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
GuestID string `json:"guest_id,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
TerminalType string `json:"terminalType,omitempty"`
|
||||
TerminalType string `json:"terminal_type,omitempty"`
|
||||
Topics []string `json:"topics,omitempty"`
|
||||
}
|
||||
|
||||
@@ -135,19 +138,19 @@ func (e RealtimeResyncRequiredEvent) EventPayload() RealtimeEventPayload {
|
||||
}
|
||||
|
||||
type RealtimeMessageCreatedPayload struct {
|
||||
ConversationID int64 `json:"conversationId,omitempty"`
|
||||
MessageID int64 `json:"messageId,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
ConversationID int64 `json:"conversation_id,omitempty"`
|
||||
MessageID int64 `json:"message_id,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Message response.MessageResponse `json:"message,omitempty"`
|
||||
Status enums.IMConversationStatus `json:"status,omitempty"`
|
||||
CurrentAssigneeID int64 `json:"currentAssigneeId,omitempty"`
|
||||
SenderType enums.IMSenderType `json:"senderType,omitempty"`
|
||||
SenderID int64 `json:"senderId,omitempty"`
|
||||
MessageType enums.IMMessageType `json:"messageType,omitempty"`
|
||||
CurrentAssigneeID int64 `json:"current_assignee_id,omitempty"`
|
||||
SenderType enums.IMSenderType `json:"sender_type,omitempty"`
|
||||
SenderID int64 `json:"sender_id,omitempty"`
|
||||
MessageType enums.IMMessageType `json:"message_type,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Payload string `json:"payload,omitempty"`
|
||||
SendStatus enums.IMMessageStatus `json:"sendStatus,omitempty"`
|
||||
SentAt string `json:"sentAt,omitempty"`
|
||||
SendStatus enums.IMMessageStatus `json:"send_status,omitempty"`
|
||||
SentAt string `json:"sent_at,omitempty"`
|
||||
}
|
||||
|
||||
func (RealtimeMessageCreatedPayload) realtimeEventPayload() {}
|
||||
@@ -165,12 +168,12 @@ func (e RealtimeMessageCreatedEvent) EventPayload() RealtimeEventPayload {
|
||||
}
|
||||
|
||||
type RealtimeMessageRecalledPayload struct {
|
||||
ConversationID int64 `json:"conversationId,omitempty"`
|
||||
MessageID int64 `json:"messageId,omitempty"`
|
||||
SenderType enums.IMSenderType `json:"senderType,omitempty"`
|
||||
SenderID int64 `json:"senderId,omitempty"`
|
||||
SendStatus enums.IMMessageStatus `json:"sendStatus,omitempty"`
|
||||
RecalledAt string `json:"recalledAt,omitempty"`
|
||||
ConversationID int64 `json:"conversation_id,omitempty"`
|
||||
MessageID int64 `json:"message_id,omitempty"`
|
||||
SenderType enums.IMSenderType `json:"sender_type,omitempty"`
|
||||
SenderID int64 `json:"sender_id,omitempty"`
|
||||
SendStatus enums.IMMessageStatus `json:"send_status,omitempty"`
|
||||
RecalledAt string `json:"recalled_at,omitempty"`
|
||||
}
|
||||
|
||||
func (RealtimeMessageRecalledPayload) realtimeEventPayload() {}
|
||||
@@ -188,21 +191,30 @@ func (e RealtimeMessageRecalledEvent) EventPayload() RealtimeEventPayload {
|
||||
}
|
||||
|
||||
type RealtimeConversationChangedPayload struct {
|
||||
ConversationID int64 `json:"conversationId,omitempty"`
|
||||
ConversationID int64 `json:"conversation_id,omitempty"`
|
||||
Status enums.IMConversationStatus `json:"status,omitempty"`
|
||||
ServiceMode enums.IMConversationServiceMode `json:"serviceMode,omitempty"`
|
||||
CurrentAssigneeID int64 `json:"currentAssigneeId,omitempty"`
|
||||
CurrentTeamID int64 `json:"currentTeamId,omitempty"`
|
||||
LastMessageID int64 `json:"lastMessageId,omitempty"`
|
||||
LastMessageAt string `json:"lastMessageAt,omitempty"`
|
||||
LastActiveAt string `json:"lastActiveAt,omitempty"`
|
||||
LastMessageSummary string `json:"lastMessageSummary,omitempty"`
|
||||
CustomerUnreadCount int `json:"customerUnreadCount,omitempty"`
|
||||
AgentUnreadCount int `json:"agentUnreadCount,omitempty"`
|
||||
CustomerLastReadMessageID int64 `json:"customerLastReadMessageId,omitempty"`
|
||||
CustomerLastReadAt string `json:"customerLastReadAt,omitempty"`
|
||||
AgentLastReadMessageID int64 `json:"agentLastReadMessageId,omitempty"`
|
||||
AgentLastReadAt string `json:"agentLastReadAt,omitempty"`
|
||||
ServiceMode enums.IMConversationServiceMode `json:"service_mode,omitempty"`
|
||||
CurrentAssigneeID int64 `json:"current_assignee_id,omitempty"`
|
||||
CurrentTeamID int64 `json:"current_team_id,omitempty"`
|
||||
LastMessageID int64 `json:"last_message_id,omitempty"`
|
||||
LastMessageAt string `json:"last_message_at,omitempty"`
|
||||
LastActiveAt string `json:"last_active_at,omitempty"`
|
||||
LastMessageSummary string `json:"last_message_summary,omitempty"`
|
||||
CustomerUnreadCount int `json:"customer_unread_count,omitempty"`
|
||||
AgentUnreadCount int `json:"agent_unread_count,omitempty"`
|
||||
CustomerLastReadMessageID int64 `json:"customer_last_read_message_id,omitempty"`
|
||||
CustomerLastReadAt string `json:"customer_last_read_at,omitempty"`
|
||||
AgentLastReadMessageID int64 `json:"agent_last_read_message_id,omitempty"`
|
||||
AgentLastReadAt string `json:"agent_last_read_at,omitempty"`
|
||||
QueueEnteredAt string `json:"queue_entered_at"`
|
||||
QueuePosition int `json:"queue_position"`
|
||||
QueueAheadCount int `json:"queue_ahead_count"`
|
||||
QueueWaitingCount int `json:"queue_waiting_count"`
|
||||
QueueWaitSeconds int64 `json:"queue_wait_seconds"`
|
||||
QueueEstimatedWaitSeconds int64 `json:"queue_estimated_wait_seconds"`
|
||||
QueueEscalationLevel int `json:"queue_escalation_level"`
|
||||
EffectivePriority int `json:"effective_priority"`
|
||||
QueueServiceOnline bool `json:"queue_service_online"`
|
||||
}
|
||||
|
||||
func (RealtimeConversationChangedPayload) realtimeEventPayload() {}
|
||||
@@ -241,7 +253,7 @@ func (e RealtimeNotificationCreatedEvent) EventPayload() RealtimeEventPayload {
|
||||
type realtimeClientMessage struct {
|
||||
Type string `json:"type"`
|
||||
Topics []string `json:"topics,omitempty"`
|
||||
EventID string `json:"eventId,omitempty"`
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
}
|
||||
|
||||
type ClientSession struct {
|
||||
@@ -271,13 +283,63 @@ func (s *ClientSession) enqueue(payload []byte) bool {
|
||||
}
|
||||
|
||||
func (s *ClientSession) enqueueEvent(event RealtimeEvent) bool {
|
||||
payload, err := json.Marshal(event)
|
||||
payload, err := marshalRealtimeEvent(event)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return s.enqueue(payload)
|
||||
}
|
||||
|
||||
func marshalRealtimeEvent(event RealtimeEvent) ([]byte, error) {
|
||||
body, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var value any
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(mapRealtimeKeys(value))
|
||||
}
|
||||
|
||||
func mapRealtimeKeys(value any) any {
|
||||
switch item := value.(type) {
|
||||
case map[string]any:
|
||||
mapped := make(map[string]any, len(item))
|
||||
for key, child := range item {
|
||||
mapped[realtimeCamelToSnake(key)] = mapRealtimeKeys(child)
|
||||
}
|
||||
return mapped
|
||||
case []any:
|
||||
mapped := make([]any, len(item))
|
||||
for index, child := range item {
|
||||
mapped[index] = mapRealtimeKeys(child)
|
||||
}
|
||||
return mapped
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func realtimeCamelToSnake(value string) string {
|
||||
runes := []rune(value)
|
||||
var output strings.Builder
|
||||
for index, current := range runes {
|
||||
if unicode.IsUpper(current) {
|
||||
if index > 0 && (unicode.IsLower(runes[index-1]) || unicode.IsDigit(runes[index-1]) ||
|
||||
(index+1 < len(runes) && unicode.IsLower(runes[index+1]))) {
|
||||
output.WriteByte('_')
|
||||
}
|
||||
output.WriteRune(unicode.ToLower(current))
|
||||
continue
|
||||
}
|
||||
output.WriteRune(current)
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func (s *ClientSession) touch() {
|
||||
if s == nil {
|
||||
return
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/response"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/httpx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/openidentity"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
"encoding/json"
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/mlogclub/simple/web"
|
||||
)
|
||||
|
||||
var WsService = newWsService()
|
||||
@@ -45,7 +44,7 @@ func newWsService() *wsService {
|
||||
func (s *wsService) HandleDashboardWS(ctx *gin.Context) {
|
||||
principal := AuthService.GetAuthPrincipal(ctx)
|
||||
if principal == nil {
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonErrorCode(errorsx.CodeAuthUnauthorized, i18nx.T(ctx, "error.auth.expired")))
|
||||
httpx.AbortJSON(ctx, http.StatusUnauthorized, errorsx.UnauthorizedI18n("error.auth.expired"))
|
||||
return
|
||||
}
|
||||
if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleAdmin); err != nil {
|
||||
@@ -58,7 +57,7 @@ func (s *wsService) HandleDashboardWS(ctx *gin.Context) {
|
||||
func (s *wsService) HandleDashboardNotificationWS(ctx *gin.Context) {
|
||||
principal := AuthService.GetAuthPrincipal(ctx)
|
||||
if principal == nil {
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonErrorCode(errorsx.CodeAuthUnauthorized, i18nx.T(ctx, "error.auth.expired")))
|
||||
httpx.AbortJSON(ctx, http.StatusUnauthorized, errorsx.UnauthorizedI18n("error.auth.expired"))
|
||||
return
|
||||
}
|
||||
if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleNotification); err != nil {
|
||||
@@ -71,7 +70,7 @@ func (s *wsService) HandleDashboardNotificationWS(ctx *gin.Context) {
|
||||
func (s *wsService) HandleOpenWS(ctx *gin.Context) {
|
||||
channel := ChannelService.GetEnabledChannel(ctx)
|
||||
if channel == nil {
|
||||
ctx.AbortWithStatusJSON(http.StatusBadRequest, web.JsonErrorCode(errorsx.CodeInvalidParam, i18nx.T(ctx, "error.e0209")))
|
||||
httpx.AbortJSON(ctx, http.StatusBadRequest, errorsx.InvalidParamI18n("error.e0209"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,9 +80,13 @@ func (s *wsService) HandleOpenWS(ctx *gin.Context) {
|
||||
)
|
||||
if principal == nil {
|
||||
var err error
|
||||
external, err = SubjectService.CurrentExternal(ctx.Request.Context())
|
||||
external, err = SubjectService.ResolveExternal(
|
||||
ctx.Request.Context(),
|
||||
strings.TrimSpace(ctx.Query("external_id")),
|
||||
strings.TrimSpace(ctx.Query("external_name")),
|
||||
)
|
||||
if err != nil {
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err))
|
||||
httpx.AbortJSON(ctx, http.StatusUnauthorized, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -95,7 +98,7 @@ func (s *wsService) HandleOpenWS(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalUser, role string) error {
|
||||
conn, err := s.upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
|
||||
conn, err := s.upgrader.Upgrade(ctx.Writer, ctx.Request, websocketUpgradeHeader(ctx.Request))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -133,7 +136,7 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
|
||||
"connId", session.ID,
|
||||
"role", session.Role,
|
||||
"userId", logUserID,
|
||||
"externalId", logExternalID,
|
||||
"external_id", logExternalID,
|
||||
"terminalType", session.TerminalType,
|
||||
"topicCount", len(session.Topics),
|
||||
"sessionCount", sessionCount,
|
||||
@@ -155,6 +158,20 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
|
||||
return nil
|
||||
}
|
||||
|
||||
// websocketUpgradeHeader echoes the bearer subprotocol selected by the host
|
||||
// authentication middleware. Browsers reject an upgrade when they request a
|
||||
// subprotocol and the server does not return the selected value.
|
||||
func websocketUpgradeHeader(req *http.Request) http.Header {
|
||||
for _, protocol := range websocket.Subprotocols(req) {
|
||||
if strings.HasPrefix(strings.ToLower(protocol), "bearer.") {
|
||||
header := make(http.Header)
|
||||
header.Set("Sec-WebSocket-Protocol", protocol)
|
||||
return header
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wsService) readPump(session *ClientSession) {
|
||||
defer s.closeSession(session)
|
||||
|
||||
@@ -259,7 +276,7 @@ func (s *wsService) closeSession(session *ClientSession) {
|
||||
"connId", session.ID,
|
||||
"role", session.Role,
|
||||
"userId", discUserID,
|
||||
"externalId", discExternalID,
|
||||
"external_id", discExternalID,
|
||||
"terminalType", session.TerminalType,
|
||||
"sessionCount", remaining,
|
||||
)
|
||||
@@ -318,7 +335,6 @@ func (s *wsService) buildRealtimeMessage(item *models.Message) response.MessageR
|
||||
ID: item.ID,
|
||||
ConversationID: item.ConversationID,
|
||||
RequestID: item.RequestID,
|
||||
WorkflowRunID: item.WorkflowRunID,
|
||||
ClientMsgID: item.ClientMsgID,
|
||||
SenderType: item.SenderType,
|
||||
SenderID: item.SenderID,
|
||||
@@ -348,6 +364,7 @@ func (s *wsService) fillRealtimeMessageSender(ret *response.MessageResponse, ite
|
||||
case enums.IMSenderTypeAI:
|
||||
if aiAgent := AIAgentService.Get(item.SenderID); aiAgent != nil {
|
||||
ret.SenderName = aiAgent.Name
|
||||
ret.SenderAvatar = strings.TrimSpace(aiAgent.Avatar)
|
||||
}
|
||||
case enums.IMSenderTypeAgent:
|
||||
if profile := AgentProfileService.GetByUserID(item.SenderID); profile != nil {
|
||||
@@ -412,6 +429,7 @@ func (s *wsService) PublishConversationChanged(conversation *models.Conversation
|
||||
return
|
||||
}
|
||||
agentReadState, customerReadState := ConversationReadStateService.GetConversationReadStates(conversation.ID)
|
||||
queueSnapshot := ConversationQueueService.GetSnapshot(conversation)
|
||||
|
||||
event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeConversationChangedEvent{
|
||||
Type: eventType,
|
||||
@@ -431,6 +449,15 @@ func (s *wsService) PublishConversationChanged(conversation *models.Conversation
|
||||
CustomerLastReadAt: readStateAt(customerReadState),
|
||||
AgentLastReadMessageID: readStateMessageID(agentReadState),
|
||||
AgentLastReadAt: readStateAt(agentReadState),
|
||||
QueueEnteredAt: utils.FormatTimePtr(queueSnapshot.EnteredAt),
|
||||
QueuePosition: queueSnapshot.Position,
|
||||
QueueAheadCount: queueSnapshot.AheadCount,
|
||||
QueueWaitingCount: queueSnapshot.WaitingCount,
|
||||
QueueWaitSeconds: queueSnapshot.WaitSeconds,
|
||||
QueueEstimatedWaitSeconds: queueSnapshot.EstimatedWaitSeconds,
|
||||
QueueEscalationLevel: queueSnapshot.EscalationLevel,
|
||||
EffectivePriority: queueSnapshot.EffectivePriority,
|
||||
QueueServiceOnline: queueSnapshot.ServiceOnline,
|
||||
},
|
||||
})
|
||||
s.PublishToTopics(s.routeConversationTopics(conversation), event)
|
||||
@@ -502,7 +529,7 @@ func (s *wsService) PublishToTopics(topics []string, event RealtimeEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(event)
|
||||
payload, err := marshalRealtimeEvent(event)
|
||||
if err != nil {
|
||||
slog.Error("marshal realtime event failed", "error", err, "type", event.Type)
|
||||
return
|
||||
@@ -564,7 +591,7 @@ func (s *wsService) defaultTopics(session *ClientSession) []string {
|
||||
}
|
||||
return []string{s.adminTopic(session.Principal.UserID), realtimeTopicAdminAll}
|
||||
default:
|
||||
// 开放 IM:仅 External、无 AuthPrincipal 的访客连接必须仍能订阅 guest:{externalId},否则收不到推送。
|
||||
// 开放 IM:仅 External、无 AuthPrincipal 的访客连接必须仍能订阅 guest:{external_id},否则收不到推送。
|
||||
if session.External != nil && strings.TrimSpace(session.External.ExternalID) != "" {
|
||||
return []string{s.guestTopic(session.External.ExternalID)}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,62 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/response"
|
||||
)
|
||||
|
||||
func TestRealtimeEventJSONUsesSnakeCaseFields(t *testing.T) {
|
||||
event := RealtimeEvent{
|
||||
EventID: "event-1",
|
||||
Type: "message.created",
|
||||
Data: RealtimeMessageCreatedPayload{
|
||||
ConversationID: 12,
|
||||
MessageID: 34,
|
||||
},
|
||||
}
|
||||
body, err := marshalRealtimeEvent(event)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(body)
|
||||
for _, field := range []string{`"event_id"`, `"conversation_id"`, `"message_id"`} {
|
||||
if !strings.Contains(text, field) {
|
||||
t.Fatalf("expected %s in %s", field, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "eventId") || strings.Contains(text, "conversationId") {
|
||||
t.Fatalf("unexpected camelCase field in %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebsocketUpgradeHeaderEchoesBearerProtocol(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "http://example.com/api/ws/dashboard", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Sec-WebSocket-Protocol", "bearer.header-safe-token")
|
||||
|
||||
header := websocketUpgradeHeader(req)
|
||||
if got := header.Get("Sec-WebSocket-Protocol"); got != "bearer.header-safe-token" {
|
||||
t.Fatalf("expected bearer protocol to be echoed, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebsocketUpgradeHeaderIgnoresNonBearerProtocol(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "http://example.com/api/ws/dashboard", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Sec-WebSocket-Protocol", "chat")
|
||||
|
||||
if header := websocketUpgradeHeader(req); header != nil {
|
||||
t.Fatalf("expected non-bearer protocol to be ignored, got %v", header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWsNotificationTopic(t *testing.T) {
|
||||
svc := newWsService()
|
||||
if got := svc.notificationTopic(123); got != "notification:123" {
|
||||
|
||||
@@ -524,7 +524,7 @@ func (s *wxWorkKFInboundService) buildInboundAssetPayload(conversationID int64,
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
asset, err := AssetService.UploadBytes(data, "", "", nil)
|
||||
asset, err := AssetService.UploadConversationBytes(data, "", "", conversationID, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
@@ -192,11 +192,11 @@ func (s *wxWorkKFOutboundService) processOutbox(outboxID int64) error {
|
||||
rawPayload := strings.TrimSpace(outbox.Payload)
|
||||
if len(chunks) > i {
|
||||
if payload, err := json.Marshal(map[string]any{
|
||||
"messageId": message.ID,
|
||||
"chunkIndex": i,
|
||||
"chunkType": chunks[i].MessageType,
|
||||
"chunkText": strings.TrimSpace(chunks[i].Content),
|
||||
"chunkAssetId": strings.TrimSpace(chunks[i].AssetID),
|
||||
"message_id": message.ID,
|
||||
"chunk_index": i,
|
||||
"chunk_type": chunks[i].MessageType,
|
||||
"chunk_text": strings.TrimSpace(chunks[i].Content),
|
||||
"chunk_asset_id": strings.TrimSpace(chunks[i].AssetID),
|
||||
}); err == nil {
|
||||
rawPayload = string(payload)
|
||||
}
|
||||
@@ -406,12 +406,12 @@ func (s *wxWorkKFOutboundService) buildOutboundClientMsgID(messageID int64, chun
|
||||
}
|
||||
|
||||
type wxWorkKFOutboundPayload struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
MessageID int64 `json:"messageId"`
|
||||
MessageType enums.IMMessageType `json:"messageType"`
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
MessageType enums.IMMessageType `json:"message_type"`
|
||||
Content string `json:"content"`
|
||||
Payload string `json:"payload"`
|
||||
SenderID int64 `json:"senderId"`
|
||||
SenderID int64 `json:"sender_id"`
|
||||
}
|
||||
|
||||
func (s *wxWorkKFOutboundService) parseOutboxPayload(raw string) (*wxWorkKFOutboundPayload, error) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/identity"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/wxwork"
|
||||
|
||||
"github.com/mlogclub/simple/common/arrs"
|
||||
@@ -39,7 +38,8 @@ func (s *wxWorkNotifyService) Enabled() bool {
|
||||
if !wxwork.Enabled() {
|
||||
return false
|
||||
}
|
||||
return config.Current().WxWork.Notify.Enabled
|
||||
cfg, err := wxwork.CurrentConfig()
|
||||
return err == nil && cfg.Notify.Enabled
|
||||
}
|
||||
|
||||
func (s *wxWorkNotifyService) SendTextToAssigneeOrDefault(assigneeID int64, title, body string) error {
|
||||
@@ -68,7 +68,10 @@ func (s *wxWorkNotifyService) sendText(title, body string, toUsers []string) err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := config.Current().WxWork
|
||||
cfg, err := wxwork.CurrentConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := wxmessage.SendTextRequest{
|
||||
SendRequestCommon: &wxmessage.SendRequestCommon{
|
||||
ToUser: strings.Join(toUsers, "|"),
|
||||
@@ -89,7 +92,7 @@ func (s *wxWorkNotifyService) resolveToUsersByUserIDs(userIDs []int64) []string
|
||||
return nil
|
||||
}
|
||||
subjects, err := SubjectService.Query(context.Background(), identity.Query{
|
||||
Types: []identity.SubjectType{identity.SubjectAdmin, identity.SubjectAgent},
|
||||
Types: []identity.SubjectType{identity.SubjectAdmin},
|
||||
IDs: userIDs,
|
||||
EnabledOnly: true,
|
||||
})
|
||||
@@ -106,8 +109,11 @@ func (s *wxWorkNotifyService) resolveToUsersByUserIDs(userIDs []int64) []string
|
||||
}
|
||||
|
||||
func (s *wxWorkNotifyService) defaultToUsers() []string {
|
||||
cfg := config.Current().WxWork.Notify
|
||||
return s.resolveToUsersByUserIDs(cfg.ToUsers)
|
||||
cfg, err := wxwork.CurrentConfig()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return s.resolveToUsersByUserIDs(cfg.Notify.ToUsers)
|
||||
}
|
||||
|
||||
func (s *wxWorkNotifyService) buildTextContent(title, body string) string {
|
||||
|
||||
Reference in New Issue
Block a user