refactor: 将客服后端重构为宿主可嵌入模块

- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
t
2026-08-28 22:23:13 +08:00
parent 6845c728f8
commit 18c9354095
377 changed files with 13199 additions and 22881 deletions
+31 -48
View File
@@ -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
+67 -64
View File
@@ -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,
}
+26 -24
View File
@@ -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")
}
}
+45 -150
View File
@@ -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 {
+9 -2
View File
@@ -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
}
+6 -2
View File
@@ -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)
-649
View File
@@ -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",
}
}
+85 -39
View File
@@ -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 {
+2 -1
View File
@@ -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")
}
+65
View File
@@ -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
}
+14 -46
View File
@@ -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
+1 -1
View File
@@ -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)
-150
View File
@@ -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(&currentProfile).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
}
+123 -151
View File
@@ -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)
}
}
-373
View File
@@ -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
}
-100
View File
@@ -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")
}
+9 -2
View File
@@ -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
}
+67 -15
View File
@@ -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{
-155
View File
@@ -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)
}
+97 -25
View File
@@ -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) == "" {
+84 -35
View File
@@ -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() {
-67
View File
@@ -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)
}
+10 -10
View File
@@ -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)
}
+65
View File
@@ -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 -5
View File
@@ -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 {
+71 -1
View File
@@ -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)
}
}
+49 -2
View File
@@ -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)
}
-269
View File
@@ -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,
})
}
-123
View File
@@ -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)
}
-690
View File
@@ -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
}
-703
View File
@@ -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
}
-104
View File
@@ -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
}
-92
View File
@@ -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)
}
-139
View File
@@ -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
}
+4 -4
View File
@@ -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,
})
+98 -36
View File
@@ -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
+40 -13
View File
@@ -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)}
}
+51
View File
@@ -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) {
+12 -6
View File
@@ -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 {