From 81662556e8e1d4085ed50ed293d7f6700bd7d58a Mon Sep 17 00:00:00 2001 From: lzf <3153566913@qq.com> Date: Tue, 14 Jul 2026 09:57:37 +0800 Subject: [PATCH 1/3] feat(agent): add unified backend runtime and quality gates --- .github/workflows/ci.yml | 51 +- .gitignore | 5 + ...14\346\227\266\350\256\276\350\256\241.md" | 575 +++++++++ ...73\347\220\206\350\256\241\345\210\222.md" | 187 +++ .../13-Code-Nest-full-site-test-results.md | 233 ++++ AI-DOCS/Technical/README.md | 1 + docker/ai/README.md | 1 + docker/ai/docker-compose.yml | 1 + docker/env/example.env | 1 + docs-site/operations/env-vars.md | 2 + docs-site/operations/troubleshooting.md | 1 + docs-site/reference/env-vars.md | 2 + scripts/agent-runtime-eval.ps1 | 247 ++++ scripts/code-nest-eval.ps1 | 389 ++++++ sql/MySql/code_nest.sql | 55 + sql/MySql/code_nest_data.sql | 42 + sql/v2.4.0/admin_agent_audit.sql | 35 + sql/v2.4.0/admin_agent_audit_idempotency.sql | 55 + sql/v2.4.0/admin_agent_permissions.sql | 44 + sql/v2.4.0/admin_agent_session_context.sql | 10 + vue3-admin-front/src/api/agentChat.js | 9 + vue3-admin-front/src/api/chat.js | 5 + vue3-admin-front/src/api/community.js | 6 + .../src/components/agent/AdminAgentDrawer.vue | 500 ++++++++ vue3-admin-front/src/layout/index.vue | 3 + .../tests/admin-agent-chat-ui.test.js | 38 + .../tests/admin-api-contract.test.js | 27 + .../com/xiaou/ai/client/AiModelFactory.java | 23 +- .../com/xiaou/ai/prompt/AiPromptCatalog.java | 2 + .../com/xiaou/ai/prompt/AiPromptSpec.java | 15 +- .../prompt/admin/AdminAgentPromptSpecs.java | 60 + .../AiStructuredJsonSchemaBuilder.java | 16 + .../AiStructuredObjectContract.java | 4 + .../structured/AiStructuredOutputCatalog.java | 2 + .../AiStructuredOutputValidator.java | 27 + .../AiStructuredValidationContracts.java | 12 + .../AdminAgentStructuredOutputSpecs.java | 24 + .../xiaou/ai/support/AiExecutionSupport.java | 6 +- .../xiaou/ai/client/AiModelFactoryTest.java | 65 + .../com/xiaou/ai/prompt/AiPromptFixtures.java | 260 +--- .../com/xiaou/ai/prompt/AiPromptSpecTest.java | 133 +- .../AiStructuredOutputFixtures.java | 5 + .../src/main/resources/application.yml | 1 + xiaou-chat/pom.xml | 6 + .../controller/admin/ChatAdminController.java | 26 + .../xiaou/chat/dto/ChatUserBanResponse.java | 47 + .../chat/service/ChatUserBanService.java | 6 + .../service/impl/ChatMessageServiceImpl.java | 37 +- .../service/impl/ChatUserBanServiceImpl.java | 6 + .../impl/ChatMessageServiceImplTest.java | 637 ++++++++++ .../com/xiaou/common/config/AiProperties.java | 8 + .../xiaou/community/dto/UserBanRequest.java | 8 +- .../community/dto/UserBanRequestTest.java | 36 + xiaou-filestorage/pom.xml | 9 +- .../service/FileSystemSettingService.java | 9 +- .../service/impl/FileStorageServiceImpl.java | 6 +- .../impl/FileSystemSettingServiceImpl.java | 14 +- .../strategy/AbstractFileStorageStrategy.java | 4 +- .../strategy/impl/LocalStorageStrategy.java | 107 +- .../impl/FileStorageServiceImplTest.java | 153 +++ .../AbstractFileStorageStrategyTest.java | 96 ++ .../impl/LocalStorageStrategyTest.java | 146 +++ xiaou-moyu/pom.xml | 7 + .../AdminDeveloperCalendarController.java | 6 +- .../moyu/dto/AdminCalendarEventRequest.java | 7 +- .../dto/AdminCalendarEventRequestTest.java | 35 + .../java/com/xiaou/oj/judge/JudgeService.java | 43 +- .../com/xiaou/oj/judge/JudgeServiceTest.java | 402 ++++++ xiaou-points/pom.xml | 6 + .../points/chain/impl/PointsCheckHandler.java | 9 +- .../service/impl/LotteryServiceImpl.java | 43 +- .../chain/LotteryRiskCheckChainTest.java | 348 +++++ .../service/impl/LotteryServiceImplTest.java | 624 +++++++++ .../agent/AbstractReadonlyAgentTool.java | 86 ++ .../system/agent/AgentChatErrorCode.java | 26 + .../system/agent/AgentChatOrchestrator.java | 776 +++++++++++ .../system/agent/AgentExecutionContext.java | 39 + .../com/xiaou/system/agent/AgentOperator.java | 57 + .../system/agent/AgentPlanResolution.java | 52 + .../xiaou/system/agent/AgentPlanResolver.java | 23 + .../system/agent/AgentPolicyDecision.java | 56 + .../xiaou/system/agent/AgentPolicyEngine.java | 220 ++++ .../system/agent/AgentResolvedToolCall.java | 9 + .../xiaou/system/agent/AgentRuntimeTrace.java | 47 + .../agent/AgentSessionContextRepository.java | 13 + .../agent/AgentSessionContextStore.java | 101 ++ .../system/agent/AgentSessionProperties.java | 36 + .../system/agent/AgentSessionSnapshot.java | 19 + .../xiaou/system/agent/AgentSessionTurn.java | 24 + .../com/xiaou/system/agent/AgentTool.java | 28 + .../com/xiaou/system/agent/AgentToolCall.java | 21 + .../system/agent/AgentToolCatalogService.java | 24 + .../system/agent/AgentToolDefinition.java | 47 + .../agent/AgentToolDefinitionBuilder.java | 131 ++ .../system/agent/AgentToolMetricSample.java | 28 + .../agent/AgentToolMetricsRecorder.java | 52 + .../xiaou/system/agent/AgentToolPreview.java | 30 + .../xiaou/system/agent/AgentToolRegistry.java | 54 + .../xiaou/system/agent/AgentToolResult.java | 29 + .../DbAgentSessionContextRepository.java | 94 ++ .../agent/DeterministicAgentPlanResolver.java | 23 + ...InMemoryAgentSessionContextRepository.java | 63 + .../system/agent/LlmAgentPlanResolver.java | 299 +++++ .../RedisAgentSessionContextRepository.java | 111 ++ .../tools/AgentAuditDetailAgentTool.java | 150 +++ .../agent/tools/AgentAuditListAgentTool.java | 188 +++ .../tools/AgentFailureRecoverySupport.java | 165 +++ .../tools/AgentOperatorSelfAgentTool.java | 138 ++ .../AgentPlannerDiagnosticsAgentTool.java | 143 +++ .../tools/AgentPlannerDryRunAgentTool.java | 241 ++++ .../tools/AgentPolicyDryRunAgentTool.java | 190 +++ .../tools/AgentRecoveryDryRunAgentTool.java | 251 ++++ .../tools/AgentRecoveryExplainAgentTool.java | 103 ++ .../tools/AgentRequestDryRunAgentTool.java | 348 +++++ .../AgentRuntimeObservabilityAgentTool.java | 210 +++ .../tools/AgentRuntimeReadinessAgentTool.java | 337 +++++ .../tools/AgentRuntimeStatusAgentTool.java | 181 +++ .../tools/AgentSessionContextAgentTool.java | 130 ++ .../tools/AgentToolAccessCheckAgentTool.java | 193 +++ .../tools/AgentToolCatalogAgentTool.java | 108 ++ .../AgentToolDefinitionValidateAgentTool.java | 288 +++++ .../agent/tools/AgentToolDetailAgentTool.java | 143 +++ .../AgentToolMetricsAlertsAgentTool.java | 166 +++ .../AgentToolMetricsHealthAgentTool.java | 143 +++ .../AgentToolMetricsSnapshotSupport.java | 351 +++++ .../AgentToolMetricsSummaryAgentTool.java | 115 ++ .../agent/tools/AgentToolSearchAgentTool.java | 193 +++ .../tools/ChatUserBanStatusAgentTool.java | 108 ++ .../agent/tools/ChatUserUnbanAgentTool.java | 173 +++ .../LotteryRealtimeMonitorAgentTool.java | 152 +++ .../tools/OperationLogCleanAgentTool.java | 145 +++ .../tools/OperationLogListAgentTool.java | 113 ++ .../controller/AgentChatController.java | 92 ++ .../xiaou/system/domain/SysAgentAudit.java | 75 ++ .../system/domain/SysAgentSessionContext.java | 26 + .../system/dto/AgentAuditPreviewRequest.java | 52 + .../system/dto/AgentAuditQueryRequest.java | 50 + .../xiaou/system/dto/AgentAuditResponse.java | 81 ++ .../system/dto/AgentAuditResultRequest.java | 24 + .../xiaou/system/dto/AgentChatArtifact.java | 26 + .../system/dto/AgentChatConfirmation.java | 22 + .../xiaou/system/dto/AgentChatDiffItem.java | 26 + .../xiaou/system/dto/AgentChatPlanStep.java | 24 + .../xiaou/system/dto/AgentChatRequest.java | 31 + .../xiaou/system/dto/AgentChatResponse.java | 68 + .../xiaou/system/dto/AgentChatTraceStep.java | 26 + .../system/mapper/SysAgentAuditMapper.java | 24 + .../mapper/SysAgentSessionContextMapper.java | 17 + .../system/service/SysAgentAuditService.java | 27 + .../impl/SysAgentAuditServiceImpl.java | 197 +++ .../resources/mapper/SysAgentAuditMapper.xml | 106 ++ .../mapper/SysAgentSessionContextMapper.xml | 29 + .../agent/AbstractReadonlyAgentToolTest.java | 102 ++ .../AdminAgentPlannerRegressionTest.java | 192 +++ .../agent/AgentChatOrchestratorTest.java | 1131 +++++++++++++++++ .../AgentLiveWriteAcceptanceHarness.java | 373 ++++++ .../xiaou/system/agent/AgentOperatorTest.java | 41 + .../AgentPermissionSeedContractTest.java | 106 ++ .../system/agent/AgentPolicyEngineTest.java | 191 +++ .../AgentRuntimeAllToolsUnifiedEntryTest.java | 235 ++++ .../agent/AgentRuntimeLiveAiSmokeTest.java | 249 ++++ .../AgentRuntimeLiveWriteAcceptanceTest.java | 584 +++++++++ .../agent/AgentSessionContextStoreTest.java | 135 ++ .../agent/AgentToolDefinitionBuilderTest.java | 54 + .../AgentToolDefinitionContractTest.java | 117 ++ .../agent/AgentToolMetricsRecorderTest.java | 75 ++ .../system/agent/AgentToolRegistryTest.java | 45 + .../system/agent/AgentToolTestCatalog.java | 92 ++ .../DbAgentSessionContextRepositoryTest.java | 107 ++ ...moryAgentSessionContextRepositoryTest.java | 68 + .../agent/LlmAgentPlanResolverTest.java | 264 ++++ ...edisAgentSessionContextRepositoryTest.java | 125 ++ .../tools/AgentAuditDetailAgentToolTest.java | 94 ++ .../tools/AgentAuditListAgentToolTest.java | 75 ++ .../tools/AgentOperatorSelfAgentToolTest.java | 84 ++ .../AgentPlannerDiagnosticsAgentToolTest.java | 80 ++ .../AgentPlannerDryRunAgentToolTest.java | 162 +++ .../tools/AgentPolicyDryRunAgentToolTest.java | 130 ++ .../AgentRecoveryDryRunAgentToolTest.java | 159 +++ .../AgentRecoveryExplainAgentToolTest.java | 140 ++ .../AgentRequestDryRunAgentToolTest.java | 226 ++++ ...gentRuntimeObservabilityAgentToolTest.java | 175 +++ .../AgentRuntimeReadinessAgentToolTest.java | 159 +++ .../AgentRuntimeStatusAgentToolTest.java | 100 ++ .../AgentSessionContextAgentToolTest.java | 99 ++ .../AgentToolAccessCheckAgentToolTest.java | 139 ++ .../tools/AgentToolAccessDefinitionTest.java | 277 ++++ .../tools/AgentToolCatalogAgentToolTest.java | 60 + ...ntToolDefinitionValidateAgentToolTest.java | 133 ++ .../tools/AgentToolDetailAgentToolTest.java | 91 ++ .../AgentToolMetricsAlertsAgentToolTest.java | 136 ++ .../AgentToolMetricsHealthAgentToolTest.java | 118 ++ .../AgentToolMetricsSummaryAgentToolTest.java | 99 ++ .../tools/AgentToolSearchAgentToolTest.java | 116 ++ .../agent/tools/ChatUserBanAgentToolTest.java | 123 ++ .../LotteryRealtimeMonitorAgentToolTest.java | 124 ++ .../tools/OperationLogListAgentToolTest.java | 122 ++ .../controller/AgentChatControllerTest.java | 307 +++++ .../impl/SysAgentAuditServiceImplTest.java | 185 +++ .../admin-agent-planner-regression-cases.json | 291 +++++ 200 files changed, 22793 insertions(+), 375 deletions(-) create mode 100644 "AI-DOCS/Technical/11-\347\256\241\347\220\206\345\221\230\347\253\257\350\266\205\347\272\247\346\231\272\350\203\275\344\275\223\345\220\216\347\253\257\347\273\237\344\270\200\350\277\220\350\241\214\346\227\266\350\256\276\350\256\241.md" create mode 100644 "AI-DOCS/Technical/12-Code-Nest\345\205\250\347\253\231\346\265\213\350\257\225\346\262\273\347\220\206\350\256\241\345\210\222.md" create mode 100644 AI-DOCS/Technical/13-Code-Nest-full-site-test-results.md create mode 100644 scripts/agent-runtime-eval.ps1 create mode 100644 scripts/code-nest-eval.ps1 create mode 100644 sql/v2.4.0/admin_agent_audit.sql create mode 100644 sql/v2.4.0/admin_agent_audit_idempotency.sql create mode 100644 sql/v2.4.0/admin_agent_permissions.sql create mode 100644 sql/v2.4.0/admin_agent_session_context.sql create mode 100644 vue3-admin-front/src/api/agentChat.js create mode 100644 vue3-admin-front/src/components/agent/AdminAgentDrawer.vue create mode 100644 vue3-admin-front/tests/admin-agent-chat-ui.test.js create mode 100644 xiaou-ai/src/main/java/com/xiaou/ai/prompt/admin/AdminAgentPromptSpecs.java create mode 100644 xiaou-ai/src/main/java/com/xiaou/ai/structured/admin/AdminAgentStructuredOutputSpecs.java create mode 100644 xiaou-ai/src/test/java/com/xiaou/ai/client/AiModelFactoryTest.java create mode 100644 xiaou-chat/src/main/java/com/xiaou/chat/dto/ChatUserBanResponse.java create mode 100644 xiaou-chat/src/test/java/com/xiaou/chat/service/impl/ChatMessageServiceImplTest.java create mode 100644 xiaou-community/src/test/java/com/xiaou/community/dto/UserBanRequestTest.java create mode 100644 xiaou-filestorage/src/test/java/com/xiaou/filestorage/service/impl/FileStorageServiceImplTest.java create mode 100644 xiaou-filestorage/src/test/java/com/xiaou/filestorage/strategy/AbstractFileStorageStrategyTest.java create mode 100644 xiaou-filestorage/src/test/java/com/xiaou/filestorage/strategy/impl/LocalStorageStrategyTest.java create mode 100644 xiaou-moyu/src/test/java/com/xiaou/moyu/dto/AdminCalendarEventRequestTest.java create mode 100644 xiaou-oj/src/test/java/com/xiaou/oj/judge/JudgeServiceTest.java create mode 100644 xiaou-points/src/test/java/com/xiaou/points/chain/LotteryRiskCheckChainTest.java create mode 100644 xiaou-points/src/test/java/com/xiaou/points/service/impl/LotteryServiceImplTest.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AbstractReadonlyAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentChatErrorCode.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentChatOrchestrator.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentExecutionContext.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentOperator.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentPlanResolution.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentPlanResolver.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentPolicyDecision.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentPolicyEngine.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentResolvedToolCall.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentRuntimeTrace.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionContextRepository.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionContextStore.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionProperties.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionSnapshot.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionTurn.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolCall.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolCatalogService.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolDefinition.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolDefinitionBuilder.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolMetricSample.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolMetricsRecorder.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolPreview.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolRegistry.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolResult.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/DbAgentSessionContextRepository.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/DeterministicAgentPlanResolver.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/InMemoryAgentSessionContextRepository.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/LlmAgentPlanResolver.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/RedisAgentSessionContextRepository.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentAuditDetailAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentAuditListAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentFailureRecoverySupport.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentOperatorSelfAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentPlannerDiagnosticsAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentPlannerDryRunAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentPolicyDryRunAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRecoveryDryRunAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRecoveryExplainAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRequestDryRunAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRuntimeObservabilityAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRuntimeReadinessAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRuntimeStatusAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentSessionContextAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolAccessCheckAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolCatalogAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolDefinitionValidateAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolDetailAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsAlertsAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsHealthAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsSnapshotSupport.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsSummaryAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolSearchAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/ChatUserBanStatusAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/ChatUserUnbanAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/LotteryRealtimeMonitorAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/OperationLogCleanAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/agent/tools/OperationLogListAgentTool.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/controller/AgentChatController.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/domain/SysAgentAudit.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/domain/SysAgentSessionContext.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditPreviewRequest.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditQueryRequest.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditResponse.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditResultRequest.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatArtifact.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatConfirmation.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatDiffItem.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatPlanStep.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatRequest.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatResponse.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatTraceStep.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/mapper/SysAgentAuditMapper.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/mapper/SysAgentSessionContextMapper.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/service/SysAgentAuditService.java create mode 100644 xiaou-system/src/main/java/com/xiaou/system/service/impl/SysAgentAuditServiceImpl.java create mode 100644 xiaou-system/src/main/resources/mapper/SysAgentAuditMapper.xml create mode 100644 xiaou-system/src/main/resources/mapper/SysAgentSessionContextMapper.xml create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AbstractReadonlyAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AdminAgentPlannerRegressionTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AgentChatOrchestratorTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AgentLiveWriteAcceptanceHarness.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AgentOperatorTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AgentPermissionSeedContractTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AgentPolicyEngineTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AgentRuntimeAllToolsUnifiedEntryTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AgentRuntimeLiveAiSmokeTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AgentRuntimeLiveWriteAcceptanceTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AgentSessionContextStoreTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolDefinitionBuilderTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolDefinitionContractTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolMetricsRecorderTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolRegistryTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolTestCatalog.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/DbAgentSessionContextRepositoryTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/InMemoryAgentSessionContextRepositoryTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/LlmAgentPlanResolverTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/RedisAgentSessionContextRepositoryTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentAuditDetailAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentAuditListAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentOperatorSelfAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentPlannerDiagnosticsAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentPlannerDryRunAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentPolicyDryRunAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRecoveryDryRunAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRecoveryExplainAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRequestDryRunAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRuntimeObservabilityAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRuntimeReadinessAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRuntimeStatusAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentSessionContextAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolAccessCheckAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolAccessDefinitionTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolCatalogAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolDefinitionValidateAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolDetailAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolMetricsAlertsAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolMetricsHealthAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolMetricsSummaryAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolSearchAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/ChatUserBanAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/LotteryRealtimeMonitorAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/agent/tools/OperationLogListAgentToolTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/controller/AgentChatControllerTest.java create mode 100644 xiaou-system/src/test/java/com/xiaou/system/service/impl/SysAgentAuditServiceImplTest.java create mode 100644 xiaou-system/src/test/resources/agent/admin-agent-planner-regression-cases.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01aa5af47..fa2237cbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,12 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: backend: - name: Backend Package + name: Backend Tests And Package runs-on: ubuntu-latest timeout-minutes: 25 @@ -29,9 +32,24 @@ jobs: java-version: "17" cache: maven + - name: Run backend test tier + shell: pwsh + run: ./scripts/code-nest-eval.ps1 -Tier backend + - name: Package backend run: mvn -B -pl xiaou-application -am clean package -DskipTests + - name: Upload Surefire reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: code-nest-backend-surefire-reports + path: | + **/target/surefire-reports/** + **/target/*.dump + **/target/*.dumpstream + if-no-files-found: ignore + - name: Upload backend jar uses: actions/upload-artifact@v4 with: @@ -67,6 +85,10 @@ jobs: run: npm ci working-directory: ${{ matrix.app.path }} + - name: Run contract tests + run: npm run test:contracts + working-directory: ${{ matrix.app.path }} + - name: Build run: npm run build working-directory: ${{ matrix.app.path }} @@ -109,8 +131,28 @@ jobs: path: docs-site/.vitepress/dist if-no-files-found: error + rag: + name: RAG Service Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: llamaindex-service/requirements.txt + + - name: Run RAG test tier + shell: pwsh + run: ./scripts/code-nest-eval.ps1 -Tier rag -InstallPythonDeps + scripts: - name: Script Checks + name: Repository Hygiene And Script Checks runs-on: ubuntu-latest timeout-minutes: 5 @@ -123,6 +165,10 @@ jobs: with: python-version: "3.11" + - name: Run repository hygiene tier + shell: pwsh + run: ./scripts/code-nest-eval.ps1 -Tier hygiene + - name: Python syntax check run: python -m py_compile scripts/deploy-frontends.py scripts/you_deserve_to_interview_sql.py @@ -136,6 +182,7 @@ jobs: - backend - frontend - docs + - rag - scripts if: always() diff --git a/.gitignore b/.gitignore index 3ca8eb981..30f015f5b 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,8 @@ dump.rdb /_write_*.cjs /nul /docs-site/build_output.txt +/.codegraph/ +/.codex-mihomo-geo/ +/findings.md +/progress.md +/task_plan.md diff --git "a/AI-DOCS/Technical/11-\347\256\241\347\220\206\345\221\230\347\253\257\350\266\205\347\272\247\346\231\272\350\203\275\344\275\223\345\220\216\347\253\257\347\273\237\344\270\200\350\277\220\350\241\214\346\227\266\350\256\276\350\256\241.md" "b/AI-DOCS/Technical/11-\347\256\241\347\220\206\345\221\230\347\253\257\350\266\205\347\272\247\346\231\272\350\203\275\344\275\223\345\220\216\347\253\257\347\273\237\344\270\200\350\277\220\350\241\214\346\227\266\350\256\276\350\256\241.md" new file mode 100644 index 000000000..62b853e35 --- /dev/null +++ "b/AI-DOCS/Technical/11-\347\256\241\347\220\206\345\221\230\347\253\257\350\266\205\347\272\247\346\231\272\350\203\275\344\275\223\345\220\216\347\253\257\347\273\237\344\270\200\350\277\220\350\241\214\346\227\266\350\256\276\350\256\241.md" @@ -0,0 +1,575 @@ +# 管理员端超级智能体后端统一运行时设计 + +## 1. 核心结论 + +管理员端超级智能体不是前端 action catalog,也不是按业务模块手动分组的规则集合。它应该是一个后端统一运行时: + +```text +AdminAgentDrawer + -> POST /admin/agent/chat + -> AgentChatController + -> AgentChatOrchestrator + -> AgentPlanResolver + -> AgentToolRegistry / AgentPolicyEngine / SysAgentAuditService + -> AgentTool + -> typed backend services +``` + +前端只负责承载一个聊天抽屉和确认交互,不拥有规划、策略、工具选择、审计、执行和回滚判断。后端通过统一 chat 接口接收管理员自然语言请求,再把请求解析为受控工具调用。 + +## 2. 设计目标 + +- 一个深接口:管理员端只调用 `/admin/agent/chat`,新能力不新增前端编排接口。 +- 通用扩展:新增能力通过新增后端 `AgentTool` Bean 完成,而不是修改前端动作列表。 +- 后端可信边界:规划、工具选择、输入校验、权限策略、预览、强确认、执行和审计都在后端。 +- 可观测:每次请求都有 `traceId`、阶段 trace、会话上下文和工具调用 metrics。 +- 可回归:LLM planner 只输出候选工具调用,必须经过 registry/policy/schema/audit 再执行。 + +## 3. 非目标 + +- 不在前端维护 planner、policy、executor 或 action catalog。 +- 不按业务模块继续拆“日志组、用户组、聊天组”等前端配置。 +- 不让 LLM 直接执行写入动作。 +- 不把 traceId/sessionId 这类高基数字段放入 metrics tag。 + +## 4. 运行时分层 + +### 4.1 AdminAgentDrawer + +薄 UI。职责只有: + +- 输入管理员自然语言消息。 +- 调用 `/admin/agent/chat`。 +- 展示后端返回的 answer、plan、diff、artifacts、trace、confirmation。 +- 对 `confirm_required` 响应提交 `auditId + confirmationText`。 + +### 4.2 AgentChatController + +HTTP 适配层。职责只有: + +- 接收 `AgentChatRequest`。 +- 解析当前管理员身份为 `AgentOperator`。 +- 调用 `AgentChatOrchestrator.chat(request, operator)`。 +- 返回 `AgentChatResponse`。 + +### 4.3 AgentChatOrchestrator + +后端统一运行时核心。职责: + +- 创建 `traceId` 并记录阶段 trace。 +- 加载 `AgentSessionSnapshot`,让 planner 能理解上下文引用。 +- 调用 `AgentPlanResolver` 解析候选工具。 +- 通过 `AgentToolRegistry` 重新校验候选工具必须是已注册工具。 +- 调用 `AgentPolicyEngine` 做 schema、权限、角色、租户、风险和强确认策略。 +- 对写入/破坏性动作先 `preview`,再创建审计记录。 +- 对确认动作从审计记录恢复 payload 后执行。 +- 记录工具 preview/execute 指标。 +- 把响应写回 `AgentSessionContextStore`。 + +### 4.4 AgentPlanResolver + +计划解析器只负责“生成候选”,不负责执行。 + +当前策略: + +- `DeterministicAgentPlanResolver`:先走确定性解析,适合明确命中的工具。 +- `LlmAgentPlanResolver`:当确定性解析没有命中时,调用统一 AI 运行时生成结构化候选。 + +LLM planner 必须遵守结构化输出: + +```json +{ + "toolName": "chat.userBan.unban", + "input": {"userId": 88}, + "confidence": 0.92, + "missingFields": [] +} +``` + +解析后仍要经过 registry、schema、policy、audit。LLM 没有执行权。 + +### 4.5 AgentToolRegistry + +工具注册表是能力扩展入口: + +- Spring 自动收集所有 `AgentTool` Bean。 +- 拒绝空工具名。 +- 拒绝重复工具名。 +- 给 planner 暴露工具定义。 +- 给 orchestrator 提供二次校验。 + +新增后台能力时,只新增一个后端 `AgentTool`,不改前端编排。 + +### 4.6 AgentPolicyEngine + +策略引擎集中处理: + +- required input 校验。 +- unknown field 拒绝。 +- 基础 schema 类型校验。 +- string `minLength/maxLength`。 +- number `minimum/maximum`。 +- enum 范围校验。 +- `requiredPermissions` 权限校验,要求当前管理员具备全部声明权限。 +- `requiredRoles` 角色校验,要求当前管理员至少具备其中一个声明角色。 +- `tenantScope=SAME_TENANT` 租户校验,要求工具输入 `tenantId` 与当前操作者租户一致。 +- readonly 直接执行。 +- write/destructive/non-readonly 必须强确认。 +- 缺少强确认文本时拒绝执行。 + +策略输入来自 `AgentOperator`: + +- `id` +- `name` +- `tenantId` +- `roles` +- `permissions` + +`AgentChatController` 从现有管理员体系解析角色和权限,前端不参与授权。 + +当前内置工具权限码: + +| 工具 | 权限码 | 默认授权 | +| --- | --- | --- | +| `AgentToolCatalogAgentTool` | `agent:runtime:tool-catalog:read` | `SUPER_ADMIN` | +| `AgentToolDetailAgentTool` | `agent:runtime:tool-catalog:read` | `SUPER_ADMIN` | +| `AgentToolSearchAgentTool` | `agent:runtime:tool-catalog:read` | `SUPER_ADMIN` | +| `AgentToolDefinitionValidateAgentTool` | `agent:runtime:tool-catalog:read` | `SUPER_ADMIN` | +| `AgentToolAccessCheckAgentTool` | `agent:runtime:tool-access:read` | `SUPER_ADMIN` | +| `AgentRuntimeStatusAgentTool` | `agent:runtime:status:read` | `SUPER_ADMIN` | +| `AgentRuntimeObservabilityAgentTool` | `agent:runtime:status:read`、`agent:runtime:metrics:read` | `SUPER_ADMIN` | +| `AgentSessionContextAgentTool` | `agent:runtime:session:read` | `SUPER_ADMIN` | +| `AgentToolMetricsSummaryAgentTool` | `agent:runtime:metrics:read` | `SUPER_ADMIN` | +| `AgentToolMetricsHealthAgentTool` | `agent:runtime:metrics:read` | `SUPER_ADMIN` | +| `AgentToolMetricsAlertsAgentTool` | `agent:runtime:metrics:read` | `SUPER_ADMIN` | +| `AgentOperatorSelfAgentTool` | `agent:runtime:operator:read` | `SUPER_ADMIN` | +| `AgentPlannerDiagnosticsAgentTool` | `agent:runtime:planner:read` | `SUPER_ADMIN` | +| `AgentPlannerDryRunAgentTool` | `agent:runtime:planner:read` | `SUPER_ADMIN` | +| `AgentRequestDryRunAgentTool` | `agent:runtime:planner:read`、`agent:runtime:policy:read` | `SUPER_ADMIN` | +| `AgentPolicyDryRunAgentTool` | `agent:runtime:policy:read` | `SUPER_ADMIN` | +| `AgentAuditListAgentTool` | `agent:runtime:audit:read` | `SUPER_ADMIN` | +| `AgentAuditDetailAgentTool` | `agent:runtime:audit:read` | `SUPER_ADMIN` | +| `AgentRecoveryExplainAgentTool` | `agent:runtime:audit:read` | `SUPER_ADMIN` | +| `OperationLogListAgentTool` | `agent:system:operation-log:read` | `SUPER_ADMIN` | +| `OperationLogCleanAgentTool` | `agent:system:operation-log:clean` | `SUPER_ADMIN` | +| `ChatUserBanStatusAgentTool` | `agent:chat:user-ban:read` | `SUPER_ADMIN` | +| `ChatUserUnbanAgentTool` | `agent:chat:user-ban:write` | `SUPER_ADMIN` | + +权限种子文件: + +- `sql/v2.4.0/admin_agent_permissions.sql`:升级脚本,支持重复执行。 +- `sql/MySql/code_nest_data.sql`:全量初始化数据,保证新库直接具备同一批权限定义。 + +### 4.7 SysAgentAuditService + +写入/破坏性动作必须走审计: + +- `createPreview`:保存预览、payload、diff、plan、操作者。 +- `confirm`:强确认通过后标记确认。 +- `recordResult`:记录执行结果。 +- `cancel`:管理员取消预览。 +- `getByAuditId`:恢复待确认动作。 + +审计记录是确认链路的唯一可信来源,前端不能自行携带执行 payload。 + +写入动作幂等性也由审计链路承担: + +- `createPreview` 为每个写入预览生成并保存 `idempotencyKey`。 +- `confirm` 只负责把 `PREVIEW` CAS 到 `CONFIRMED`,真正执行前从审计记录恢复工具名、payload、plan 和确认策略。 +- `recordResult` 把执行结果写回同一条审计记录,后续重复确认只读取这条终态记录。 +- 如果重复确认命中 `EXECUTED/FAILED`,统一返回上一次执行结果,不再次调用目标工具。 +- 如果并发确认导致 CAS 失败,orchestrator 会重新加载审计记录;若已经进入终态,则按幂等重放返回。 +- `CANCELLED` 不是执行结果重放,重复确认或取消仍按终态拒绝,避免把取消误认为执行成功。 +- 前端可以展示 `idempotencyKey` 用于排障,但不拥有幂等判断,不能靠按钮禁用来保证防重。 + +审计状态迁移使用乐观条件更新: + +- `PREVIEW -> CONFIRMED`:强确认通过后才能进入执行。 +- `PREVIEW -> CANCELLED`:只有待确认预览允许取消。 +- `CONFIRMED -> EXECUTED/FAILED`:工具执行结果只能从已确认状态落库。 +- `EXECUTED/FAILED/CANCELLED` 为终态,不能被重复确认、重复取消或覆盖结果。 + +这样可以防止前端连点确认、网络重试或并发请求导致同一个写入工具被重复执行。 + +### 4.8 AgentTool + +每个工具是一个后端适配器: + +```java +public interface AgentTool { + AgentToolDefinition definition(); + Optional resolve(String message); + AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context); + AgentToolResult execute(AgentToolCall call, AgentExecutionContext context); +} +``` + +工具只做一件事:把通用 runtime 的工具调用转换为已有 typed backend service 调用。 + +## 5. 请求流程 + +### 5.1 只读动作 + +```text +request + -> load session + -> resolve plan + -> registry re-check + -> policy allow readonly + -> execute tool + -> record metrics + -> response answered + -> record session turn +``` + +### 5.2 写入动作 + +```text +request + -> load session + -> resolve plan + -> registry re-check + -> policy requires confirmation + -> preview tool + -> record preview metrics + -> create audit preview + idempotencyKey + -> response confirm_required +``` + +### 5.3 确认执行 + +```text +request(auditId + confirmationText) + -> load audit + -> verify operator + -> verify status PREVIEW + -> parse audited payload + -> policy re-check + -> verify confirmation text + -> confirm audit + -> execute tool + -> record execute metrics + -> record audit result + -> response executed +``` + +如果同一个 `auditId` 被网络重试或并发重复确认,后端会优先读取审计终态;已 `EXECUTED/FAILED` 的请求直接返回上一次结果,目标工具不会再次执行。 + +失败处置也由统一运行时兜底: + +- 工具执行失败时,orchestrator 会追加通用 `nextActions`,提示管理员先查询目标对象当前状态,确认是否发生部分写入。 +- 已经生成审计的写入失败会提示管理员查看对应 `auditId` 的审计详情,核对 payload、result 和 errorMessage。 +- 修复外部依赖、权限或输入数据后,必须重新发起自然语言请求生成新的预览,不能复用旧失败审计强行再次执行。 +- `FAILED` 终态重放会返回同一套处置建议,并明确说明这是幂等重放,目标工具没有再次执行。 + +同时,失败响应会携带结构化 `failureRecovery` artifact,作为后续补偿能力的统一 contract: + +- `auditId` / `idempotencyKey`:定位失败写入链路。 +- `toolName` / `riskLevel` / `riskCategory`:定位失败工具及风险类型。 +- `errorMessage`:记录失败原因。 +- `retryPolicy=NEW_PREVIEW_REQUIRED`:声明失败后必须重新生成预览。 +- `sameAuditRetryAllowed=false` / `requiresNewPreview=true`:禁止复用旧失败审计直接重试。 +- `recommendedActions`:机器可读的恢复建议。 + +当前 contract 只描述恢复上下文,不自动执行补偿动作;真正自动补偿必须另行通过统一 policy/audit/confirmation 链路。 + +## 6. 统一响应契约 + +`AgentChatResponse` 保持一个接口覆盖所有状态: + +- `answered`:只读动作已回答。 +- `confirm_required`:写入动作已生成预览,等待强确认。 +- `executed`:确认后的写入动作已执行。 +- `cancelled`:预览已取消。 +- `rejected`:策略、schema、审计或工具校验拒绝。 +- `error`:工具执行或运行时异常。 + +响应可携带: + +- `answer` +- `auditId` +- `idempotencyKey` +- `toolName` +- `riskLevel` +- `riskCategory` +- `plan` +- `diff` +- `artifacts` +- `confirmation` +- `nextActions` +- `traceId` +- `trace` +- `errorCode` +- `errorMessage` + +## 7. 会话上下文 + +`AgentSessionContextStore` 保存同一管理员、同一 `sessionId` 的最近若干轮对话。 + +用途: + +- 解决“那就解除”“继续处理这个人”等上下文引用。 +- 给 planner 提供最近状态。 +- 不作为执行授权来源。 + +当前已拆成两层: + +- `AgentSessionContextStore`:运行时门面,负责把 `AgentChatRequest/AgentChatResponse` 转换为 `AgentSessionTurn`。 +- `AgentSessionContextRepository`:存储端口,默认实现为 `InMemoryAgentSessionContextRepository`。 +- `RedisAgentSessionContextRepository`:Redis 实现,配置 `xiaou.admin-agent.session.repository=redis` 后启用。 +- `DbAgentSessionContextRepository`:DB 实现,配置 `xiaou.admin-agent.session.repository=db` 后启用。 + +前端仍只看到原始 `sessionId`;后端访问 Repository 时统一转换为 `operator::` 存储键。这样即使两个管理员提交相同 `sessionId`,memory、Redis 和 DB 三种实现也不会共享上下文。`sessionId` 最大 128 字符,带管理员命名空间后的键仍落在数据库 `varchar(160)` 范围内。 + +统一 HTTP 请求还限制 `message` 最大 4000 字符、`auditId` 最大 80 字符、`confirmationText` 最大 200 字符,长度校验在调用 planner 和工具前完成。 + +会话配置: + +- `xiaou.admin-agent.session.repository`:`memory` / `redis` / `db`,默认 `memory`。 +- `xiaou.admin-agent.session.max-recent-turns`:每个会话保留的最近轮数,默认 6。 +- `xiaou.admin-agent.session.redis-key-prefix`:Redis Key 前缀。 +- `xiaou.admin-agent.session.ttl-seconds`:Redis TTL,小于等于 0 表示不设置过期。 + +DB 会话上下文表: + +- `sql/v2.4.0/admin_agent_session_context.sql` +- `sql/MySql/code_nest.sql` 中的 `sys_agent_session_context` + +无论底层存储使用哪种实现,都不改变 orchestrator、planner 和前端接口。会话上下文只辅助 planner 理解省略指代,不参与授权;审计确认仍要求记录中的 `operatorId` 与当前管理员严格一致,空归属记录也拒绝执行。 + +## 8. 运行追踪 + +每次请求生成 `traceId`,并返回 `trace` 阶段列表。 + +典型阶段: + +- `request.received` +- `session.loaded` +- `plan.resolved` +- `tool.registered` +- `policy.evaluated` +- `tool.previewed` +- `audit.preview_created` +- `audit.loaded` +- `confirmation.checked` +- `tool.executed` +- `audit.result_recorded` +- `response.rejected` +- `response.error` + +trace 用于前端展示和排障,不作为 metrics tag。 + +## 9. 工具调用指标 + +`AgentToolMetricsRecorder` 记录通用工具调用指标。 + +指标名: + +- `xiaou.agent.tool.duration` +- `xiaou.agent.tool.invocations` + +低基数标签: + +- `tool` +- `phase` +- `outcome` +- `risk_level` +- `risk_category` + +不进入标签: + +- `trace_id` +- `session_id` +- 任意用户输入 +- 任意业务对象 ID + +当前接入阶段: + +- `preview` +- `execute` + +outcome 约定: + +- `success` +- `blocked` +- `error` + +## 10. 新增工具步骤 + +1. 新增一个 `AgentTool` Bean。 +2. 在 `definition()` 中声明 `name/title/description/intent/route/risk/inputSchema/requiredInputKeys`。 +3. 如果工具需要受控访问,声明 `requiredPermissions/requiredRoles/tenantScope`。 +4. 只读工具实现 `execute` 即可,`preview` 可返回默认预览。 +5. 写入工具必须实现可读的 `preview`,并配置强确认文本。 +6. 工具内部调用已有 typed backend service,不绕过服务层。 +7. 增加工具单测和 planner 回归 fixture。 +8. 不修改前端动作表,因为前端没有动作表。 + +### 10.1 工具接入门禁 + +新增工具必须通过两类通用门禁: + +- `AgentToolDefinitionContractTest`:校验工具定义元数据、schema、权限声明、租户范围和写入强确认。 +- `AdminAgentPlannerRegressionTest`:使用真实内置工具目录,每个注册工具至少要有一个 `RESOLVED` planner fixture。 + +planner fixture 约束: + +- `id/message/aiResponse/expectedStatus` 必填。 +- `expectedStatus` 只能是 `RESOLVED`、`CLARIFICATION`、`EMPTY`。 +- `RESOLVED/CLARIFICATION` 必须声明 `expectedToolName`,且工具必须已注册。 +- `expectedInput` 和 `expectedMissingFields` 的字段必须存在于该工具的 `inputSchema`。 + +这套门禁的目的不是增加业务分组,而是保证工具扩展仍然走后端统一工具目录和统一 planner 合约。 + +当前已有一个运行时自描述工具: + +- `system.agent.tools.list`:回答“你能做什么/有哪些工具”,从后端 `AgentToolRegistry` 读取当前工具目录。 +- 该工具只读、受权限控制、通过统一 chat 入口执行,前端仍不维护动作表。 +- `system.agent.tools.detail`:回答“某个工具需要什么权限/输入 schema/风险等级”,按工具名读取单个后端工具定义。 +- 该工具复用 `agent:runtime:tool-catalog:read` 权限,只读执行;详情来自真实 `AgentToolDefinition`,不依赖前端动作配置。 +- `system.agent.tools.search`:回答“搜索某类工具/查找某个关键词相关工具”,按关键词过滤真实后端工具目录。 +- 该工具复用 `agent:runtime:tool-catalog:read` 权限,只读执行;它只是 registry 搜索视图,不维护业务分组,也不执行搜索结果。 +- `system.agent.tools.validate`:回答“当前工具定义是否合规”,校验真实后端工具目录中的 metadata、schema、权限命名、租户范围、确认策略和重复工具名。 +- 该工具复用 `agent:runtime:tool-catalog:read` 权限,只读执行;它是运行时可见的扩展门禁,不调用目标工具、不维护业务分组。 +- `system.agent.tools.access_check`:回答“我能不能用某个工具”,按工具名检查当前 `AgentExecutionContext.operator()` 是否满足该工具声明的权限、角色和租户策略。 +- 该工具只读、受权限控制、通过统一 chat 入口执行,只做静态访问自检;真实执行仍必须重新经过 `AgentPolicyEngine`。 +- `system.agent.runtime.status`:回答“智能体状态怎么样/运行情况怎么样”,从后端工具目录和会话配置读取运行时状态。 +- 该工具只读、受权限控制、通过统一 chat 入口执行,用于排障和确认运行时扩展状态,不暴露业务分组编排。 +- `system.agent.runtime.readiness`:回答“智能体运行时是否具备上线条件/readiness 自检”,检查核心运行时工具、工具定义、权限声明、写入强确认和会话配置。 +- 该工具使用 `agent:runtime:readiness:read` 权限,只读执行;它只输出 `READY/WARN/BLOCKED` 体检结果,不执行业务工具、不创建审计记录、不替代真实请求时的 policy/audit/confirm 链路。 +- `system.agent.runtime.observability`:回答“智能体运行时观测快照/告警总览是什么”,聚合工具目录摘要、会话配置、metrics health 和 alerts。 +- 该工具复用 `agent:runtime:status:read` 与 `agent:runtime:metrics:read` 权限,只读执行;它输出 dashboard 前的统一后端观测快照,不执行业务工具、不发送外部通知、不让前端自行推导运行时健康和告警。 +- `system.agent.session.context`:回答“你记住了什么/当前会话上下文是什么”,读取本次请求已加载的 `AgentSessionSnapshot`。 +- 该工具只读、受权限控制、通过统一 chat 入口执行,只展示当前会话摘要,不支持按任意 `sessionId` 查询其它会话。 +- `system.agent.metrics.summary`:回答“智能体工具调用指标怎么样”,读取 `xiaou.agent.tool.invocations` 和 `xiaou.agent.tool.duration`。 +- 该工具只读、受权限控制、通过统一 chat 入口执行,只汇总低基数标签 `tool/phase/outcome/risk_level/risk_category`,不暴露 `traceId/sessionId`。 +- `system.agent.metrics.health`:回答“智能体工具调用健康/告警情况怎么样”,读取同一组工具调用 metrics 并输出 `HEALTHY/WARN/ALERT`。 +- 该工具只读、受权限控制、通过统一 chat 入口执行,用于 dashboard/告警接入前的后端健康契约;它只检查指标序列、错误、阻断和慢调用,不执行业务工具、不创建审计记录。 +- `system.agent.metrics.alerts`:回答“智能体工具指标告警规则/告警事件怎么评估”,读取同一组工具调用 metrics 并输出标准化 `rules` 和活跃 `alerts`。 +- 该工具复用 `agent:runtime:metrics:read` 权限,只读执行;它只根据后端指标评估 `OK/WARN/ALERT` 和机器可读告警事件,不发送外部通知、不创建审计记录、不让 dashboard 自己推导阈值。 +- `system.agent.operator.self`:回答“我在智能体里的角色和权限是什么”,读取本次请求的 `AgentExecutionContext.operator()`。 +- 该工具只读、受权限控制、通过统一 chat 入口执行,只展示当前操作者上下文,不接受 `adminId/userId`,不支持查询任意其他用户。 +- `system.agent.planner.diagnostics`:回答“planner 结构化输出契约和命中规则是什么”,展示 planner prompt 版本、结构化候选字段、最低置信度、候选过滤规则和当前注册工具数量。 +- 该工具只读、受权限控制、通过统一 chat 入口执行,不执行规划、不试跑工具、不暴露新的业务入口。 +- `system.agent.planner.dry_run`:回答“这句话会被 planner 解析成哪个工具调用”,对指定管理员自然语言请求调用真实 `AgentPlanResolver.resolvePlan` 做只读预演。 +- 该工具只读、受权限控制、通过统一 chat 入口执行,只返回 `RESOLVED/CLARIFICATION/EMPTY` 规划结果,不执行命中的目标工具、不创建审计记录。 +- `system.agent.request.dry_run`:回答“这句话如果走完整运行时会怎样”,对指定管理员自然语言请求依次执行 planner、policy 和必要的写入 preview 沙盘预演。 +- 该工具只读、受权限控制、通过统一 chat 入口执行;只读目标工具不会被执行,写入目标工具最多调用 `preview`,不会调用 `execute`、不会创建审计记录、不会绕过真实请求时的二次校验。 +- `system.agent.policy.dry_run`:回答“对某个工具和输入做策略预检”,调用真实 `AgentPolicyEngine.evaluate` 解释 schema、权限、角色、租户和强确认判定。 +- 该工具只读、受权限控制、通过统一 chat 入口执行,不执行目标工具、不创建审计记录;真实执行时仍必须重新走 orchestrator/policy/audit。 + +当前已有运行时审计查询工具: + +- `system.agent.audit.list`:回答“最近智能体执行了什么/有哪些失败或待确认记录”,从后端 `SysAgentAuditService` 查询审计记录。 +- 该工具只读、受权限控制、通过统一 chat 入口执行,不暴露绕过确认链路的执行入口。 +- `system.agent.audit.detail`:回答“查某条智能体审计详情”,按 `auditId` 查询单条审计记录,返回计划、输入、结果和状态信息。 +- 该工具复用 `agent:runtime:audit:read` 权限,只读执行;如果记录不存在,统一返回工具执行错误,不生成写入审计。 +- `system.agent.recovery.explain`:回答“这条失败审计怎么处理/失败恢复上下文是什么”,按 `auditId` 查询审计记录并解释 `failureRecovery` artifact。 +- 该工具复用 `agent:runtime:audit:read` 权限,只读执行;它只解释恢复上下文,不执行补偿、不重试旧审计、不调用目标工具。 +- `system.agent.recovery.dry_run`:回答“这条失败审计能不能重试/补偿”,按 `auditId` 查询审计记录并输出失败恢复安全预演。 +- 该工具复用 `agent:runtime:audit:read` 权限,只读执行;它只读取 audit 和 `failureRecovery`,判断同审计重试、新预览要求和补偿门禁,不执行目标工具、不写审计、不自动补偿。 + +当前已有真实业务后端工具接入示例: + +- `points.lottery.monitor.realtime`:回答“抽奖实时监控/今日概览/奖品状态怎么样”,通过 `LotteryAdminService#getRealtimeMonitor()` 读取抽奖管理侧实时监控数据。 +- 该工具使用 `agent:points:lottery:monitor:read` 权限,只读执行;它不维护抽奖专属编排、不复制抽奖业务规则,只把 typed backend service 暴露为统一 `AgentTool`,继续走 `/admin/agent/chat`、registry、policy、metrics、planner fixture 和权限种子契约。 + +当前已有通用 AgentTool 作者工具层: + +- `AgentToolDefinitionBuilder`:集中构造 `AgentToolDefinition`,统一只读/写入风险默认值、权限声明、输入 schema、required input、强确认文本和基础字段校验。 +- `AbstractReadonlyAgentTool`:统一只读工具的 `definition()`、只读 `preview()`、`AgentToolCall` 构造、`AgentChatArtifact` 构造和成功结果包装。 +- 首批迁移范围为真实只读业务工具:`system.operationLog.list`、`chat.userBan.active`、`points.lottery.monitor.realtime`。迁移后这些工具仍保留原工具名、权限、schema、artifact 类型和 typed backend service 调用,只是删除重复样板代码。 +- 后续新增只读能力优先继承 `AbstractReadonlyAgentTool`,工具作者只写意图识别、typed service 调用和业务摘要映射;不得在前端或业务分组文档中维护额外动作目录。 + +当前已有 Agent Runtime 评测闭环: + +- `scripts/agent-runtime-eval.ps1` 固定执行后端统一运行时回归、AI prompt/structured output 回归、前端薄边界回归、`git diff --check` 和文本 NUL-byte 扫描。 +- 评测闭环顺序固定为:先写/更新 eval,再跑目标测试得到基线,再做最小修改,再重跑同一目标测试,最后跑 `scripts/agent-runtime-eval.ps1` 做整体验收。 +- `AgentRuntimeAllToolsUnifiedEntryTest` 读取真实内置工具定义和 planner fixture,要求每个已注册工具都能通过 `AgentChatOrchestrator.chat()` 统一入口得到 `answered` 或 `confirm_required`,避免只测工具本身而漏测统一编排边界。 +- live LLM smoke 只通过 `AgentRuntimeLiveAiSmokeTest` opt-in 执行,默认 CI 和本地回归都会跳过真实模型调用。 +- live smoke 使用真实 `LlmAgentPlanResolver`、真实 `AiExecutionSupport` 和一个只暴露 `system.agent.tools.list` 的后端 registry,验证模型只生成候选工具调用,最终仍由后端 registry/schema 校验收口。 +- live smoke 环境变量:`AGENT_LIVE_AI_TEST=true`、`XIAOU_AI_BASE_URL`、`XIAOU_AI_API_KEY`、`XIAOU_AI_CHAT_MODEL`。密钥不得写入配置文件、文档或 fixture。 + +## 11. 当前进度 + +- P0:删除前端编排思路,收束为后端统一 chat 接口。已完成。 +- P1:`AgentChatController` + `AgentChatOrchestrator` 深接口主链路。已完成。 +- P2:`AgentToolRegistry`、`AgentPolicyEngine`、schema 校验、强确认策略。已完成。 +- P3:审计预览、确认、取消、执行结果记录。已完成。 +- P4:LLM planner 结构化输出、工具输入过滤、planner 回归 fixture。已完成。 +- P5:trace、session context、工具调用 metrics。已完成。 +- P6:session context 存储端口抽象,默认内存实现和可配置 Redis 实现。已完成。 +- P7:通用权限、角色、租户策略进入 `AgentPolicyEngine`。已完成。 +- P8:现有内置工具补齐 `requiredPermissions` 声明,并提供权限 SQL 种子。已完成。 +- P9:审计状态迁移增加 CAS 防重,避免重复确认、取消覆盖和并发执行。已完成。 +- P10:会话上下文增加 DB repository,实现 memory/redis/db 三种可替换存储。已完成。 +- P11:新增工具接入门禁,真实工具目录驱动 planner fixture 回归。已完成。 +- P12:新增运行时工具目录自描述能力,统一接口可回答当前后端工具能力。已完成。 +- P13:新增运行时审计查询能力,统一接口可查询智能体自身审计记录。已完成。 +- P14:新增运行时审计详情能力,统一接口可追踪单条智能体审计记录。已完成。 +- P15:新增运行时状态自检能力,统一接口可查看工具注册、风险分布和会话存储配置。已完成。 +- P16:新增当前会话上下文自检能力,统一接口可解释 planner 使用的最近对话摘要。已完成。 +- P17:新增工具调用指标摘要能力,统一接口可查看工具调用次数、耗时和结果分布。已完成。 +- P18:新增工具定义详情能力,统一接口可查看单个工具的 schema、权限、风险和确认策略。已完成。 +- P19:新增当前操作者上下文自检能力,统一接口可查看当前请求在后端运行时中的角色、权限和租户上下文。已完成。 +- P20:新增 planner 诊断能力,统一接口可查看结构化输出契约、候选过滤规则和当前注册工具摘要。已完成。 +- P21:新增当前操作者工具访问自检能力,统一接口可解释当前操作者是否满足某个工具声明的权限、角色和租户策略。已完成。 +- P22:新增工具目录搜索能力,统一接口可按关键词搜索真实后端工具目录,避免工具增多后依赖前端分组。已完成。 +- P23:新增策略 dry-run 能力,统一接口可对指定工具输入调用真实 `AgentPolicyEngine` 做只读预检解释。已完成。 +- P24:新增 planner dry-run 能力,统一接口可对管理员自然语言请求调用真实 `AgentPlanResolver` 做只读规划预演。已完成。 +- P25:新增 request dry-run 能力,统一接口可对管理员自然语言请求做 planner + policy + preview 沙盘预演。已完成。 +- P26:新增工具定义契约自检能力,统一接口可校验当前已注册 `AgentToolDefinition` 是否满足扩展门禁。已完成。 +- P27:新增写入动作幂等键和终态重放能力,重复确认或并发确认不会重复执行目标工具。已完成。 +- P28:新增通用失败处置建议,写入失败和 `FAILED` 终态重放都会返回统一人工处置路径。已完成。 +- P29:新增结构化 `failureRecovery` artifact,失败恢复上下文可被后续补偿能力复用。已完成。 +- P30:新增失败恢复解释工具,统一接口可按 `auditId` 解释 `failureRecovery`,但不自动补偿或重试。已完成。 +- P31:新增失败恢复 dry-run 安全闸门,统一接口可按 `auditId` 判断失败审计是否允许同审计重试、是否必须新预览、是否可补偿;当前只读,不执行补偿、不写审计。已完成。 +- P32:新增运行时 readiness 上线自检能力,统一接口可检查核心工具齐备性、权限声明、写入强确认和 session 配置,输出 `READY/WARN/BLOCKED`。已完成。 +- P33:新增 AgentTool 权限种子契约测试,确保 Java 工具声明的 `requiredPermissions` 同步出现在全量 SQL、v2.4.0 权限迁移和 SUPER_ADMIN 授权列表。已完成。 +- P34:新增工具调用指标健康检查能力,统一接口可输出 `HEALTHY/WARN/ALERT`,用于 dashboard/告警接入前的后端契约。已完成。 +- P35:新增工具指标告警评估能力,统一接口可输出 `rules/alerts` 和 `OK/WARN/ALERT`,让 dashboard 或外部告警系统消费后端契约而不是自行推导。已完成。 +- P36:新增运行时观测快照能力,并抽出 `AgentToolMetricsSnapshotSupport` 统一 metrics summary/health/alerts 的低基数指标计算,避免观测工具各自复制阈值和序列扫描逻辑。已完成。 +- P37:新增首个 points 抽奖实时监控真实业务工具,验证任意后端 typed service 可按 `AgentTool` 模式接入统一 chat/runtime/policy/permission/planner 契约。已完成。 +- P38:新增 AgentTool 通用作者工具层,提供 definition builder 和只读工具基类,并迁移首批真实只读业务工具,减少新增后端能力时的手写样板。已完成。 +- P39:新增 Agent Runtime 评测闭环,提供统一回归脚本和 opt-in live LLM smoke test,后续按“eval -> 修改 -> 同测 -> 全量回归 -> 记录结果”推进。已完成。 + +## 12. 已覆盖测试 + +统一评测入口: + +```powershell +.\scripts\agent-runtime-eval.ps1 +``` + +带真实 OpenAI-compatible 中转站的 live smoke: + +```powershell +$env:XIAOU_AI_API_KEY="" +.\scripts\agent-runtime-eval.ps1 -LiveAi -AiBaseUrl "https://yaoshanapi.com/v1" -AiChatModel "gpt-5.5" +``` + +后端通用运行时: + +```powershell +mvn -pl xiaou-system -am "-Dtest=AgentChatControllerTest,AgentChatOrchestratorTest,AgentOperatorTest,AgentPolicyEngineTest,AgentToolRegistryTest,AgentSessionContextStoreTest,InMemoryAgentSessionContextRepositoryTest,RedisAgentSessionContextRepositoryTest,DbAgentSessionContextRepositoryTest,AgentToolMetricsRecorderTest,AgentToolDefinitionBuilderTest,AbstractReadonlyAgentToolTest,AgentToolDefinitionContractTest,AgentToolAccessDefinitionTest,AgentPermissionSeedContractTest,AgentToolCatalogAgentToolTest,AgentToolDetailAgentToolTest,AgentToolSearchAgentToolTest,AgentToolDefinitionValidateAgentToolTest,AgentToolAccessCheckAgentToolTest,AgentPolicyDryRunAgentToolTest,AgentRuntimeStatusAgentToolTest,AgentRuntimeReadinessAgentToolTest,AgentRuntimeObservabilityAgentToolTest,AgentSessionContextAgentToolTest,AgentOperatorSelfAgentToolTest,AgentPlannerDiagnosticsAgentToolTest,AgentPlannerDryRunAgentToolTest,AgentRequestDryRunAgentToolTest,AgentToolMetricsSummaryAgentToolTest,AgentToolMetricsHealthAgentToolTest,AgentToolMetricsAlertsAgentToolTest,AgentAuditListAgentToolTest,AgentAuditDetailAgentToolTest,AgentRecoveryExplainAgentToolTest,AgentRecoveryDryRunAgentToolTest,OperationLogListAgentToolTest,ChatUserBanAgentToolTest,LotteryRealtimeMonitorAgentToolTest,LlmAgentPlanResolverTest,AgentRuntimeLiveAiSmokeTest,AgentRuntimeAllToolsUnifiedEntryTest,AdminAgentPlannerRegressionTest,SysAgentAuditServiceImplTest" "-Dsurefire.failIfNoSpecifiedTests=false" test -q +``` + +AI prompt/structured output: + +```powershell +mvn -pl xiaou-ai -am "-Dtest=AiPromptSpecTest,AiStructuredOutputSpecTest,AiStructuredOutputValidatorTest,AiStructuredOutputSchemaExporterTest,AiRagRetrievalProfileTest" "-Dsurefire.failIfNoSpecifiedTests=false" test -q +``` + +前端边界: + +```powershell +node --test "vue3-admin-front/tests/admin-api-contract.test.js" "vue3-admin-front/tests/admin-agent-chat-ui.test.js" +``` + +## 13. 后续收口方向 + +- 给更多后台能力补 `AgentTool`,但仍保持一个统一 chat 接口。 +- 新增工具时同步声明 `requiredPermissions/requiredRoles/tenantScope`,并补对应权限种子。 +- 增加更多 planner fixture 和工具级回归用例。 +- 给 metrics 告警评估结果接入真实 dashboard、外部通知或 Prometheus/Alertmanager 适配层。 +- 后续如需自动补偿,必须基于 `failureRecovery` 和 `system.agent.recovery.dry_run` 继续抽象补偿工具、策略门禁和强确认链路,不能散落到前端或业务分组配置。 diff --git "a/AI-DOCS/Technical/12-Code-Nest\345\205\250\347\253\231\346\265\213\350\257\225\346\262\273\347\220\206\350\256\241\345\210\222.md" "b/AI-DOCS/Technical/12-Code-Nest\345\205\250\347\253\231\346\265\213\350\257\225\346\262\273\347\220\206\350\256\241\345\210\222.md" new file mode 100644 index 000000000..5749712ff --- /dev/null +++ "b/AI-DOCS/Technical/12-Code-Nest\345\205\250\347\253\231\346\265\213\350\257\225\346\262\273\347\220\206\350\256\241\345\210\222.md" @@ -0,0 +1,187 @@ +# Code-Nest 全站测试治理计划 + +## 1. 目标 + +把 Code-Nest 的测试从“局部回归”升级为“全站分层测试体系”: + +- 后端多模块:能按模块、风险和发布阶段运行测试。 +- 前端双端:能覆盖 build、路由、API contract、适配器和调试输出约束。 +- AI/Agent:能覆盖 prompt、结构化输出、graph、planner、统一运行时和 opt-in live smoke。 +- 数据库/脚本/配置:能检查 SQL schema、部署脚本语法、密钥泄漏、NUL 字节和 diff 空白问题。 +- CI/CD:最终把稳定的层接入 CI,把慢/外部依赖层保留为手动或定时触发。 + +## 2. 分层模型 + +| Tier | 名称 | 默认 CI | 目的 | 典型命令 | +|---|---|---:|---|---| +| T0 | hygiene | yes | 快速检查仓库健康,防止低级问题进入后续测试 | `git diff --check`、secret scan、NUL scan | +| T1 | smoke | yes | 覆盖关键契约和薄边界,几分钟内给出信号 | 前端 `node --test` contract、Agent no-live eval | +| T2 | module | yes/partial | 按模块运行已有单元/服务/SQL 测试 | Maven module test suites | +| T3 | rag | yes/partial | 独立 Python RAG sidecar 的 API contract 和检索行为 | `python -m unittest discover -s tests -v` | +| T4 | integration | manual/nightly | 需要 DB/Redis/WebSocket/文件存储/判题沙箱的集成验证 | Spring/Web/DB integration tests | +| T5 | ai-live | manual | 真实 AI 中转站、RAG 服务或第三方模型联调 | `-LiveAi` smoke | +| T6 | release | release/manual | 发版前构建产物和全量门禁 | backend package、front build、docs build、scripts syntax | + +## 3. 当前覆盖快照 + +### 3.1 后端模块 + +| 覆盖等级 | 模块 | +|---|---| +| 高 | `xiaou-system`、`xiaou-ai` | +| 中 | `xiaou-oj`、`xiaou-learning-asset`、`xiaou-mock-interview`、`xiaou-filestorage`、`xiaou-points`、`xiaou-chat` | +| 低 | `xiaou-community`、`xiaou-user`、`xiaou-sensitive`、`xiaou-moyu`、`xiaou-sql-optimizer` | +| 空白 | `xiaou-common`、`xiaou-user-api`、`xiaou-application`、`xiaou-interview`、`xiaou-notification`、`xiaou-moment`、`xiaou-sensitive-api`、`xiaou-knowledge`、`xiaou-version`、`xiaou-blog`、`xiaou-codepen`、`xiaou-resume`、`xiaou-plan`、`xiaou-team`、`xiaou-flashcard` | + +空白不等于坏,但代表后续补测优先级要显式管理,不能靠“全量 mvn test”假装覆盖。 + +### 3.2 前端 + +- `vue3-admin-front/tests` 已有 API contract、Agent UI boundary、路由、request options、no-debug-console 等直接 `node --test` 测试。 +- `vue3-user-front/tests` 已有 captcha/moyu contract、career-loop/home/OJ adapter、导航、request options、no-debug-console 等直接 `node --test` 测试。 +- 两个前端 `package.json` 均已提供 `test:contracts`,模块内可直接执行 `npm run test:contracts`;`lint` 当前仍带 `--fix`,不作为只读 release 门禁。 + +### 3.3 AI/Agent + +- AI 已有 prompt、RAG query、structured output、graph runner、regression service 测试。 +- Agent 已有统一运行时回归脚本 `scripts/agent-runtime-eval.ps1`。 +- `AgentRuntimeAllToolsUnifiedEntryTest` 已要求每个注册工具都能通过统一 `AgentChatOrchestrator.chat()` 入口。 +- live LLM smoke 使用 `AGENT_LIVE_AI_TEST=true`、`XIAOU_AI_BASE_URL`、`XIAOU_AI_API_KEY`、`XIAOU_AI_CHAT_MODEL`,默认跳过。 + +## 4. 推荐执行顺序 + +日常开发: + +1. 修改前先定位影响模块和已有测试。 +2. 先跑目标模块测试或目标前端 contract test。 +3. 修改后重跑同一命令,确认 RED/GREEN 闭环。 +4. 跑 `.\scripts\code-nest-eval.ps1 -Tier smoke`。 + +Agent/AI 修改: + +1. 跑目标 AI/Agent 单测。 +2. 跑 `.\scripts\agent-runtime-eval.ps1`。 +3. 如涉及真实模型联调,再 opt-in 跑 `-LiveAi`。 + +发版前: + +1. 跑 `.\scripts\code-nest-eval.ps1 -Tier backend`。 +2. 跑 `.\scripts\code-nest-eval.ps1 -Tier frontend`。 +3. 跑 `.\scripts\code-nest-eval.ps1 -Tier release`。 +4. 如本次涉及 AI/Agent,追加 live smoke。 + +RAG sidecar: + +1. 首次执行建议创建临时 venv,避免污染系统 Python。 +2. 用 `.\scripts\code-nest-eval.ps1 -Tier rag -InstallPythonDeps` 安装并运行测试。 +3. 后续可通过 `CODE_NEST_PYTHON` 或 `-PythonCommand` 指向已准备好的 venv。 + +## 5. 补测优先级 + +P0 高风险: + +- `xiaou-chat`:WebSocket、消息发送、撤回、删除、禁言、限流、在线状态。 +- `xiaou-points`:积分、抽奖库存/概率/并发、奖品状态、用户积分流水。 +- `xiaou-oj`:判题链路、测试用例、运行沙箱异常、比赛排名。 +- `xiaou-filestorage`:上传、路径、配置切换、异常回滚。 + +P1 核心业务: + +- `xiaou-knowledge`:知识图谱 CRUD、节点/边结构、发布/隐藏。 +- `xiaou-plan`:打卡、重复规则、连续天数、积分奖励。 +- `xiaou-team`:申请、审核、成员角色、权限。 +- `xiaou-notification`:通知发送、已读、批量处理。 + +P2 平台支撑: + +- `xiaou-common`:统一响应、分页、异常、工具类、配置属性。 +- `xiaou-application`:应用启动 smoke、关键 Bean wiring。 +- API-only 模块:至少保留 DTO/contract 或编译门禁。 + +## 6. 测试命名和准入规则 + +- 单元测试:`*Test.java`,不依赖外部服务。 +- 集成测试:`*IntegrationTest.java`,可依赖 Spring context、DB/Redis mock 或 Testcontainers。 +- Web/API 测试:`*WebTest.java`,覆盖 controller contract 和认证边界。 +- SQL contract:`*SchemaSqlTest.java`,确保 migration 和字段契约稳定。 +- 外部 live:必须用环境变量开关,默认 skipped。 +- 新业务模块新增功能时,至少补一个 service 单测或 API contract 测试。 + +## 7. CI 演进路线 + +当前 CI: + +- 后端只 package 且 `-DskipTests`。 +- 前端只 build。 +- AI regression 单独按路径触发。 +- docs 和脚本有基础构建/语法检查。 + +建议演进: + +1. CI 增加 T0 hygiene 和前端 `node --test` contract。 +2. CI 增加后端 fast module tests,不跑 live/external。 +3. AI workflow 扩大到完整 prompt/structured output/graph regression。 +4. nightly 跑 integration/release tier。 +5. release 分支强制 `Tier release`。 + +## 8. 当前统一入口 + +第一版统一入口: + +```powershell +.\scripts\code-nest-eval.ps1 -Tier smoke +.\scripts\code-nest-eval.ps1 -Tier backend +.\scripts\code-nest-eval.ps1 -Tier frontend +.\scripts\code-nest-eval.ps1 -Tier rag +.\scripts\code-nest-eval.ps1 -Tier release +``` + +Windows Bash 说明: + +- `release` tier 会检查 `scripts/deploy-release.sh` 语法。 +- Windows 下会优先使用 Git Bash,避免误用 `C:\Windows\system32\bash.exe` 的 WSL shim。 +- 如需指定路径,可设置 `$env:CODE_NEST_BASH` 或传 `-BashCommand`。 + +RAG 临时 venv 示例: + +```powershell +python -m venv .tmp\code-nest-rag-venv +$env:CODE_NEST_PYTHON=(Resolve-Path ".tmp\code-nest-rag-venv\Scripts\python.exe").Path +.\scripts\code-nest-eval.ps1 -Tier rag -InstallPythonDeps +``` + +Agent live: + +```powershell +$env:XIAOU_AI_API_KEY="" +.\scripts\code-nest-eval.ps1 -Tier agent -LiveAi -AiBaseUrl "https://yaoshanapi.com/v1" -AiChatModel "gpt-5.5" +``` + +## 9. 下一步 + +- 给两个前端补 `test:contracts` script。 +- 给 CI 增加 T0/T1。 +- 从 P0 高风险模块开始补缺失测试,优先 `xiaou-chat`、`xiaou-points`、`xiaou-oj`、`xiaou-filestorage`。 +- 每补一个模块,把命令加入 `scripts/code-nest-eval.ps1` 对应 tier。 + +## 10. 已验证基线 + +截至 2026-07-09: + +- `hygiene`:通过。secret scan 已过滤测试占位值,真实密钥仍拦截。 +- `frontend`:通过,46 个 Node contract tests 全绿。 +- `ai`:通过,53 个 Java AI regression tests 全绿。 +- `rag`:通过,临时 venv 安装依赖后 10 个 unittest 全绿。 +- `smoke`:通过,前端 46 个 contract tests 与 Agent no-live eval 全绿。 +- `backend`:通过,Maven reactor 23 个模块 SUCCESS,用时约 2 分 38 秒。 +- `release`:通过,后端 package、双前端 build、docs build、Python/Bash 脚本语法检查均通过。 +- `agent-live`:通过,真实 `gpt-5.5` 执行工具目录和运行时状态两个只读工具,并在随机临时 MySQL 库完成解除禁言写入、强确认和 `EXECUTED` 审计闭环。 + +当前主要剩余缺口不是“已有测试能不能跑”,而是“部分业务模块还没有足够的业务断言”。下一阶段应优先把空白/低覆盖模块纳入可重复测试矩阵,而不是继续扩散临时脚本。 + +非阻断观察项: + +- Maven 每轮提示 profile `rdc` 不存在,需要确认是否来自本机 Maven 配置或 CI 配置。 +- Vite/VitePress build 有 chunk size 与 Rollup PURE 注释提示,当前不阻断。 +- Windows 控制台仍可能对部分 ANSI/UTF-8 输出显示乱码,测试结果不受影响。 +- 写操作 live 验收采用随机临时 MySQL 库和专用 fixture,测试结束删除数据库,不修改现有业务库。 diff --git a/AI-DOCS/Technical/13-Code-Nest-full-site-test-results.md b/AI-DOCS/Technical/13-Code-Nest-full-site-test-results.md new file mode 100644 index 000000000..26c4ba6e0 --- /dev/null +++ b/AI-DOCS/Technical/13-Code-Nest-full-site-test-results.md @@ -0,0 +1,233 @@ +# Code-Nest 全站测试结果报告 + +## 结论 + +截至 2026-07-14,Code-Nest 全站基础门禁、Agent 统一后端回归以及当前 26 个已注册 Agent 工具的真实 AI live 验收均已跑通。 + +这里的“26 个工具全通过”只表示当前 `AgentToolRegistry` 中的全部生产工具已覆盖,不表示尚未注册为 Agent 工具的全站业务接口已经能被自然语言调用。 + +真实 API Key 只通过测试进程环境变量注入,没有写入配置、源码或测试报告。 + +## 汇总 + +| 测试层 | 结果 | 覆盖内容 | 备注 | +|---|---|---|---| +| `hygiene` | 通过 | `git diff --check`、secret scan、NUL scan | secret scan 已过滤测试占位值,真实密钥仍会失败 | +| `frontend` | 通过 | 双前端 Node contract tests | 共 46 个测试通过 | +| `ai` | 通过 | Java AI regression tests | 共 54 个测试通过 | +| `rag` | 通过 | `llamaindex-service` Python unittest | 临时 venv 安装依赖后 10 个测试通过 | +| `smoke` | 通过 | 前端 contract + Agent no-live eval | 日常最快全站入口已可用 | +| `backend` | 通过 | Maven 多模块测试 | Reactor 23 个模块 SUCCESS;最新复验约 2 分 33 秒,包含 `xiaou-filestorage` 10 个、`xiaou-oj` 20 个、`xiaou-points` 36 个和 `xiaou-chat` 38 个测试 | +| `release` | 通过 | 后端 package、双前端 build、docs build、脚本语法检查 | 首次因 Bash 环境失败,修复后重跑通过 | +| `agent-live` | 通过 | 真实 `gpt-5.5` planner + 完整 26 工具 Registry + 真实只读/写入工具执行 | 全工具 live acceptance 独立通过两次;最新完整运行 `26/26`,写工具包含隔离 MySQL、强确认、审计和结果回查 | + +封版基线更新为 `v2.4.0` 后再次执行统一 `all` 门禁和 `release` 门禁,分别以退出码 `0` 通过;最终产物包含 `xiaou-application-v2.4.0.jar`、管理端 dist、用户端 dist 和 VitePress dist。 + +## Agent 真实后端验收结果 + +本轮真实验收不是只探测网关,也没有用模型 fixture 代替 planner。测试通过真实 `AgentChatOrchestrator.chat()` 发送自然语言,由真实 `gpt-5.5` 选择工具并执行生产 `AgentTool`。API Key 只进入测试进程,报告和仓库均不保存明文。 + +| 验收层 | 结果 | 真实断言 | +|---|---|---| +| OpenAI 兼容网关 | 通过 | `/v1/models` 返回 `gpt-5.5`;`/v1/chat/completions` 返回预期文本。根地址返回站点 HTML,因此 Base URL 必须包含 `/v1` | +| 真实 smoke | `2/2` 通过 | 共完成 3 次真实 planner 请求;正确执行 `system.agent.tools.list` 和 `system.agent.runtime.status`,严格 planner 场景无 deterministic fallback | +| 完整生产工具目录 | `26/26` 通过 | 每个注册工具至少有一个自然语言 live 场景;工具名、状态、trace 和 artifact 均通过断言 | +| 解除禁言 | 通过 | `chat.userBan.unban` 返回 `confirm_required -> executed`;预览阶段数据未变化,确认后禁言状态和审计终态均回查成功 | +| 清理过期日志 | 通过 | `system.operationLog.cleanExpired` 返回 `confirm_required -> executed`;只删除 30 天前数据,近期日志保留 | +| 缺少必填输入 | 通过 | “解除禁言但不提供用户编号”返回 `PLAN_CLARIFICATION_REQUIRED`,没有执行写入 | + +完整 26 工具 live acceptance 已独立通过两次。最新一次 Surefire 结果为 `1/1`、零失败、零错误,耗时 `1075.929s`;耗时主要来自外部网关多次 `502/524` 后的真实重试,最终没有走本地 fallback。 + +一次修复前的组合脚本运行中,完整 26 工具 live acceptance 已通过,但同一 Maven 进程里的 smoke 因禁用了网络重试而超时,后端结果为 `211/212`。该次运行没有记为全绿;将 smoke 调整为与生产一致的 60 秒读取超时和 2 次真实 HTTP 重试后,smoke `2/2` 通过,随后无 live 的统一回归也打印 `all checks passed`。 + +写操作验收使用随机命名的临时 MySQL 数据库,加载真实 Chat、Operation Log 和 Agent Audit MyBatis Mapper,调用真实业务 Service、真实写工具与真实审计服务。测试无论成功或失败都会在 `finally` 删除整个临时数据库,不修改现有 `code_nest` 业务库。 + +写操作还验证了以下运行轨迹: + +- 两个预览响应均为 `confirm_required`,审计状态为 `PREVIEW`,业务 fixture 均未变化。 +- 确认文本必须分别精确匹配工具声明的 `确认解除禁言` 和 `确认清理操作日志`。 +- 确认后 trace 包含 `audit.confirmed=done`、`tool.executed=done`、`audit.result_recorded=done`。 +- 最终禁言记录 `status=0`;过期日志删除、近期日志保留;两条审计均为 `EXECUTED` 并记录结果 JSON。 + +真实 live 目标测试和完整 Agent 回归均通过: + +```powershell +$env:XIAOU_AI_BASE_URL = "https:///v1" +$env:XIAOU_AI_API_KEY = "" +$env:XIAOU_AI_CHAT_MODEL = "gpt-5.5" +mvn -pl xiaou-system -am "-Dtest=AgentRuntimeLiveAiSmokeTest" "-Dsurefire.failIfNoSpecifiedTests=false" test +.\scripts\agent-runtime-eval.ps1 -LiveAi -AiBaseUrl $env:XIAOU_AI_BASE_URL -AiChatModel $env:XIAOU_AI_CHAT_MODEL +.\scripts\agent-runtime-eval.ps1 -LiveWrite -AiBaseUrl $env:XIAOU_AI_BASE_URL -AiChatModel $env:XIAOU_AI_CHAT_MODEL ` + -MysqlUrl $env:AGENT_TEST_MYSQL_URL -MysqlUsername $env:AGENT_TEST_MYSQL_USERNAME ` + -MysqlPassword $env:AGENT_TEST_MYSQL_PASSWORD +``` + +报告中的命令刻意不包含 API Key。执行时应使用 `XIAOU_AI_API_KEY` 环境变量或临时参数注入。 + +## 写操作验收基座扩展状态 + +写操作 live 测试已从解除禁言单场景重构为通用 `AgentLiveWriteAcceptanceHarness`。基座统一负责真实 AI planner、临时 MySQL 数据库生命周期、MyBatis Mapper 装载、统一编排器以及 `PREVIEW -> CONFIRMED -> EXECUTED` 审计断言;业务场景只提供最小表结构、fixture 和业务结果断言。 + +当前同一个 `AgentToolRegistry` 同时注册 `chat.userBan.unban` 与 `system.operationLog.cleanExpired`,避免只有一个候选工具时的弱路由测试。新增操作日志场景会创建一条 45 天前的过期日志和一条 2 天内的近期日志,预览阶段断言两条记录都不变,确认后断言只删除超过 30 天的记录。 + +本轮已验证: + +- `OperationLogListAgentToolTest` 共 5 个测试通过,覆盖清理请求解析、破坏性风险声明、预览零写入和类型化 Service 执行。 +- `AgentRuntimeLiveWriteAcceptanceTest` 默认模式编译通过并按 opt-in 约定跳过真实外部调用。 +- 完整 `scripts/agent-runtime-eval.ps1` 无 live 模式通过,两个写工具都从统一聊天入口进入 `confirm_required`。 +- 完整 26 工具 `AgentRuntimeLiveWriteAcceptanceTest` 已使用真实 `gpt-5.5` 与隔离 MySQL 独立通过两次;最新 Surefire 结果为 `1/1`,零失败、零错误、零跳过。 +- 测试后独立查询 `information_schema`,`code_nest_agent_it_%` 临时数据库残留数量为 0;进程环境中的 AI Key、MySQL 密码和 live 标志均已清除。 + +## 统一自然语言规划器回归结果 + +- `LlmAgentPlanResolver` 现在优先把自然语言、会话上下文和完整工具 schema 交给 LLM;只有模型不可用、响应无效或没有形成计划时,才调用 deterministic resolver 兜底。 +- `AgentTool.resolve()` 改为可选默认能力,新工具只实现 definition/schema、预览和执行即可被 LLM 规划,不再被接口强制要求维护关键词匹配。 +- 当前生产工具目录包含 26 个 `AgentTool` Bean;`AgentRuntimeAllToolsUnifiedEntryTest` 使用完整目录和统一 `AgentChatOrchestrator.chat()` 验证每个工具的计划、风险、确认/执行状态。 +- 本轮 Agent tier 通过:后端 Agent 回归 215 个测试(3 个 live 用例按 opt-in 跳过),前端统一聊天薄边界 11 个测试通过。 +- `AgentRuntimeLiveAiSmokeTest` 使用完整 26 工具 Registry 和真实 `gpt-5.5`;最终 `2/2` 通过,共完成 3 次真实 planner 请求,未使用 deterministic fallback。 + +## 统一 HTTP 入口回归结果 + +- `AgentChatControllerTest` 当前 `5/5` 通过;用例使用 `MockMvc` 将 JSON 请求真实绑定到 `POST /admin/agent/chat`,并验证超长消息在进入编排器前返回 `400`。 +- 该用例经过真实 `AgentChatController`、`LlmAgentPlanResolver`、`AgentToolRegistry`、`AgentPolicyEngine` 和 `AgentChatOrchestrator`,只替换模型响应 fixture、管理员服务和审计外部依赖。 +- 测试工具只声明 definition/schema、预览和执行,没有实现关键词 `resolve()`;自然语言请求仍由 planner 候选驱动,schema 外字段在执行前被过滤。 +- 响应断言覆盖统一 `Result` 包装、会话 ID、状态、工具名、trace、结构化 artifact、管理员权限和请求长度边界。会话存储回归同时确认相同 `sessionId` 在不同管理员下互相隔离,无归属审计不能继续确认。 +- 最新无 live `scripts/agent-runtime-eval.ps1` 通过:Agent 后端 `215` 个测试零失败、`3` 个 live 用例按 opt-in 跳过,AI Prompt/Schema `19/19`、前端薄边界 `11/11` 通过,hygiene 检查通过。 + +## 积分抽奖后端回归结果 + +`xiaou-points` 已进入统一 backend tier,不再是仅编译、无业务断言的模块。本轮新增两个测试类,共 36 个测试,零失败、零错误、零跳过: + +- 风控责任链 16 个测试,覆盖固定链序、黑名单、积分缺失、全局/用户/IP 限流、Redis fail-open、限流器初始化和多档冷却时间。 +- `LotteryServiceImpl` 20 个测试,覆盖熔断、用户锁、风控短路、原子扣分、策略调用、库存扣减、奖励发放、记录与事件顺序、有限库存补偿、无限库存不补偿、中断标记、历史记录契约、统计和剩余次数边界。 +- JaCoCo 结果:`LotteryServiceImpl` 行覆盖 `186/187`,核心 `draw()` 行覆盖 `37/37`、分支覆盖 `10/10`;六个风控链核心类行覆盖 `83/83`、分支覆盖 `43/48`。 + +测试按 RED -> 最小修复 -> GREEN 发现并修复四类问题:空积分值触发 NPE、风控链返回 `false` 后仍继续抽奖、立即响应与历史响应字段不一致、旧限制记录的今日次数为空时剩余次数接口触发 NPE。立即响应和历史响应现共用同一个转换路径,成本、净收益、中奖状态、策略、IP 和设备契约保持一致。 + +## 聊天消息后端回归结果 + +`xiaou-chat` 已从无模块测试状态进入统一 backend tier。本轮为 `ChatMessageServiceImpl` 新增 38 个测试,零失败、零错误、零跳过,覆盖文本/图片输入白名单、禁言短路、消息写入与回读、回复摘要、历史顺序、撤回窗口、单删/批量删除和系统公告。 + +- JaCoCo 结果:`ChatMessageServiceImpl` 行覆盖 `126/128`(98.4%),分支覆盖 `87/94`(92.6%);未覆盖的两行仅是管理端分页委托。 +- 消息写入与完整消息回读现在处于同一事务;回读为空会抛业务异常并回滚,不再把 `null` 交给 WebSocket 调用方。 +- 回复消息必须属于当前聊天室且未被软删除,避免跨房间或已删除内容重新进入消息摘要。 +- 图片 URL 的有效上限始终不超过数据库 `VARCHAR(500)`,即使外部配置误设得更大也会在访问房间和 Mapper 前拒绝。 +- 已软删除消息按不可见处理,不能重复撤回;批量删除影响 0 行会明确失败,不再记录伪成功。 + +本轮没有声称整个聊天模块完全覆盖。WebSocket 会话、在线状态、聊天限流与禁言管理的独立回归仍属于后续测试范围。 + +## 文件存储策略回归结果 + +`xiaou-filestorage` 本轮模块测试共 `10/10` 通过且无失败、无错误、无跳过:`FileStorageServiceImplTest` 4 个测试、`LocalStorageStrategyTest` 5 个真实临时文件系统测试,以及 1 个共享策略生命周期契约测试。 + +- 正常链路覆盖流式上传、目录创建、下载、存在性、URL、复制、大小查询和删除。 +- RED 测试确认本地策略的八类操作直接拼接 `basePath` 与用户路径,`../outside.txt` 可以越界读写;绝对路径、符号链接和 `.` 根路径也分别形成边界风险。 +- GREEN 修复将基目录统一规范化为绝对真实路径,所有上传、下载、删除、存在性、URL、复制和大小操作共用同一个安全解析器。 +- 解析器拒绝绝对路径、规范化后越界路径、基础目录本身和路径中的符号链接;非法用户路径按现有接口契约返回失败、`null` 或 `false`。 +- JaCoCo:`LocalStorageStrategy` 行覆盖 `75/119`(63.0%),分支覆盖 `16/28`(57.1%);剩余未覆盖主要是初始化失败、连接失败和底层 I/O 异常分支,不代表本地路径边界未测试。 +- 共享 `AbstractFileStorageStrategy` 契约验证重新初始化抛异常后会立即失效,后续 `testConnection()` 不再使用旧的可用状态;其 JaCoCo 行覆盖 `20/35`(57.1%)、分支覆盖 `5/10`(50.0%)。 +- 非法路径日志从完整异常堆栈收敛为简短 `WARN`,系统 I/O 故障仍保留 `ERROR` 堆栈,避免把可预期输入拒绝放大为日志噪音。 + +## OJ 判题服务回归结果 + +`xiaou-oj` 本轮将 `JudgeServiceTest` 扩展到 10 个测试,模块全测试共 `20/20` 通过且无失败、无错误、无跳过。 + +- RED 测试确认 go-judge 返回空 `stderr`、但 `error` 字段包含真实原因时,编译错误和运行错误都会被记录成空字符串,用户无法看到沙箱故障原因。 +- GREEN 修复将编译阶段和运行阶段的错误提取统一到 `getErrorMessage`:优先使用非空 stderr,否则回退到 error 字段。 +- accepted 链路同时验证了编译缓存文件传递、逐用例输出比较、耗时/内存统计、提交状态更新和缓存清理,避免修复错误信息时破坏判题主链路。 +- 超时和超内存场景验证判题会立即停止,且保留已通过用例数、总用例数、最大耗时和最大内存。 +- RED 测试确认 `.strip()` 会同时忽略前导空白,与“只忽略末尾空白”的比较契约不一致,导致本应答案错误的输出被判为通过;现改为 `stripTrailing()`。 +- 新增首次 AC 计数/积分副作用隔离、计数或积分异常不污染 accepted 主结果、运行异常转 `SYSTEM_ERROR` 并清理编译缓存的回归场景。 +- JaCoCo:`JudgeService` 行覆盖 `116/123`(94.3%),分支覆盖 `40/54`(74.1%)。 + +## 已执行命令 + +```powershell +.\scripts\code-nest-eval.ps1 -Tier hygiene +.\scripts\code-nest-eval.ps1 -Tier frontend +.\scripts\code-nest-eval.ps1 -Tier ai +.\scripts\code-nest-eval.ps1 -Tier rag +.\scripts\code-nest-eval.ps1 -Tier smoke +.\scripts\code-nest-eval.ps1 -Tier backend +.\scripts\code-nest-eval.ps1 -Tier release +mvn -pl xiaou-points -am org.jacoco:jacoco-maven-plugin:prepare-agent test org.jacoco:jacoco-maven-plugin:report +mvn -pl xiaou-chat -am org.jacoco:jacoco-maven-plugin:prepare-agent test org.jacoco:jacoco-maven-plugin:report +mvn -pl xiaou-filestorage -am test -q +mvn -pl xiaou-filestorage -am org.jacoco:jacoco-maven-plugin:prepare-agent test org.jacoco:jacoco-maven-plugin:report -q +mvn -pl xiaou-oj -am test -q +mvn -pl xiaou-oj -am org.jacoco:jacoco-maven-plugin:prepare-agent test org.jacoco:jacoco-maven-plugin:report -q +``` + +RAG sidecar 使用临时虚拟环境验证: + +```powershell +python -m venv .tmp\code-nest-rag-venv +$env:CODE_NEST_PYTHON=(Resolve-Path ".tmp\code-nest-rag-venv\Scripts\python.exe").Path +.\scripts\code-nest-eval.ps1 -Tier rag -InstallPythonDeps +``` + +前端包内测试入口也已验证: + +```powershell +npm --prefix vue3-admin-front run test:contracts +npm --prefix vue3-user-front run test:contracts +``` + +## 中途发现并修复的问题 + +| 问题 | 处理结果 | +|---|---| +| 中转站根地址返回管理站点 HTML,模型接口被误判为不可用 | 明确使用带 `/v1` 的 OpenAI 兼容 Base URL;真实 `/v1/models` 和 `/v1/chat/completions` 均验证通过 | +| 推理模型没有 completion 上限,大工具目录 Prompt 可持续占用连接直到超时 | 增加全局 `XIAOU_AI_MAX_COMPLETION_TOKENS`,并支持 `AiPromptSpec` 声明场景级预算;管理员 planner 使用 512 | +| “预演某请求”可能直接执行内层目标工具 | planner 统一增加外层主动作规则;预演、预检、校验、解释和恢复分析优先选择元工具,不按具体工具硬编码 | +| 客户端 `sessionId` 直接作为共享存储键,不同管理员同名会话可能串读上下文 | Repository 存储键统一增加管理员 ID 命名空间,响应仍保留原始 `sessionId`;新增双管理员隔离回归 | +| 统一聊天请求缺少长度边界,空归属历史审计仍可进入确认判断 | DTO 增加四个字段长度上限并在 Controller 启用 `@Valid`;审计归属改为严格匹配,空归属直接拒绝 | +| 指标健康验收语句同时要求“告警总览”,与运行时观测工具语义重叠 | 收紧 live fixture 的验收意图,明确只检查错误、阻断和慢调用信号;生产路由未增加测试专用分支 | +| live smoke 将网络重试设为 0,网关偶发超时会直接阻断整套回归 | 保持 deterministic fallback 关闭,但传输层对齐生产的 60 秒读取超时和 2 次重试 | +| secret scan 误报测试占位值 `secret-key` | 已改成命中后过滤测试/文档占位值,真实长随机密钥仍拦截 | +| RAG unittest 首次缺 `fastapi` | 新增 `rag` tier、`-PythonCommand`、`-InstallPythonDeps`,并用临时 venv 跑通 | +| Windows 默认 `bash` 指向 WSL shim,导致 release 失败 | 已改成优先解析 Git Bash,并支持 `CODE_NEST_BASH` / `-BashCommand` | +| 前端缺包内标准测试命令 | 已给两个前端补 `test:contracts` | +| 抽奖风控链返回 `false` 时服务仍继续执行 | 服务入口现在显式检查布尔结果并立即短路 | +| 抽奖立即响应与历史响应字段不一致 | 两条路径改为复用同一个记录转换函数 | +| 积分余额或今日抽奖次数为空时触发 NPE | 在业务读取边界按 0 归一,并增加回归测试 | +| 聊天消息插入后回读为空仍返回 `null`,且写入/回读无事务 | 增加事务边界;回读失败抛业务异常并回滚 | +| 回复可引用其他房间或已删除消息 | 回复元数据只接受当前房间内可见消息 | +| 图片 URL 默认/可配置上限超过数据库列长度 | 有效上限收敛到 500,并在外部查询前校验 | +| 已删除消息可重复撤回、批量删除 0 行仍成功 | 在服务状态边界显式拒绝并增加回归测试 | +| 本地存储路径可通过 `..`、绝对路径或符号链接越过配置目录 | 所有本地策略操作统一经过规范化、包含性和符号链接校验;增加真实临时目录回归测试 | +| go-judge 空 `stderr` 覆盖真实 `error`,导致编译/运行失败原因丢失 | 编译和运行阶段统一使用非空 stderr 优先、error 回退的错误提取逻辑,并增加判题回归测试 | +| 判题输出比较使用 `.strip()`,错误忽略前导空白 | 改为只去除末尾空白,并增加前导空白差异的 WRONG_ANSWER 回归测试 | + +## 非阻断观察项 + +- Maven 每轮提示 requested profile `rdc` 不存在,目前不影响构建和测试。 +- Vite/VitePress build 有 chunk size 与 Rollup PURE 注释警告,目前不阻断 release。 +- Windows 控制台对部分 ANSI/UTF-8 输出仍可能显示乱码,但测试结果不受影响。 +- 部分业务模块仍属于低覆盖或空白覆盖,当前“通过”代表已有测试和构建门禁通过,不代表业务断言已经完全充分。 + +## CI 门禁接入 + +主 CI 已复用统一测试入口,不在 workflow 中维护第二份 Java 测试清单: + +- Backend job 在 package 前执行 `scripts/code-nest-eval.ps1 -Tier backend`,失败时上传 Surefire 报告。 +- Admin/User frontend matrix 在 build 前分别执行包内 `test:contracts`。 +- 新增独立 RAG job,使用 Python 3.11、pip cache 和统一 `rag -InstallPythonDeps` 入口。 +- Scripts job 执行跨平台 `hygiene`、Python 语法检查和 Bash 语法检查。 +- `ci-summary` 现在同时依赖 backend、frontend、docs、rag、scripts;workflow 权限收敛为 `contents: read`。 +- 真实 AI Key 与 MySQL live 测试不进入普通 CI,继续保持手动 opt-in,避免长期凭据和外部服务波动阻断提交。 + +本地验证结果:PyYAML 结构检查通过,官方 `actionlint v1.7.12` 语义检查通过,backend 23 模块 `BUILD SUCCESS`,`xiaou-filestorage` `10/10`、`xiaou-oj` `20/20`、`xiaou-points` `36/36`、`xiaou-chat` `38/38` 通过,双前端 contract `19 + 27` 通过,RAG `10` 通过,hygiene 通过。GitHub-hosted runner 的首次远端结果需要在 workflow 推送后确认,本报告未将本地结果冒充为远端运行结果。 + +## 尚未执行项 + +| 项目 | 原因 | +|---|---| +| GitHub-hosted runner 首次 CI | workflow 已完成本地结构、语义和命令验证,需推送后确认远端环境 | +| Redis/对象存储/判题沙箱集成测试 | 当前已覆盖隔离 MySQL,其他外部依赖层后续单独治理 | + +## 下一步建议 + +1. 推送后观察首次远端 CI,并将 `CI Summary` 设置为分支保护 required check。 +2. 继续从低覆盖高风险模块补测试:优先推进文件存储其他云策略和 Redis/对象存储/判题沙箱集成边界;本轮已补齐 OJ 首次 AC、副作用隔离、系统异常清理、错误回退、资源限制和答案比较。 +3. 后续新增写工具直接复用通用基座,只补业务 schema、fixture 和结果断言,不再复制数据库和确认链代码。 diff --git a/AI-DOCS/Technical/README.md b/AI-DOCS/Technical/README.md index 7bcf72ca7..43e68b962 100644 --- a/AI-DOCS/Technical/README.md +++ b/AI-DOCS/Technical/README.md @@ -20,3 +20,4 @@ - [08-统一AI服务与Prompt工程.md](./08-统一AI服务与Prompt工程.md) - [09-AI工作流编排与技能化封装.md](./09-AI工作流编排与技能化封装.md) - [10-AI能力平台化与工作流编排.md](./10-AI能力平台化与工作流编排.md) +- [11-管理员端超级智能体后端统一运行时设计.md](./11-管理员端超级智能体后端统一运行时设计.md) diff --git a/docker/ai/README.md b/docker/ai/README.md index 36515c923..7889c9dc2 100644 --- a/docker/ai/README.md +++ b/docker/ai/README.md @@ -20,6 +20,7 @@ cp .env.example .env - `XIAOU_AI_BASE_URL` - `XIAOU_AI_API_KEY` - `XIAOU_AI_CHAT_MODEL` +- `XIAOU_AI_MAX_COMPLETION_TOKENS`(可选,默认 `2048`) - `XIAOU_AI_RAG_API_KEY` 3. 在仓库根目录执行: diff --git a/docker/ai/docker-compose.yml b/docker/ai/docker-compose.yml index 402b0a8a6..6df2e9bc4 100644 --- a/docker/ai/docker-compose.yml +++ b/docker/ai/docker-compose.yml @@ -87,6 +87,7 @@ services: XIAOU_AI_BASE_URL: ${XIAOU_AI_BASE_URL:-} XIAOU_AI_API_KEY: ${XIAOU_AI_API_KEY:-} XIAOU_AI_CHAT_MODEL: ${XIAOU_AI_CHAT_MODEL:-gpt-5.4} + XIAOU_AI_MAX_COMPLETION_TOKENS: ${XIAOU_AI_MAX_COMPLETION_TOKENS:-2048} XIAOU_AI_RAG_ENABLED: ${XIAOU_AI_RAG_ENABLED:-true} XIAOU_AI_RAG_ENDPOINT: http://llamaindex-service:18080 XIAOU_AI_RAG_API_KEY: ${XIAOU_AI_RAG_API_KEY:-} diff --git a/docker/env/example.env b/docker/env/example.env index f30760618..dfe44e7b6 100644 --- a/docker/env/example.env +++ b/docker/env/example.env @@ -16,6 +16,7 @@ XIAOU_AI_PROVIDER=openai-compatible XIAOU_AI_BASE_URL=https://your-openai-proxy.example.com/v1 XIAOU_AI_API_KEY=sk-xxxx XIAOU_AI_CHAT_MODEL=gpt-5.4 +XIAOU_AI_MAX_COMPLETION_TOKENS=2048 XIAOU_AI_RAG_ENABLED=true XIAOU_AI_RAG_ENDPOINT=http://llamaindex-service:18080 XIAOU_AI_RAG_API_KEY=rag-local-key diff --git a/docs-site/operations/env-vars.md b/docs-site/operations/env-vars.md index 531714f7f..5c17d5fdb 100644 --- a/docs-site/operations/env-vars.md +++ b/docs-site/operations/env-vars.md @@ -78,6 +78,7 @@ Sa-Token 公共配置(`application.yml`): | `XIAOU_AI_BASE_URL` | 空 | `application.yml` | AI API Base URL,必填 | | `XIAOU_AI_API_KEY` | 空 | `application.yml` | AI API Key,必填 | | `XIAOU_AI_CHAT_MODEL` | `gpt-5.4` | `application.yml` | 聊天模型名称 | +| `XIAOU_AI_MAX_COMPLETION_TOKENS` | `2048` | `application.yml` | 单次 completion 上限,推理模型同时约束推理与最终输出 | | `XIAOU_AI_EMBEDDING_MODEL` | `text-embedding-3-small` | `application.yml` | Embedding 模型名称 | | `XIAOU_AI_TIMEOUT_CONNECT_MS` | `10000` | `application.yml` | 连接超时 ms | | `XIAOU_AI_TIMEOUT_READ_MS` | `60000` | `application.yml` | 读取超时 ms | @@ -194,6 +195,7 @@ XIAOU_AI_PROVIDER=openai-compatible XIAOU_AI_BASE_URL=https://your-openai-proxy.example.com/v1 XIAOU_AI_API_KEY=sk-your-api-key XIAOU_AI_CHAT_MODEL=gpt-4o +XIAOU_AI_MAX_COMPLETION_TOKENS=2048 XIAOU_AI_EMBEDDING_MODEL=text-embedding-3-small # ---- RAG ---- diff --git a/docs-site/operations/troubleshooting.md b/docs-site/operations/troubleshooting.md index eb0702f76..3988cb2f2 100644 --- a/docs-site/operations/troubleshooting.md +++ b/docs-site/operations/troubleshooting.md @@ -379,6 +379,7 @@ proxy: { | 模型名称不匹配 | 确认 `XIAOU_AI_CHAT_MODEL`,默认 `gpt-5.4` | | 网络不通 | 检查代理设置或防火墙 | | 读取超时 | 增大 `xiaou.ai.timeout.read-ms`,默认 60000ms | +| 推理模型长时间不结束 | 设置 `XIAOU_AI_MAX_COMPLETION_TOKENS`;默认 `2048`,短结构化 Prompt 可声明更小预算 | | AI 功能未开启 | 确认 `xiaou.ai.enabled=true` | > **提示**:不配置 AI 也能正常启动,只是 AI 相关接口会返回降级响应。 diff --git a/docs-site/reference/env-vars.md b/docs-site/reference/env-vars.md index e0a745eec..3810e1e08 100644 --- a/docs-site/reference/env-vars.md +++ b/docs-site/reference/env-vars.md @@ -62,6 +62,7 @@ java -jar app.jar --spring.profiles.active=prod,sec | `XIAOU_AI_BASE_URL` | `xiaou.ai.base-url` | 空 | AI API 基础 URL | | `XIAOU_AI_API_KEY` | `xiaou.ai.api-key` | 空 | **生产环境必须覆盖** | | `XIAOU_AI_CHAT_MODEL` | `xiaou.ai.model.chat` | `gpt-5.4` | Chat 模型名 | +| `XIAOU_AI_MAX_COMPLETION_TOKENS` | `xiaou.ai.model.max-completion-tokens` | `2048` | 单次 completion 上限 | | `XIAOU_AI_EMBEDDING_MODEL` | `xiaou.ai.model.embedding` | `text-embedding-3-small` | Embedding 模型名 | | `XIAOU_AI_PRICING_CURRENCY` | `xiaou.ai.pricing.currency` | `USD` | 定价货币 | | `XIAOU_AI_INPUT_PRICE_PER_MILLION` | `xiaou.ai.pricing.input-per-million` | `0` | 输入单价(每百万 token) | @@ -136,6 +137,7 @@ xiaou: api-key: sk-your-real-key model: chat: gpt-5.4 + max-completion-tokens: 2048 ``` > **不要把真实密钥写在 `application-dev.yml` 或 `application-docker.yml` 中**。这些文件会进入 Git 仓库。 diff --git a/scripts/agent-runtime-eval.ps1 b/scripts/agent-runtime-eval.ps1 new file mode 100644 index 000000000..85383fa15 --- /dev/null +++ b/scripts/agent-runtime-eval.ps1 @@ -0,0 +1,247 @@ +param( + [switch]$LiveAi, + [switch]$LiveWrite, + [string]$AiBaseUrl = $env:XIAOU_AI_BASE_URL, + [string]$AiApiKey = $env:XIAOU_AI_API_KEY, + [string]$AiChatModel = $(if ($env:XIAOU_AI_CHAT_MODEL) { $env:XIAOU_AI_CHAT_MODEL } else { "gpt-5.5" }), + [string]$MysqlUrl = $env:AGENT_TEST_MYSQL_URL, + [string]$MysqlUsername = $env:AGENT_TEST_MYSQL_USERNAME, + [string]$MysqlPassword = $env:AGENT_TEST_MYSQL_PASSWORD +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +Set-Location $repoRoot + +$backendRuntimeTests = @( + "AgentChatControllerTest", + "AgentChatOrchestratorTest", + "AgentOperatorTest", + "AgentPolicyEngineTest", + "AgentToolRegistryTest", + "AgentSessionContextStoreTest", + "InMemoryAgentSessionContextRepositoryTest", + "RedisAgentSessionContextRepositoryTest", + "DbAgentSessionContextRepositoryTest", + "AgentToolMetricsRecorderTest", + "AgentToolDefinitionBuilderTest", + "AbstractReadonlyAgentToolTest", + "AgentToolDefinitionContractTest", + "AgentToolAccessDefinitionTest", + "AgentPermissionSeedContractTest", + "AgentToolCatalogAgentToolTest", + "AgentToolDetailAgentToolTest", + "AgentToolSearchAgentToolTest", + "AgentToolDefinitionValidateAgentToolTest", + "AgentToolAccessCheckAgentToolTest", + "AgentPolicyDryRunAgentToolTest", + "AgentRuntimeStatusAgentToolTest", + "AgentRuntimeReadinessAgentToolTest", + "AgentRuntimeObservabilityAgentToolTest", + "AgentSessionContextAgentToolTest", + "AgentOperatorSelfAgentToolTest", + "AgentPlannerDiagnosticsAgentToolTest", + "AgentPlannerDryRunAgentToolTest", + "AgentRequestDryRunAgentToolTest", + "AgentToolMetricsSummaryAgentToolTest", + "AgentToolMetricsHealthAgentToolTest", + "AgentToolMetricsAlertsAgentToolTest", + "AgentAuditListAgentToolTest", + "AgentAuditDetailAgentToolTest", + "AgentRecoveryExplainAgentToolTest", + "AgentRecoveryDryRunAgentToolTest", + "OperationLogListAgentToolTest", + "ChatUserBanAgentToolTest", + "LotteryRealtimeMonitorAgentToolTest", + "LlmAgentPlanResolverTest", + "AgentRuntimeLiveAiSmokeTest", + "AgentRuntimeLiveWriteAcceptanceTest", + "AgentRuntimeAllToolsUnifiedEntryTest", + "AdminAgentPlannerRegressionTest", + "SysAgentAuditServiceImplTest" +) + +$aiStructuredTests = @( + "AiPromptSpecTest", + "AiStructuredOutputSpecTest", + "AiStructuredOutputValidatorTest", + "AiStructuredOutputSchemaExporterTest", + "AiRagRetrievalProfileTest" +) + +function Invoke-Step { + param( + [string]$Name, + [scriptblock]$Command + ) + + Write-Host "" + Write-Host "[agent-runtime-eval] $Name" -ForegroundColor Cyan + & $Command +} + +function Invoke-Native { + param( + [string]$Command, + [string[]]$Arguments = @() + ) + + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code $LASTEXITCODE`: $Command $($Arguments -join ' ')" + } +} + +function Invoke-MavenTests { + param( + [string]$Module, + [string[]]$Tests + ) + + $args = @( + "-pl", $Module, + "-am", + "-Dtest=$($Tests -join ',')", + "-Dsurefire.failIfNoSpecifiedTests=false", + "test", + "-q" + ) + Invoke-Native -Command "mvn" -Arguments $args +} + +function Test-TextFileHasNul { + param([string]$Path) + + $bytes = [System.IO.File]::ReadAllBytes($Path) + return $bytes -contains 0 +} + +function Invoke-NulScan { + $excludedSegments = @( + "\.git\", + "\.codegraph\", + "\.codex-mihomo-geo\", + "\node_modules\", + "\target\", + "\dist\", + "\build\", + "\__pycache__\" + ) + $textExtensions = @( + ".java", ".xml", ".json", ".md", ".yml", ".yaml", ".js", ".ts", ".vue", + ".ps1", ".sql", ".properties", ".txt", ".css", ".html", ".csv" + ) + $violations = New-Object System.Collections.Generic.List[string] + + Get-ChildItem -Path $repoRoot -Recurse -File | ForEach-Object { + $fullName = $_.FullName + $excluded = $false + foreach ($segment in $excludedSegments) { + if ($fullName.Contains($segment)) { + $excluded = $true + break + } + } + + $extension = $_.Extension.ToLowerInvariant() + $isTextLike = $textExtensions.Contains($extension) -or $_.Name -eq "pom-xml-flattened" + if (-not $excluded -and $isTextLike -and (Test-TextFileHasNul -Path $fullName)) { + $violations.Add($fullName) + } + } + + if ($violations.Count -gt 0) { + $violations | ForEach-Object { Write-Error "NUL byte detected: $_" } + throw "NUL scan failed" + } + + Write-Host "[agent-runtime-eval] NUL scan passed" -ForegroundColor Green +} + +$previousLiveAi = $env:AGENT_LIVE_AI_TEST +$previousBaseUrl = $env:XIAOU_AI_BASE_URL +$previousApiKey = $env:XIAOU_AI_API_KEY +$previousChatModel = $env:XIAOU_AI_CHAT_MODEL +$previousLiveWrite = $env:AGENT_LIVE_WRITE_TEST +$previousMysqlUrl = $env:AGENT_TEST_MYSQL_URL +$previousMysqlUsername = $env:AGENT_TEST_MYSQL_USERNAME +$previousMysqlPassword = $env:AGENT_TEST_MYSQL_PASSWORD + +try { + if ($LiveWrite) { + $LiveAi = $true + } + if ($LiveAi) { + if ([string]::IsNullOrWhiteSpace($AiBaseUrl)) { + throw "Live AI acceptance requested but XIAOU_AI_BASE_URL / -AiBaseUrl is missing" + } + if ([string]::IsNullOrWhiteSpace($AiApiKey)) { + throw "Live AI acceptance requested but XIAOU_AI_API_KEY / -AiApiKey is missing" + } + $env:AGENT_LIVE_AI_TEST = "true" + $env:XIAOU_AI_BASE_URL = $AiBaseUrl + $env:XIAOU_AI_API_KEY = $AiApiKey + $env:XIAOU_AI_CHAT_MODEL = $AiChatModel + Write-Host "[agent-runtime-eval] Live AI acceptance enabled: baseUrl=$AiBaseUrl model=$AiChatModel" -ForegroundColor Yellow + } else { + $env:AGENT_LIVE_AI_TEST = "false" + Write-Host "[agent-runtime-eval] Live AI acceptance disabled; pass -LiveAi to enable it" -ForegroundColor Yellow + } + + if ($LiveWrite) { + if ([string]::IsNullOrWhiteSpace($MysqlUrl)) { + throw "Live write acceptance requested but AGENT_TEST_MYSQL_URL / -MysqlUrl is missing" + } + if ([string]::IsNullOrWhiteSpace($MysqlUsername)) { + throw "Live write acceptance requested but AGENT_TEST_MYSQL_USERNAME / -MysqlUsername is missing" + } + if ([string]::IsNullOrWhiteSpace($MysqlPassword)) { + throw "Live write acceptance requested but AGENT_TEST_MYSQL_PASSWORD / -MysqlPassword is missing" + } + $env:AGENT_LIVE_WRITE_TEST = "true" + $env:AGENT_TEST_MYSQL_URL = $MysqlUrl + $env:AGENT_TEST_MYSQL_USERNAME = $MysqlUsername + $env:AGENT_TEST_MYSQL_PASSWORD = $MysqlPassword + Write-Host "[agent-runtime-eval] Live write acceptance enabled with an isolated temporary MySQL database" -ForegroundColor Yellow + } else { + $env:AGENT_LIVE_WRITE_TEST = "false" + Write-Host "[agent-runtime-eval] Live write acceptance disabled; pass -LiveWrite to enable it" -ForegroundColor Yellow + } + + Invoke-Step "backend unified runtime regression" { + Invoke-MavenTests -Module "xiaou-system" -Tests $backendRuntimeTests + } + + Invoke-Step "AI prompt and structured output regression" { + Invoke-MavenTests -Module "xiaou-ai" -Tests $aiStructuredTests + } + + Invoke-Step "frontend thin-boundary regression" { + Invoke-Native -Command "node" -Arguments @( + "--test", + "vue3-admin-front/tests/admin-api-contract.test.js", + "vue3-admin-front/tests/admin-agent-chat-ui.test.js" + ) + } + + Invoke-Step "git diff whitespace check" { + Invoke-Native -Command "git" -Arguments @("diff", "--check") + } + + Invoke-Step "text NUL-byte scan" { + Invoke-NulScan + } + + Write-Host "" + Write-Host "[agent-runtime-eval] all checks passed" -ForegroundColor Green +} finally { + $env:AGENT_LIVE_AI_TEST = $previousLiveAi + $env:XIAOU_AI_BASE_URL = $previousBaseUrl + $env:XIAOU_AI_API_KEY = $previousApiKey + $env:XIAOU_AI_CHAT_MODEL = $previousChatModel + $env:AGENT_LIVE_WRITE_TEST = $previousLiveWrite + $env:AGENT_TEST_MYSQL_URL = $previousMysqlUrl + $env:AGENT_TEST_MYSQL_USERNAME = $previousMysqlUsername + $env:AGENT_TEST_MYSQL_PASSWORD = $previousMysqlPassword +} diff --git a/scripts/code-nest-eval.ps1 b/scripts/code-nest-eval.ps1 new file mode 100644 index 000000000..c36b4f9df --- /dev/null +++ b/scripts/code-nest-eval.ps1 @@ -0,0 +1,389 @@ +param( + [ValidateSet("smoke", "hygiene", "agent", "ai", "rag", "frontend", "backend", "release", "all")] + [string]$Tier = "smoke", + [switch]$LiveAi, + [switch]$LiveWrite, + [switch]$InstallPythonDeps, + [string]$PythonCommand = $(if ($env:CODE_NEST_PYTHON) { $env:CODE_NEST_PYTHON } else { "python" }), + [string]$BashCommand = $env:CODE_NEST_BASH, + [string]$AiBaseUrl = $env:XIAOU_AI_BASE_URL, + [string]$AiApiKey = $env:XIAOU_AI_API_KEY, + [string]$AiChatModel = $(if ($env:XIAOU_AI_CHAT_MODEL) { $env:XIAOU_AI_CHAT_MODEL } else { "gpt-5.5" }), + [string]$MysqlUrl = $env:AGENT_TEST_MYSQL_URL, + [string]$MysqlUsername = $env:AGENT_TEST_MYSQL_USERNAME, + [string]$MysqlPassword = $env:AGENT_TEST_MYSQL_PASSWORD +) + +$ErrorActionPreference = "Stop" +$OutputEncoding = [System.Text.UTF8Encoding]::new() +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +Set-Location $repoRoot + +$backendModulesWithTests = @( + "xiaou-system", + "xiaou-ai", + "xiaou-oj", + "xiaou-learning-asset", + "xiaou-mock-interview", + "xiaou-community", + "xiaou-user", + "xiaou-points", + "xiaou-filestorage", + "xiaou-sensitive", + "xiaou-moyu", + "xiaou-sql-optimizer" +) + +$aiRegressionTests = @( + "AiSceneRegressionEvalTest", + "InterviewGraphTest", + "JobBattleGraphTest", + "SqlOptimizeGraphTest", + "AiSqlOptimizeServiceImplTest", + "AiPromptSpecTest", + "AiStructuredOutputValidatorTest", + "LlamaIndexClientTest" +) + +$frontendTests = @( + "vue3-admin-front/tests/admin-api-contract.test.js", + "vue3-admin-front/tests/admin-agent-chat-ui.test.js", + "vue3-admin-front/tests/design-system-demo-routes.test.js", + "vue3-admin-front/tests/no-debug-console.test.js", + "vue3-admin-front/tests/request-options.test.js", + "vue3-admin-front/tests/sidebar-routes.test.js", + "vue3-user-front/tests/captcha-api-contract.test.js", + "vue3-user-front/tests/career-loop-adapter.test.js", + "vue3-user-front/tests/design-system-demo-routes.test.js", + "vue3-user-front/tests/home-data-adapter.test.js", + "vue3-user-front/tests/interview-navigation.test.js", + "vue3-user-front/tests/moyu-api-contract.test.js", + "vue3-user-front/tests/navigation-routes.test.js", + "vue3-user-front/tests/no-debug-console.test.js", + "vue3-user-front/tests/oj-contest-adapter.test.js", + "vue3-user-front/tests/request-options.test.js" +) + +function Invoke-Step { + param( + [string]$Name, + [scriptblock]$Command + ) + + Write-Host "" + Write-Host "[code-nest-eval] $Name" -ForegroundColor Cyan + & $Command +} + +function Invoke-Native { + param( + [string]$Command, + [string[]]$Arguments = @() + ) + + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code $LASTEXITCODE`: $Command $($Arguments -join ' ')" + } +} + +function Invoke-Maven { + param([string[]]$Arguments) + Invoke-Native -Command "mvn" -Arguments $Arguments +} + +function Resolve-BashCommand { + if ($BashCommand) { + return $BashCommand + } + + $candidatePaths = @( + "C:\Program Files\Git\bin\bash.exe", + "C:\Program Files\Git\usr\bin\bash.exe" + ) + foreach ($candidate in $candidatePaths) { + if (Test-Path $candidate) { + return $candidate + } + } + + $commands = @(Get-Command "bash" -All -ErrorAction SilentlyContinue | + Where-Object { $_.Source -notlike "*\Windows\system32\bash.exe" }) + if ($commands.Count -gt 0) { + return $commands[0].Source + } + + throw "Bash syntax check requires Git Bash, WSL bash, or CODE_NEST_BASH pointing to bash.exe" +} + +function Test-TextFileHasNul { + param([string]$Path) + + $bytes = [System.IO.File]::ReadAllBytes($Path) + return $bytes -contains 0 +} + +function Invoke-NulScan { + $excludedSegments = @( + "/.git/", + "/.codegraph/", + "/.codex-mihomo-geo/", + "/node_modules/", + "/target/", + "/dist/", + "/build/", + "/__pycache__/" + ) + $textExtensions = @( + ".java", ".xml", ".json", ".md", ".yml", ".yaml", ".js", ".ts", ".vue", + ".ps1", ".sql", ".properties", ".txt", ".css", ".html", ".csv" + ) + $violations = New-Object System.Collections.Generic.List[string] + + Get-ChildItem -Path $repoRoot -Recurse -File | ForEach-Object { + $fullName = $_.FullName + $normalizedFullName = $fullName.Replace("\", "/") + $excluded = $false + foreach ($segment in $excludedSegments) { + if ($normalizedFullName.Contains($segment)) { + $excluded = $true + break + } + } + + $extension = $_.Extension.ToLowerInvariant() + $isTextLike = $textExtensions.Contains($extension) -or $_.Name -eq "pom-xml-flattened" + if (-not $excluded -and $isTextLike -and (Test-TextFileHasNul -Path $fullName)) { + $violations.Add($fullName) + } + } + + if ($violations.Count -gt 0) { + $violations | ForEach-Object { Write-Error "NUL byte detected: $_" } + throw "NUL scan failed" + } + + Write-Host "[code-nest-eval] NUL scan passed" -ForegroundColor Green +} + +function Test-AllowedSecretPlaceholder { + param([string]$HitLine) + + $normalized = $HitLine.ToLowerInvariant() + $placeholderTokens = @( + "", + "", + "secret-key", + "test-key", + "fake-key", + "dummy-key", + "mock-key", + "example-key", + "placeholder", + "changeme" + ) + + foreach ($token in $placeholderTokens) { + if ($normalized.Contains($token)) { + return $true + } + } + + return $false +} + +function Invoke-Hygiene { + Invoke-Step "git diff whitespace check" { + Invoke-Native -Command "git" -Arguments @("diff", "--check") + } + + Invoke-Step "secret scan" { + $secretHits = & rg -n "sk-[A-Za-z0-9]{20,}|api[_-]?key\s*[:=]\s*['""][^'""]+['""]" . ` + --glob "!target/**" ` + --glob "!node_modules/**" ` + --glob "!.git/**" ` + --glob "!.codegraph/**" ` + --glob "!.codex-mihomo-geo/**" + $rgExitCode = $LASTEXITCODE + if ($rgExitCode -eq 0) { + $realSecretHits = @($secretHits | Where-Object { -not (Test-AllowedSecretPlaceholder $_) }) + if ($realSecretHits.Count -gt 0) { + $realSecretHits | ForEach-Object { Write-Error $_ } + throw "secret scan found possible secrets" + } + Write-Host "[code-nest-eval] secret scan passed with placeholder-only hits" -ForegroundColor Green + } elseif ($rgExitCode -eq 1) { + Write-Host "[code-nest-eval] secret scan passed" -ForegroundColor Green + } else { + throw "secret scan failed with exit code $rgExitCode" + } + } + + Invoke-Step "text NUL-byte scan" { + Invoke-NulScan + } +} + +function Invoke-Agent { + $args = @() + if ($LiveAi -or $LiveWrite) { + $args += "-LiveAi" + if ($AiBaseUrl) { + $args += @("-AiBaseUrl", $AiBaseUrl) + } + if ($AiApiKey) { + $args += @("-AiApiKey", $AiApiKey) + } + if ($AiChatModel) { + $args += @("-AiChatModel", $AiChatModel) + } + } + if ($LiveWrite) { + $args += "-LiveWrite" + if ($MysqlUrl) { + $args += @("-MysqlUrl", $MysqlUrl) + } + if ($MysqlUsername) { + $args += @("-MysqlUsername", $MysqlUsername) + } + if ($MysqlPassword) { + $args += @("-MysqlPassword", $MysqlPassword) + } + } + Invoke-Step "agent runtime eval" { + & (Join-Path $PSScriptRoot "agent-runtime-eval.ps1") @args + if ($LASTEXITCODE -ne 0) { + throw "agent runtime eval failed" + } + } +} + +function Invoke-Ai { + Invoke-Step "AI regression suite" { + Invoke-Maven @( + "-pl", "xiaou-ai", + "-am", + "-Dtest=$($aiRegressionTests -join ',')", + "-Dsurefire.failIfNoSpecifiedTests=false", + "test" + ) + } +} + +function Invoke-Frontend { + Invoke-Step "frontend node contract tests" { + Invoke-Native -Command "node" -Arguments (@("--test") + $frontendTests) + } +} + +function Invoke-Rag { + if ($InstallPythonDeps) { + Invoke-Step "llamaindex service dependency install" { + Invoke-Native -Command $PythonCommand -Arguments @("-m", "pip", "install", "-r", "llamaindex-service/requirements.txt") + } + } + + Invoke-Step "llamaindex service unittest" { + Push-Location "llamaindex-service" + try { + Invoke-Native -Command $PythonCommand -Arguments @("-m", "unittest", "discover", "-s", "tests", "-v") + } catch { + Write-Host "[code-nest-eval] RAG sidecar tests require Python deps. Re-run with -InstallPythonDeps, or set CODE_NEST_PYTHON to a prepared venv interpreter." -ForegroundColor Yellow + throw + } finally { + Pop-Location + } + } +} + +function Invoke-Backend { + Invoke-Step "backend module tests" { + Invoke-Maven @( + "-pl", ($backendModulesWithTests -join ","), + "-am", + "-Dsurefire.failIfNoSpecifiedTests=false", + "test" + ) + } +} + +function Invoke-Release { + Invoke-Step "backend package without tests" { + Invoke-Maven @("-B", "-pl", "xiaou-application", "-am", "clean", "package", "-DskipTests") + } + + Invoke-Step "admin frontend build" { + Push-Location "vue3-admin-front" + try { + Invoke-Native -Command "npm" -Arguments @("run", "build") + } finally { + Pop-Location + } + } + + Invoke-Step "user frontend build" { + Push-Location "vue3-user-front" + try { + Invoke-Native -Command "npm" -Arguments @("run", "build") + } finally { + Pop-Location + } + } + + Invoke-Step "docs site build" { + Push-Location "docs-site" + try { + Invoke-Native -Command "npm" -Arguments @("run", "build") + } finally { + Pop-Location + } + } + + Invoke-Step "script syntax checks" { + Invoke-Native -Command $PythonCommand -Arguments @("-m", "py_compile", "scripts/deploy-frontends.py", "scripts/you_deserve_to_interview_sql.py") + Invoke-Native -Command (Resolve-BashCommand) -Arguments @("-n", "scripts/deploy-release.sh") + } +} + +switch ($Tier) { + "hygiene" { + Invoke-Hygiene + } + "smoke" { + Invoke-Step "frontend node contract tests" { + Invoke-Native -Command "node" -Arguments (@("--test") + $frontendTests) + } + Invoke-Agent + } + "agent" { + Invoke-Agent + } + "ai" { + Invoke-Ai + } + "frontend" { + Invoke-Frontend + } + "rag" { + Invoke-Rag + } + "backend" { + Invoke-Backend + } + "release" { + Invoke-Release + } + "all" { + Invoke-Hygiene + Invoke-Frontend + Invoke-Rag + Invoke-Ai + Invoke-Backend + Invoke-Agent + } +} + +Write-Host "" +Write-Host "[code-nest-eval] tier '$Tier' passed" -ForegroundColor Green diff --git a/sql/MySql/code_nest.sql b/sql/MySql/code_nest.sql index ad04ee502..56ee6ad43 100644 --- a/sql/MySql/code_nest.sql +++ b/sql/MySql/code_nest.sql @@ -2561,6 +2561,61 @@ CREATE TABLE `sys_operation_log` ( INDEX `idx_status`(`status` ASC) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 658 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '操作日志表' ROW_FORMAT = Dynamic; +-- ---------------------------- +-- Table structure for sys_agent_audit +-- ---------------------------- +DROP TABLE IF EXISTS `sys_agent_audit`; +CREATE TABLE `sys_agent_audit` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '审计ID', + `audit_id` varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '智能体审计业务ID', + `confirmation_id` varchar(120) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '前端确认ID', + `idempotency_key` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '写入动作幂等键', + `user_message` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '管理员自然语言指令', + `intent` varchar(120) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '智能体意图', + `action_id` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作目录ID', + `route` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联管理端路由', + `risk_level` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '动作目录风险等级', + `risk_category` varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '智能体风险分类', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PREVIEW' COMMENT '状态:PREVIEW/CONFIRMED/CANCELLED/EXECUTED/FAILED', + `summary` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '智能体摘要', + `payload_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '预览参数JSON', + `diff_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '字段差异JSON', + `plan_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '执行计划JSON', + `result_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '执行结果JSON', + `error_message` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '执行失败或取消原因', + `operator_id` bigint NULL DEFAULT NULL COMMENT '操作管理员ID', + `operator_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '操作管理员名称', + `confirmed_time` datetime NULL DEFAULT NULL COMMENT '确认时间', + `executed_time` datetime NULL DEFAULT NULL COMMENT '执行完成时间', + `created_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_agent_audit_id`(`audit_id` ASC) USING BTREE, + UNIQUE INDEX `uk_agent_idempotency_key`(`idempotency_key` ASC) USING BTREE, + INDEX `idx_agent_confirmation_id`(`confirmation_id` ASC) USING BTREE, + INDEX `idx_agent_intent`(`intent` ASC) USING BTREE, + INDEX `idx_agent_action_id`(`action_id` ASC) USING BTREE, + INDEX `idx_agent_status`(`status` ASC) USING BTREE, + INDEX `idx_agent_risk_category`(`risk_category` ASC) USING BTREE, + INDEX `idx_agent_operator_id`(`operator_id` ASC) USING BTREE, + INDEX `idx_agent_created_time`(`created_time` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '管理员智能体审计表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Table structure for sys_agent_session_context +-- ---------------------------- +DROP TABLE IF EXISTS `sys_agent_session_context`; +CREATE TABLE `sys_agent_session_context` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '会话上下文ID', + `session_id` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '按管理员隔离的智能体会话存储键', + `turns_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '最近对话窗口JSON', + `created_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_agent_session_id`(`session_id` ASC) USING BTREE, + INDEX `idx_agent_session_updated_time`(`updated_time` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '管理员智能体会话上下文表' ROW_FORMAT = Dynamic; + -- ---------------------------- -- Table structure for sys_permission -- ---------------------------- diff --git a/sql/MySql/code_nest_data.sql b/sql/MySql/code_nest_data.sql index 12da9a5f7..93b67ed4f 100644 --- a/sql/MySql/code_nest_data.sql +++ b/sql/MySql/code_nest_data.sql @@ -20,6 +20,48 @@ INSERT IGNORE INTO `sys_role` VALUES (2, '系统管理员', 'SYSTEM_ADMIN', '系 INSERT IGNORE INTO `sys_role` VALUES (3, '普通管理员', 'ADMIN', '普通管理员,负责日常业务管理', 0, 3, '2025-08-31 12:11:46', '2025-08-31 12:11:46', NULL, NULL); +INSERT IGNORE INTO `sys_permission` +(`parent_id`, `permission_name`, `permission_code`, `permission_type`, `path`, `component`, `icon`, `sort_order`, `status`, `description`, `create_by`) +VALUES +(0, '智能体查询工具目录', 'agent:runtime:tool-catalog:read', 2, NULL, NULL, NULL, 2400, 0, '允许管理员智能体查询后端工具目录', NULL), +(0, '智能体查询运行时状态', 'agent:runtime:status:read', 2, NULL, NULL, NULL, 2406, 0, '允许管理员智能体查询后端运行时状态', NULL), +(0, '智能体运行时上线自检', 'agent:runtime:readiness:read', 2, NULL, NULL, NULL, 2413, 0, '允许管理员智能体执行后端运行时 readiness 上线自检', NULL), +(0, '智能体查询当前会话上下文', 'agent:runtime:session:read', 2, NULL, NULL, NULL, 2407, 0, '允许管理员智能体查询当前会话上下文摘要', NULL), +(0, '智能体查询工具调用指标', 'agent:runtime:metrics:read', 2, NULL, NULL, NULL, 2408, 0, '允许管理员智能体查询后端工具调用指标', NULL), +(0, '智能体查询当前操作者', 'agent:runtime:operator:read', 2, NULL, NULL, NULL, 2409, 0, '允许管理员智能体查询当前请求操作者上下文', NULL), +(0, '智能体查询规划器诊断', 'agent:runtime:planner:read', 2, NULL, NULL, NULL, 2410, 0, '允许管理员智能体查询 planner 契约、命中规则和规划预演结果', NULL), +(0, '智能体检查工具访问', 'agent:runtime:tool-access:read', 2, NULL, NULL, NULL, 2411, 0, '允许管理员智能体检查当前操作者对工具的访问条件', NULL), +(0, '智能体策略预检', 'agent:runtime:policy:read', 2, NULL, NULL, NULL, 2412, 0, '允许管理员智能体对工具调用执行只读策略预检', NULL), +(0, '智能体查询审计记录', 'agent:runtime:audit:read', 2, NULL, NULL, NULL, 2401, 0, '允许管理员智能体查询自身审计记录', NULL), +(0, '智能体查询操作日志', 'agent:system:operation-log:read', 2, NULL, NULL, NULL, 2402, 0, '允许管理员智能体查询系统操作日志', NULL), +(0, '智能体清理操作日志', 'agent:system:operation-log:clean', 2, NULL, NULL, NULL, 2403, 0, '允许管理员智能体清理系统操作日志', NULL), +(0, '智能体查询聊天禁言', 'agent:chat:user-ban:read', 2, NULL, NULL, NULL, 2404, 0, '允许管理员智能体查询聊天用户禁言状态', NULL), +(0, '智能体解除聊天禁言', 'agent:chat:user-ban:write', 2, NULL, NULL, NULL, 2405, 0, '允许管理员智能体解除聊天用户禁言', NULL), +(0, '智能体查询抽奖监控', 'agent:points:lottery:monitor:read', 2, NULL, NULL, NULL, 2414, 0, '允许管理员智能体查询抽奖实时监控', NULL); + +INSERT IGNORE INTO `sys_role_permission` (`role_id`, `permission_id`, `create_by`) +SELECT role_table.`id`, permission_table.`id`, NULL +FROM `sys_role` role_table +JOIN `sys_permission` permission_table + ON permission_table.`permission_code` IN ( + 'agent:runtime:tool-catalog:read', + 'agent:runtime:status:read', + 'agent:runtime:readiness:read', + 'agent:runtime:session:read', + 'agent:runtime:metrics:read', + 'agent:runtime:operator:read', + 'agent:runtime:planner:read', + 'agent:runtime:tool-access:read', + 'agent:runtime:policy:read', + 'agent:runtime:audit:read', + 'agent:system:operation-log:read', + 'agent:system:operation-log:clean', + 'agent:chat:user-ban:read', + 'agent:chat:user-ban:write', + 'agent:points:lottery:monitor:read' + ) +WHERE role_table.`role_code` = 'SUPER_ADMIN'; + -- 插入默认本地存储配置 INSERT IGNORE INTO `storage_config` (`storage_type`, `config_name`, `config_params`, `is_default`, `is_enabled`, `test_status`) VALUES diff --git a/sql/v2.4.0/admin_agent_audit.sql b/sql/v2.4.0/admin_agent_audit.sql new file mode 100644 index 000000000..cf4c13dbd --- /dev/null +++ b/sql/v2.4.0/admin_agent_audit.sql @@ -0,0 +1,35 @@ +CREATE TABLE IF NOT EXISTS `sys_agent_audit` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '审计ID', + `audit_id` varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '智能体审计业务ID', + `confirmation_id` varchar(120) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '前端确认ID', + `idempotency_key` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '写入动作幂等键', + `user_message` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '管理员自然语言指令', + `intent` varchar(120) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '智能体意图', + `action_id` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作目录ID', + `route` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联管理端路由', + `risk_level` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '动作目录风险等级', + `risk_category` varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '智能体风险分类', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PREVIEW' COMMENT '状态:PREVIEW/CONFIRMED/CANCELLED/EXECUTED/FAILED', + `summary` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '智能体摘要', + `payload_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '预览参数JSON', + `diff_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '字段差异JSON', + `plan_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '执行计划JSON', + `result_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '执行结果JSON', + `error_message` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '执行失败或取消原因', + `operator_id` bigint NULL DEFAULT NULL COMMENT '操作管理员ID', + `operator_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '操作管理员名称', + `confirmed_time` datetime NULL DEFAULT NULL COMMENT '确认时间', + `executed_time` datetime NULL DEFAULT NULL COMMENT '执行完成时间', + `created_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_agent_audit_id` (`audit_id` ASC) USING BTREE, + UNIQUE INDEX `uk_agent_idempotency_key` (`idempotency_key` ASC) USING BTREE, + INDEX `idx_agent_confirmation_id` (`confirmation_id` ASC) USING BTREE, + INDEX `idx_agent_intent` (`intent` ASC) USING BTREE, + INDEX `idx_agent_action_id` (`action_id` ASC) USING BTREE, + INDEX `idx_agent_status` (`status` ASC) USING BTREE, + INDEX `idx_agent_risk_category` (`risk_category` ASC) USING BTREE, + INDEX `idx_agent_operator_id` (`operator_id` ASC) USING BTREE, + INDEX `idx_agent_created_time` (`created_time` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '管理员智能体审计表' ROW_FORMAT = Dynamic; diff --git a/sql/v2.4.0/admin_agent_audit_idempotency.sql b/sql/v2.4.0/admin_agent_audit_idempotency.sql new file mode 100644 index 000000000..5c352fbcc --- /dev/null +++ b/sql/v2.4.0/admin_agent_audit_idempotency.sql @@ -0,0 +1,55 @@ +-- 管理员端超级智能体写入动作幂等键增量迁移。 +-- 说明:admin_agent_audit.sql 只负责新库建表;已部署库需要通过本脚本补齐字段和唯一索引。 + +SET @agent_audit_table_exists := ( + SELECT COUNT(1) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_agent_audit' +); + +SET @agent_audit_idempotency_column_exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_agent_audit' + AND COLUMN_NAME = 'idempotency_key' +); + +SET @agent_audit_idempotency_column_sql := IF( + @agent_audit_table_exists = 1 AND @agent_audit_idempotency_column_exists = 0, + 'ALTER TABLE `sys_agent_audit` ADD COLUMN `idempotency_key` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT ''写入动作幂等键'' AFTER `confirmation_id`', + 'SELECT ''sys_agent_audit.idempotency_key already exists or sys_agent_audit is missing'' AS info' +); + +PREPARE agent_audit_idempotency_column_stmt FROM @agent_audit_idempotency_column_sql; +EXECUTE agent_audit_idempotency_column_stmt; +DEALLOCATE PREPARE agent_audit_idempotency_column_stmt; + +SET @agent_audit_idempotency_index_exists := ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_agent_audit' + AND INDEX_NAME = 'uk_agent_idempotency_key' +); + +SET @agent_audit_idempotency_column_exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_agent_audit' + AND COLUMN_NAME = 'idempotency_key' +); + +SET @agent_audit_idempotency_index_sql := IF( + @agent_audit_table_exists = 1 + AND @agent_audit_idempotency_column_exists = 1 + AND @agent_audit_idempotency_index_exists = 0, + 'ALTER TABLE `sys_agent_audit` ADD UNIQUE INDEX `uk_agent_idempotency_key` (`idempotency_key` ASC) USING BTREE', + 'SELECT ''sys_agent_audit.uk_agent_idempotency_key already exists or sys_agent_audit is missing'' AS info' +); + +PREPARE agent_audit_idempotency_index_stmt FROM @agent_audit_idempotency_index_sql; +EXECUTE agent_audit_idempotency_index_stmt; +DEALLOCATE PREPARE agent_audit_idempotency_index_stmt; diff --git a/sql/v2.4.0/admin_agent_permissions.sql b/sql/v2.4.0/admin_agent_permissions.sql new file mode 100644 index 000000000..0533f88bd --- /dev/null +++ b/sql/v2.4.0/admin_agent_permissions.sql @@ -0,0 +1,44 @@ +-- 管理员端超级智能体内置工具权限种子。 +-- 说明:权限由后端 AgentToolDefinition.requiredPermissions 声明,前端不维护动作目录。 + +INSERT IGNORE INTO `sys_permission` +(`parent_id`, `permission_name`, `permission_code`, `permission_type`, `path`, `component`, `icon`, `sort_order`, `status`, `description`, `create_by`) +VALUES +(0, '智能体查询工具目录', 'agent:runtime:tool-catalog:read', 2, NULL, NULL, NULL, 2400, 0, '允许管理员智能体查询后端工具目录', NULL), +(0, '智能体查询运行时状态', 'agent:runtime:status:read', 2, NULL, NULL, NULL, 2406, 0, '允许管理员智能体查询后端运行时状态', NULL), +(0, '智能体运行时上线自检', 'agent:runtime:readiness:read', 2, NULL, NULL, NULL, 2413, 0, '允许管理员智能体执行后端运行时 readiness 上线自检', NULL), +(0, '智能体查询当前会话上下文', 'agent:runtime:session:read', 2, NULL, NULL, NULL, 2407, 0, '允许管理员智能体查询当前会话上下文摘要', NULL), +(0, '智能体查询工具调用指标', 'agent:runtime:metrics:read', 2, NULL, NULL, NULL, 2408, 0, '允许管理员智能体查询后端工具调用指标', NULL), +(0, '智能体查询当前操作者', 'agent:runtime:operator:read', 2, NULL, NULL, NULL, 2409, 0, '允许管理员智能体查询当前请求操作者上下文', NULL), +(0, '智能体查询规划器诊断', 'agent:runtime:planner:read', 2, NULL, NULL, NULL, 2410, 0, '允许管理员智能体查询 planner 契约、命中规则和规划预演结果', NULL), +(0, '智能体检查工具访问', 'agent:runtime:tool-access:read', 2, NULL, NULL, NULL, 2411, 0, '允许管理员智能体检查当前操作者对工具的访问条件', NULL), +(0, '智能体策略预检', 'agent:runtime:policy:read', 2, NULL, NULL, NULL, 2412, 0, '允许管理员智能体对工具调用执行只读策略预检', NULL), +(0, '智能体查询审计记录', 'agent:runtime:audit:read', 2, NULL, NULL, NULL, 2401, 0, '允许管理员智能体查询自身审计记录', NULL), +(0, '智能体查询操作日志', 'agent:system:operation-log:read', 2, NULL, NULL, NULL, 2402, 0, '允许管理员智能体查询系统操作日志', NULL), +(0, '智能体清理操作日志', 'agent:system:operation-log:clean', 2, NULL, NULL, NULL, 2403, 0, '允许管理员智能体清理系统操作日志', NULL), +(0, '智能体查询聊天禁言', 'agent:chat:user-ban:read', 2, NULL, NULL, NULL, 2404, 0, '允许管理员智能体查询聊天用户禁言状态', NULL), +(0, '智能体解除聊天禁言', 'agent:chat:user-ban:write', 2, NULL, NULL, NULL, 2405, 0, '允许管理员智能体解除聊天用户禁言', NULL), +(0, '智能体查询抽奖监控', 'agent:points:lottery:monitor:read', 2, NULL, NULL, NULL, 2414, 0, '允许管理员智能体查询抽奖实时监控', NULL); + +INSERT IGNORE INTO `sys_role_permission` (`role_id`, `permission_id`, `create_by`) +SELECT role_table.`id`, permission_table.`id`, NULL +FROM `sys_role` role_table +JOIN `sys_permission` permission_table + ON permission_table.`permission_code` IN ( + 'agent:runtime:tool-catalog:read', + 'agent:runtime:status:read', + 'agent:runtime:readiness:read', + 'agent:runtime:session:read', + 'agent:runtime:metrics:read', + 'agent:runtime:operator:read', + 'agent:runtime:planner:read', + 'agent:runtime:tool-access:read', + 'agent:runtime:policy:read', + 'agent:runtime:audit:read', + 'agent:system:operation-log:read', + 'agent:system:operation-log:clean', + 'agent:chat:user-ban:read', + 'agent:chat:user-ban:write', + 'agent:points:lottery:monitor:read' + ) +WHERE role_table.`role_code` = 'SUPER_ADMIN'; diff --git a/sql/v2.4.0/admin_agent_session_context.sql b/sql/v2.4.0/admin_agent_session_context.sql new file mode 100644 index 000000000..dcafa8e68 --- /dev/null +++ b/sql/v2.4.0/admin_agent_session_context.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS `sys_agent_session_context` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '会话上下文ID', + `session_id` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '按管理员隔离的智能体会话存储键', + `turns_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '最近对话窗口JSON', + `created_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_agent_session_id` (`session_id` ASC) USING BTREE, + INDEX `idx_agent_session_updated_time` (`updated_time` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '管理员智能体会话上下文表' ROW_FORMAT = Dynamic; diff --git a/vue3-admin-front/src/api/agentChat.js b/vue3-admin-front/src/api/agentChat.js new file mode 100644 index 000000000..c0f5c3ee8 --- /dev/null +++ b/vue3-admin-front/src/api/agentChat.js @@ -0,0 +1,9 @@ +import request from '@/utils/request' + +export const agentChatApi = { + sendMessage(data) { + return request.post('/admin/agent/chat', data) + } +} + +export default agentChatApi diff --git a/vue3-admin-front/src/api/chat.js b/vue3-admin-front/src/api/chat.js index 239a962c7..b18bde551 100644 --- a/vue3-admin-front/src/api/chat.js +++ b/vue3-admin-front/src/api/chat.js @@ -24,6 +24,11 @@ export function getAdminOnlineUsers() { return request.post('/admin/chat/users/online') } +// 获取用户当前生效禁言记录(管理端) +export function getActiveChatUserBan(userId) { + return request.post('/admin/chat/users/ban/active', userId) +} + // 踢出用户 export function kickUser(userId) { return request.post('/admin/chat/users/kick', userId) diff --git a/vue3-admin-front/src/api/community.js b/vue3-admin-front/src/api/community.js index 2e15a0ff8..a0f0db4a2 100644 --- a/vue3-admin-front/src/api/community.js +++ b/vue3-admin-front/src/api/community.js @@ -60,6 +60,11 @@ export function getUserStatusList(params) { return request.post('/admin/community/users/list', params) } +// 获取用户社区状态详情 +export function getUserStatus(userId) { + return request.get(`/admin/community/users/${userId}`) +} + // 封禁用户 export function banUser(id, data) { return request.put(`/admin/community/users/${id}/ban`, data) @@ -162,6 +167,7 @@ export const communityApi = { // 用户相关 getUserStatusList, + getUserStatus, banUser, unbanUser, getUserPosts, diff --git a/vue3-admin-front/src/components/agent/AdminAgentDrawer.vue b/vue3-admin-front/src/components/agent/AdminAgentDrawer.vue new file mode 100644 index 000000000..95bfb63c2 --- /dev/null +++ b/vue3-admin-front/src/components/agent/AdminAgentDrawer.vue @@ -0,0 +1,500 @@ + + + + + diff --git a/vue3-admin-front/src/layout/index.vue b/vue3-admin-front/src/layout/index.vue index fd97d4d89..7262d4d3d 100644 --- a/vue3-admin-front/src/layout/index.vue +++ b/vue3-admin-front/src/layout/index.vue @@ -85,6 +85,8 @@ + + @@ -135,6 +137,7 @@ import { import { useUserStore } from '@/stores/user' import { CnSidebar, CnThemeSwitch } from '@/design-system' import type { CnSidebarItem, CnSidebarSearchResult } from '@/design-system' +import AdminAgentDrawer from '@/components/agent/AdminAgentDrawer.vue' const route = useRoute() const router = useRouter() diff --git a/vue3-admin-front/tests/admin-agent-chat-ui.test.js b/vue3-admin-front/tests/admin-agent-chat-ui.test.js new file mode 100644 index 000000000..46f62dfeb --- /dev/null +++ b/vue3-admin-front/tests/admin-agent-chat-ui.test.js @@ -0,0 +1,38 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const projectRoot = resolve(import.meta.dirname, '..') +const agentChatApiSource = readFileSync(resolve(projectRoot, 'src/api/agentChat.js'), 'utf8') +const drawerSource = readFileSync(resolve(projectRoot, 'src/components/agent/AdminAgentDrawer.vue'), 'utf8') +const layoutSource = readFileSync(resolve(projectRoot, 'src/layout/index.vue'), 'utf8') + +test('admin agent frontend should only call the unified backend chat endpoint', () => { + assert.match( + agentChatApiSource, + /sendMessage\(data\) \{\s*return request\.post\('\/admin\/agent\/chat', data\)\s*\}/ + ) + assert.doesNotMatch(agentChatApiSource, /\/admin\/agent\/planner/) + assert.doesNotMatch(agentChatApiSource, /\/admin\/agent\/policy/) + assert.doesNotMatch(agentChatApiSource, /\/admin\/agent\/audit/) +}) + +test('admin agent drawer should remain a thin chat UI without frontend runtime orchestration', () => { + assert.match(drawerSource, /import \{ agentChatApi \} from '@\/api\/agentChat'/) + assert.match(drawerSource, /agentChatApi\.sendMessage/) + assert.match(drawerSource, /auditId: pendingConfirmation\.value\.auditId/) + assert.match(drawerSource, /confirmationText: content/) + assert.match(drawerSource, /message: '取消'/) + + assert.doesNotMatch(drawerSource, /@\/agent/) + assert.doesNotMatch(drawerSource, /createAdminAgent/) + assert.doesNotMatch(drawerSource, /agentPlannerApi|agentPolicyApi|agentAuditApi/) + assert.doesNotMatch(drawerSource, /\/admin\/agent\/planner|\/admin\/agent\/policy|\/admin\/agent\/audit/) +}) + +test('admin layout should mount only the thin drawer component', () => { + assert.match(layoutSource, //) + assert.match(layoutSource, /import AdminAgentDrawer from '@\/components\/agent\/AdminAgentDrawer\.vue'/) + assert.doesNotMatch(layoutSource, /@\/agent/) +}) diff --git a/vue3-admin-front/tests/admin-api-contract.test.js b/vue3-admin-front/tests/admin-api-contract.test.js index d212ca483..cc9906b13 100644 --- a/vue3-admin-front/tests/admin-api-contract.test.js +++ b/vue3-admin-front/tests/admin-api-contract.test.js @@ -9,6 +9,10 @@ const knowledgeApiSource = readFileSync(resolve(projectRoot, 'src/api/knowledge. const knowledgeMapsViewSource = readFileSync(resolve(projectRoot, 'src/views/knowledge/maps/index.vue'), 'utf8') const logApiSource = readFileSync(resolve(projectRoot, 'src/api/log.js'), 'utf8') const moyuApiSource = readFileSync(resolve(projectRoot, 'src/api/moyu.js'), 'utf8') +const chatApiSource = readFileSync(resolve(projectRoot, 'src/api/chat.js'), 'utf8') +const chatAdminControllerSource = readFileSync(resolve(projectRoot, '..', 'xiaou-chat/src/main/java/com/xiaou/chat/controller/admin/ChatAdminController.java'), 'utf8') +const agentChatApiSource = readFileSync(resolve(projectRoot, 'src/api/agentChat.js'), 'utf8') +const agentChatControllerSource = readFileSync(resolve(projectRoot, '..', 'xiaou-system/src/main/java/com/xiaou/system/controller/AgentChatController.java'), 'utf8') const requestSource = readFileSync(resolve(projectRoot, 'src/utils/request.js'), 'utf8') test('community admin actions should match backend HTTP method contracts', () => { @@ -80,3 +84,26 @@ test('moyu admin API should not expose stale or missing backend contracts', () = assert.doesNotMatch(moyuApiSource, /\/admin\/moyu\/statistics\/user-behavior/) assert.doesNotMatch(moyuApiSource, /\/admin\/moyu\/statistics\/trend\/\$\{type\}/) }) + +test('chat admin API should expose guarded active-ban current-state contract', () => { + assert.match( + chatApiSource, + /export function getActiveChatUserBan\(userId\) \{\s*return request\.post\('\/admin\/chat\/users\/ban\/active', userId\)\s*\}/ + ) + assert.match( + chatApiSource, + /export function unbanUser\(userId\) \{\s*return request\.post\('\/admin\/chat\/users\/unban', userId\)\s*\}/ + ) + assert.match(chatAdminControllerSource, /@PostMapping\("\/users\/ban\/active"\)/) + assert.match(chatAdminControllerSource, /public Result getActiveUserBan\(@RequestBody Long userId\)/) +}) + +test('admin agent chat API should use the unified backend chat endpoint', () => { + assert.match( + agentChatApiSource, + /sendMessage\(data\) \{\s*return request\.post\('\/admin\/agent\/chat', data\)\s*\}/ + ) + assert.match(agentChatControllerSource, /@RequestMapping\("\/admin\/agent"\)/) + assert.match(agentChatControllerSource, /@PostMapping\("\/chat"\)/) + assert.doesNotMatch(agentChatApiSource, /\/admin\/agent\/planner|\/admin\/agent\/policy|\/admin\/agent\/audit/) +}) diff --git a/xiaou-ai/src/main/java/com/xiaou/ai/client/AiModelFactory.java b/xiaou-ai/src/main/java/com/xiaou/ai/client/AiModelFactory.java index 9e0cad704..d679c1d09 100644 --- a/xiaou-ai/src/main/java/com/xiaou/ai/client/AiModelFactory.java +++ b/xiaou-ai/src/main/java/com/xiaou/ai/client/AiModelFactory.java @@ -5,8 +5,10 @@ import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.response.ChatResponse; import dev.langchain4j.model.openai.OpenAiChatModel; +import dev.langchain4j.model.openai.OpenAiChatRequestParameters; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -59,10 +61,22 @@ public ChatModel getChatModel() { } public AiChatResult chat(String systemPrompt, String userPrompt) { - ChatResponse response = getChatModel().chat(List.of( - SystemMessage.from(defaultText(systemPrompt)), - UserMessage.from(defaultText(userPrompt)) - )); + return chat(systemPrompt, userPrompt, null); + } + + public AiChatResult chat(String systemPrompt, String userPrompt, Integer maxCompletionTokens) { + ChatRequest.Builder request = ChatRequest.builder() + .messages(List.of( + SystemMessage.from(defaultText(systemPrompt)), + UserMessage.from(defaultText(userPrompt)) + )); + if (maxCompletionTokens != null && maxCompletionTokens > 0) { + request.parameters(OpenAiChatRequestParameters.builder() + .maxCompletionTokens(maxCompletionTokens) + .build()); + } + + ChatResponse response = getChatModel().chat(request.build()); return new AiChatResult() .setContent(response == null || response.aiMessage() == null ? null : response.aiMessage().text()) .setModelName(response == null ? null : response.modelName()) @@ -84,6 +98,7 @@ private ChatModel createChatModel() { .baseUrl(aiProperties.getBaseUrl()) .apiKey(aiProperties.getApiKey()) .modelName(aiProperties.getModel().getChat()) + .maxCompletionTokens(aiProperties.getModel().getMaxCompletionTokens()) .timeout(Duration.ofMillis(aiProperties.getTimeout().getReadMs())) .maxRetries(aiProperties.getRetry().getMaxAttempts()) .logRequests(false) diff --git a/xiaou-ai/src/main/java/com/xiaou/ai/prompt/AiPromptCatalog.java b/xiaou-ai/src/main/java/com/xiaou/ai/prompt/AiPromptCatalog.java index 460fa9b09..a1b79ec2d 100644 --- a/xiaou-ai/src/main/java/com/xiaou/ai/prompt/AiPromptCatalog.java +++ b/xiaou-ai/src/main/java/com/xiaou/ai/prompt/AiPromptCatalog.java @@ -1,5 +1,6 @@ package com.xiaou.ai.prompt; +import com.xiaou.ai.prompt.admin.AdminAgentPromptSpecs; import com.xiaou.ai.prompt.community.CommunityPromptSpecs; import com.xiaou.ai.prompt.interview.InterviewPromptSpecs; import com.xiaou.ai.prompt.jobbattle.JobBattlePromptSpecs; @@ -17,6 +18,7 @@ public final class AiPromptCatalog { private static final List> HOLDERS = List.of( + AdminAgentPromptSpecs.class, CommunityPromptSpecs.class, InterviewPromptSpecs.class, JobBattlePromptSpecs.class, diff --git a/xiaou-ai/src/main/java/com/xiaou/ai/prompt/AiPromptSpec.java b/xiaou-ai/src/main/java/com/xiaou/ai/prompt/AiPromptSpec.java index c5650fed5..72deda60c 100644 --- a/xiaou-ai/src/main/java/com/xiaou/ai/prompt/AiPromptSpec.java +++ b/xiaou-ai/src/main/java/com/xiaou/ai/prompt/AiPromptSpec.java @@ -16,7 +16,8 @@ public record AiPromptSpec(String key, String version, String systemPrompt, - String userTemplate) { + String userTemplate, + Integer maxCompletionTokens) { public AiPromptSpec { Assert.hasText(key, "prompt key 不能为空"); @@ -28,12 +29,22 @@ public record AiPromptSpec(String key, version = version.trim(); systemPrompt = systemPrompt.trim(); userTemplate = userTemplate.trim(); + Assert.isTrue(maxCompletionTokens == null || maxCompletionTokens > 0, + "maxCompletionTokens 必须大于 0"); AiPromptGovernance.validatePromptSpec(key, version, systemPrompt, userTemplate); } public static AiPromptSpec of(String key, String version, String systemPrompt, String userTemplate) { - return new AiPromptSpec(key, version, systemPrompt, userTemplate); + return new AiPromptSpec(key, version, systemPrompt, userTemplate, null); + } + + public static AiPromptSpec of(String key, + String version, + String systemPrompt, + String userTemplate, + Integer maxCompletionTokens) { + return new AiPromptSpec(key, version, systemPrompt, userTemplate, maxCompletionTokens); } public String promptId() { diff --git a/xiaou-ai/src/main/java/com/xiaou/ai/prompt/admin/AdminAgentPromptSpecs.java b/xiaou-ai/src/main/java/com/xiaou/ai/prompt/admin/AdminAgentPromptSpecs.java new file mode 100644 index 000000000..66631d549 --- /dev/null +++ b/xiaou-ai/src/main/java/com/xiaou/ai/prompt/admin/AdminAgentPromptSpecs.java @@ -0,0 +1,60 @@ +package com.xiaou.ai.prompt.admin; + +import com.xiaou.ai.prompt.AiPromptSpec; + +/** + * 管理员智能体 Prompt 定义。 + * + * @author xiaou + */ +public final class AdminAgentPromptSpecs { + + public static final AiPromptSpec PLAN = AiPromptSpec.of( + "admin_agent.plan", + "v1", + """ + 你是 Code Nest 管理员智能体的后端 planner。 + + 职责边界: + - 你只负责从后端工具目录中选择一个最匹配的工具,生成候选工具调用。 + - 你不能执行工具,不能声明操作已完成,不能绕过后端权限、schema、审计或强确认。 + - 写入、破坏性动作也只能返回候选,后端统一运行时会负责预览、确认和执行。 + - 如果缺少必填字段,把字段名放入 missingFields,不要臆造输入。 + - 如果没有合适工具,返回空 toolName、空 input、confidence 为 0。 + + 组合意图规则: + - 先识别管理员消息的外层主动作,再识别被引用、被检查或被预演的内层目标。 + - 如果外层动作是预演、预检、校验、解释或恢复分析,优先选择目录中承载该外层动作的元工具, + 并把内层请求、工具名或审计编号放入元工具 input;不要直接选择内层目标工具。 + - 只有管理员明确要求真实查询或真实执行内层目标时,才直接选择目标工具。 + - 多个工具都可能匹配时,选择语义最具体、覆盖外层主动作完整流程的一个工具。 + + 输出约束: + - 只输出 JSON 对象,不要输出 Markdown、解释文字或代码块。 + - input 只能包含工具 schema 中声明的字段。 + - confidence 范围为 0 到 1。 + + JSON 格式: + { + "toolName": "system.agent.tools.list", + "input": {}, + "confidence": 0.95, + "missingFields": [] + } + """, + """ + 管理员消息: + {{message}} + + 当前会话上下文 JSON: + {{sessionContextJson}} + + 可用后端工具目录 JSON: + {{toolsJson}} + """, + 512 + ); + + private AdminAgentPromptSpecs() { + } +} diff --git a/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredJsonSchemaBuilder.java b/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredJsonSchemaBuilder.java index 51996279d..a1c73eb70 100644 --- a/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredJsonSchemaBuilder.java +++ b/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredJsonSchemaBuilder.java @@ -84,6 +84,16 @@ public AiStructuredObjectContract requireIntRange(String key, int min, int max) return this; } + @Override + public AiStructuredObjectContract requireNumberRange(String key, double min, double max) { + Map schema = new LinkedHashMap<>(); + schema.put("type", "number"); + schema.put("minimum", min); + schema.put("maximum", max); + addProperty(key, schema); + return this; + } + @Override public AiStructuredObjectContract requirePositiveInt(String key) { Map schema = new LinkedHashMap<>(); @@ -116,6 +126,12 @@ public AiStructuredObjectContract requireStringOrStringArray(String key) { return this; } + @Override + public AiStructuredObjectContract requireObject(String key) { + addProperty(key, Map.of("type", "object")); + return this; + } + @Override public AiStructuredObjectContract requireObject(String key, Consumer nestedValidator) { JsonSchemaObjectContract nested = new JsonSchemaObjectContract(); diff --git a/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredObjectContract.java b/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredObjectContract.java index 5756b8f35..3ee1abb6e 100644 --- a/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredObjectContract.java +++ b/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredObjectContract.java @@ -18,12 +18,16 @@ public interface AiStructuredObjectContract { AiStructuredObjectContract requireIntRange(String key, int min, int max); + AiStructuredObjectContract requireNumberRange(String key, double min, double max); + AiStructuredObjectContract requirePositiveInt(String key); AiStructuredObjectContract requireStringArray(String key); AiStructuredObjectContract requireStringOrStringArray(String key); + AiStructuredObjectContract requireObject(String key); + AiStructuredObjectContract requireObject(String key, Consumer nestedValidator); AiStructuredObjectContract requireObjectArray(String key, Consumer itemValidator); diff --git a/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredOutputCatalog.java b/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredOutputCatalog.java index 966c88d19..b416d6801 100644 --- a/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredOutputCatalog.java +++ b/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredOutputCatalog.java @@ -1,5 +1,6 @@ package com.xiaou.ai.structured; +import com.xiaou.ai.structured.admin.AdminAgentStructuredOutputSpecs; import com.xiaou.ai.structured.community.CommunityStructuredOutputSpecs; import com.xiaou.ai.structured.interview.InterviewStructuredOutputSpecs; import com.xiaou.ai.structured.jobbattle.JobBattleStructuredOutputSpecs; @@ -15,6 +16,7 @@ public final class AiStructuredOutputCatalog { private static final List SPECS = List.of( + AdminAgentStructuredOutputSpecs.PLAN, CommunityStructuredOutputSpecs.POST_SUMMARY, InterviewStructuredOutputSpecs.EVALUATE_ANSWER, InterviewStructuredOutputSpecs.GENERATE_SUMMARY, diff --git a/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredOutputValidator.java b/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredOutputValidator.java index 859c0ab43..2ff4f1247 100644 --- a/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredOutputValidator.java +++ b/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredOutputValidator.java @@ -97,6 +97,22 @@ public ObjectValidator requireIntRange(String key, int min, int max) { return this; } + public ObjectValidator requireNumberRange(String key, double min, double max) { + if (failureReason != null) { + return this; + } + Object value = getValue(key); + if (!(value instanceof Number number)) { + failureReason = key + "_missing_or_not_number"; + return this; + } + double doubleValue = number.doubleValue(); + if (doubleValue < min || doubleValue > max) { + failureReason = key + "_out_of_range"; + } + return this; + } + public ObjectValidator requirePositiveInt(String key) { if (failureReason != null) { return this; @@ -154,6 +170,17 @@ public ObjectValidator requireStringOrStringArray(String key) { return this; } + public ObjectValidator requireObject(String key) { + if (failureReason != null) { + return this; + } + JSONObject object = json == null ? null : json.getJSONObject(key); + if (object == null) { + failureReason = key + "_missing_or_not_object"; + } + return this; + } + public ObjectValidator requireObject(String key, Consumer nestedValidator) { if (failureReason != null) { return this; diff --git a/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredValidationContracts.java b/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredValidationContracts.java index c5cde8f5a..9ce68c747 100644 --- a/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredValidationContracts.java +++ b/xiaou-ai/src/main/java/com/xiaou/ai/structured/AiStructuredValidationContracts.java @@ -53,6 +53,12 @@ public AiStructuredObjectContract requireIntRange(String key, int min, int max) return this; } + @Override + public AiStructuredObjectContract requireNumberRange(String key, double min, double max) { + delegate.requireNumberRange(key, min, max); + return this; + } + @Override public AiStructuredObjectContract requirePositiveInt(String key) { delegate.requirePositiveInt(key); @@ -71,6 +77,12 @@ public AiStructuredObjectContract requireStringOrStringArray(String key) { return this; } + @Override + public AiStructuredObjectContract requireObject(String key) { + delegate.requireObject(key); + return this; + } + @Override public AiStructuredObjectContract requireObject(String key, Consumer nestedValidator) { delegate.requireObject(key, nested -> nestedValidator.accept(new ValidationObjectContract(nested))); diff --git a/xiaou-ai/src/main/java/com/xiaou/ai/structured/admin/AdminAgentStructuredOutputSpecs.java b/xiaou-ai/src/main/java/com/xiaou/ai/structured/admin/AdminAgentStructuredOutputSpecs.java new file mode 100644 index 000000000..eadafda5f --- /dev/null +++ b/xiaou-ai/src/main/java/com/xiaou/ai/structured/admin/AdminAgentStructuredOutputSpecs.java @@ -0,0 +1,24 @@ +package com.xiaou.ai.structured.admin; + +import com.xiaou.ai.prompt.admin.AdminAgentPromptSpecs; +import com.xiaou.ai.structured.AiStructuredOutputSpec; + +/** + * 管理员智能体结构化输出契约。 + * + * @author xiaou + */ +public final class AdminAgentStructuredOutputSpecs { + + public static final AiStructuredOutputSpec PLAN = AiStructuredOutputSpec.object( + AdminAgentPromptSpecs.PLAN, + validator -> validator + .requireString("toolName") + .requireObject("input") + .requireNumberRange("confidence", 0D, 1D) + .requireStringArray("missingFields") + ); + + private AdminAgentStructuredOutputSpecs() { + } +} diff --git a/xiaou-ai/src/main/java/com/xiaou/ai/support/AiExecutionSupport.java b/xiaou-ai/src/main/java/com/xiaou/ai/support/AiExecutionSupport.java index c9c7be9d0..fcb075050 100644 --- a/xiaou-ai/src/main/java/com/xiaou/ai/support/AiExecutionSupport.java +++ b/xiaou-ai/src/main/java/com/xiaou/ai/support/AiExecutionSupport.java @@ -52,7 +52,11 @@ private AiChatResult chatInternal(String sceneName, AiPromptSpec promptSpec, Str String outcome = "success"; AiChatResult result = null; try { - result = aiModelFactory.chat(systemPrompt, userPrompt); + result = aiModelFactory.chat( + systemPrompt, + userPrompt, + promptSpec == null ? null : promptSpec.maxCompletionTokens() + ); return result; } catch (Exception e) { outcome = "error"; diff --git a/xiaou-ai/src/test/java/com/xiaou/ai/client/AiModelFactoryTest.java b/xiaou-ai/src/test/java/com/xiaou/ai/client/AiModelFactoryTest.java new file mode 100644 index 000000000..eb80a6777 --- /dev/null +++ b/xiaou-ai/src/test/java/com/xiaou/ai/client/AiModelFactoryTest.java @@ -0,0 +1,65 @@ +package com.xiaou.ai.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import com.xiaou.common.config.AiProperties; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AiModelFactoryTest { + + @Test + void shouldSendGlobalAndPromptCompletionLimitsToOpenAiCompatibleEndpoint() throws Exception { + List requestBodies = new ArrayList<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.createContext("/v1/chat/completions", exchange -> { + requestBodies.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] response = "{\"id\":\"test\",\"object\":\"chat.completion\",\"model\":\"test-model\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"LIVE_CONTRACT_OK\"},\"finish_reason\":\"stop\"}]}" + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + try (exchange) { + exchange.getResponseBody().write(response); + } + }); + server.start(); + + try { + AiProperties properties = new AiProperties(); + properties.setBaseUrl("http://localhost:" + server.getAddress().getPort() + "/v1"); + properties.setApiKey("test-key"); + properties.getModel().setChat("test-model"); + properties.getModel().setMaxCompletionTokens(321); + properties.getTimeout().setReadMs(5000); + properties.getRetry().setMaxAttempts(0); + + AiModelFactory factory = new AiModelFactory(properties); + AiChatResult result = factory.chat("system", "user"); + AiChatResult boundedResult = factory.chat("system", "bounded-user", 123); + + assertEquals("LIVE_CONTRACT_OK", result.getContent()); + assertEquals("LIVE_CONTRACT_OK", boundedResult.getContent()); + assertEquals(2, requestBodies.size()); + + JsonNode globalRequest = new ObjectMapper().readTree(requestBodies.get(0)); + assertEquals(321, globalRequest.path("max_completion_tokens").asInt()); + assertEquals("test-model", globalRequest.path("model").asText()); + assertTrue(globalRequest.path("messages").isArray()); + + JsonNode promptRequest = new ObjectMapper().readTree(requestBodies.get(1)); + assertEquals(123, promptRequest.path("max_completion_tokens").asInt()); + assertEquals("bounded-user", promptRequest.path("messages").get(1).path("content").asText()); + } finally { + server.stop(0); + } + } +} diff --git a/xiaou-ai/src/test/java/com/xiaou/ai/prompt/AiPromptFixtures.java b/xiaou-ai/src/test/java/com/xiaou/ai/prompt/AiPromptFixtures.java index 6450ac272..f24df3f2c 100644 --- a/xiaou-ai/src/test/java/com/xiaou/ai/prompt/AiPromptFixtures.java +++ b/xiaou-ai/src/test/java/com/xiaou/ai/prompt/AiPromptFixtures.java @@ -4,214 +4,84 @@ import java.util.Map; /** - * Prompt / RAG Query 渲染样例。 + * Prompt 渲染测试样例。 * * @author xiaou */ public final class AiPromptFixtures { - private static final Map> PROMPT_FIXTURES = buildPromptFixtures(); - private static final Map> RAG_QUERY_FIXTURES = buildRagQueryFixtures(); + private static final Map DEFAULT_VALUES = buildDefaultValues(); private AiPromptFixtures() { } - public static Map promptVariables(AiPromptSpec spec) { - return PROMPT_FIXTURES.get(spec.promptId()); + public static Map variables(AiPromptSpec spec) { + Map variables = new LinkedHashMap<>(); + for (String variable : spec.templateVariables()) { + variables.put(variable, DEFAULT_VALUES.getOrDefault(variable, "sample-" + variable)); + } + return variables; } public static Map ragQueryVariables(AiRagQuerySpec spec) { - return RAG_QUERY_FIXTURES.get(spec.queryId()); - } - - private static Map> buildPromptFixtures() { - Map> fixtures = new LinkedHashMap<>(); - - fixtures.put("community.post_summary:v1", mapOf( - "title", "SQL 优化经验总结", - "content", "这是一篇关于 SQL 优化与索引设计的实践复盘。" - )); - - Map interviewEvaluation = mapOf( - "direction", "Java后端", - "level", "高级", - "style", "压力型", - "followUpCount", 1, - "question", "你如何设计高并发库存扣减?", - "answer", "我会结合 Redis 预扣、幂等校验和最终一致性方案。", - "ragSection", AiPromptSections.ragSection("库存扣减要关注幂等、防超卖和补偿机制。") - ); - fixtures.put("mock_interview.evaluate_answer:v1", interviewEvaluation); - fixtures.put("mock_interview.generate_follow_up:v1", interviewEvaluation); - - fixtures.put("mock_interview.generate_summary:v1", mapOf( - "direction", "Java后端", - "level", "高级", - "questionCount", 8, - "answeredCount", 7, - "skippedCount", 1, - "totalScore", 78, - "qaListJson", "[{\"question\":\"Redis 为什么快\",\"answer\":\"基于内存与高效数据结构\",\"score\":8}]", - "ragSection", AiPromptSections.ragSection("复盘建议要覆盖能力模型、短板和下一阶段行动。") - )); - - fixtures.put("mock_interview.generate_questions:v1", mapOf( - "direction", "Java后端", - "level", "高级", - "count", 5, - "ragSection", AiPromptSections.ragSection("优先覆盖并发、缓存、消息队列和数据库。") - )); - - fixtures.put("job_battle.jd_parse:v1", mapOf( - "targetRole", "Java开发工程师", - "targetLevel", "高级", - "city", "上海", - "jdText", "负责高并发服务设计、缓存治理和分布式系统建设。", - "ragSection", AiPromptSections.ragSection("岗位画像优先关注架构能力、业务复杂度与稳定性经验。") - )); - - fixtures.put("job_battle.resume_match:v1", mapOf( - "parsedJdJson", "{\"jobTitle\":\"Java开发工程师\",\"mustSkills\":[\"Redis\",\"MySQL\"]}", - "resumeText", "5 年 Java 后端经验,负责订单与营销系统。", - "projectHighlights", "主导秒杀链路优化,峰值 TPS 提升 3 倍。", - "targetCompanyType", "互联网", - "ragSection", AiPromptSections.ragSection("匹配分析要强调关键词缺失、项目深度与证据强弱。") - )); - - fixtures.put("job_battle.plan_generate:v1", mapOf( - "gapsJson", "[{\"skill\":\"系统设计\",\"priority\":\"P0\"}]", - "targetDays", 21, - "weeklyHours", 12, - "preferredLearningMode", "项目实战", - "nextInterviewDate", "2026-05-10", - "ragSection", AiPromptSections.ragSection("计划应体现冲刺节奏、优先级与产出物。") - )); - - fixtures.put("job_battle.interview_review:v1", mapOf( - "targetRole", "Java开发工程师", - "interviewResult", "二面未通过", - "nextInterviewDate", "2026-05-02", - "interviewNotes", "项目深挖不足,分布式事务回答偏泛。", - "qaTranscriptJson", "[{\"question\":\"分布式事务方案\",\"answer\":\"用了本地消息表\"}]", - "ragSection", AiPromptSections.ragSection("复盘要关注高影响修复动作和 7 天冲刺计划。") - )); - - Map sqlAnalyze = mapOf( - "sql", "select * from orders where user_id = 1 order by create_time desc limit 20", - "explainFormat", "TRADITIONAL", - "explainResult", "[{\"table\":\"orders\",\"type\":\"ALL\",\"rows\":120000}]", - "tableStructures", "[{\"tableName\":\"orders\",\"ddl\":\"CREATE TABLE orders(...)\"}]", - "mysqlVersion", "8.0", - "ragSection", AiPromptSections.ragSection("分析要重点考虑全表扫描、排序和覆盖索引。") - ); - fixtures.put("sql_optimize.analyze:v1", sqlAnalyze); - fixtures.put("sql_optimize.analyze:v2", sqlAnalyze); - - fixtures.put("sql_optimize.rewrite:v2", mapOf( - "originalSql", "select * from orders where user_id = 1 order by create_time desc limit 20", - "diagnoseJson", "{\"score\":52,\"problems\":[{\"type\":\"FULL_TABLE_SCAN\"}]}", - "tableStructures", "[{\"tableName\":\"orders\",\"ddl\":\"CREATE TABLE orders(...)\"}]", - "mysqlVersion", "8.0", - "ragSection", AiPromptSections.ragSection("重写建议要兼顾语义安全、索引维护成本与回表风险。") - )); - - fixtures.put("sql_optimize.compare:v2", mapOf( - "explainFormat", "TRADITIONAL", - "beforeSql", "select * from orders where user_id = 1 order by create_time desc limit 20", - "beforeExplain", "[{\"type\":\"ALL\",\"rows\":120000}]", - "afterSql", "select id, create_time from orders force index(idx_user_ctime) where user_id = 1 order by create_time desc limit 20", - "afterExplain", "[{\"type\":\"range\",\"rows\":120}]" - )); - - return Map.copyOf(fixtures); - } - - private static Map> buildRagQueryFixtures() { - Map> fixtures = new LinkedHashMap<>(); - - fixtures.put("mock_interview.retrieve.generate_questions:v1", mapOf( - "direction", "Java后端", - "level", "高级", - "count", 5 - )); - fixtures.put("mock_interview.retrieve.generate_summary:v1", mapOf( - "direction", "Java后端", - "level", "高级", - "questionCount", 8, - "answeredCount", 7, - "skippedCount", 1, - "totalScore", 78 - )); - fixtures.put("mock_interview.retrieve.generate_follow_up:v1", mapOf( - "direction", "Java后端", - "level", "高级", - "style", "压力型", - "question", "你如何设计高并发库存扣减?", - "answer", "结合 Redis 预扣与数据库补偿。", - "followUpCount", 1 - )); - fixtures.put("mock_interview.retrieve.evaluate_answer:v1", mapOf( - "direction", "Java后端", - "level", "高级", - "style", "压力型", - "question", "你如何设计高并发库存扣减?", - "answer", "结合 Redis 预扣与数据库补偿。", - "followUpCount", 1 - )); - - fixtures.put("job_battle.retrieve.parse_jd:v1", mapOf( - "targetRole", "Java开发工程师", - "targetLevel", "高级", - "city", "上海", - "jdText", "负责高并发服务设计、缓存治理和分布式系统建设。" - )); - fixtures.put("job_battle.retrieve.match_resume:v1", mapOf( - "parsedJdJson", "{\"jobTitle\":\"Java开发工程师\"}", - "resumeText", "5 年 Java 后端经验,负责订单与营销系统。", - "projectHighlights", "主导秒杀链路优化,峰值 TPS 提升 3 倍。", - "targetCompanyType", "互联网" - )); - fixtures.put("job_battle.retrieve.generate_plan:v1", mapOf( - "gapsJson", "[{\"skill\":\"系统设计\",\"priority\":\"P0\"}]", - "targetDays", 21, - "weeklyHours", 12, - "preferredLearningMode", "项目实战", - "nextInterviewDate", "2026-05-10" - )); - fixtures.put("job_battle.retrieve.review_interview:v1", mapOf( - "targetRole", "Java开发工程师", - "interviewResult", "二面未通过", - "interviewNotes", "项目深挖不足,分布式事务回答偏泛。", - "qaTranscriptJson", "[{\"question\":\"分布式事务方案\",\"answer\":\"用了本地消息表\"}]", - "nextInterviewDate", "2026-05-02" - )); - fixtures.put("job_battle.retrieve.analyze_target:v1", mapOf( - "targetRole", "Java开发工程师", - "targetLevel", "高级", - "city", "上海", - "jdText", "负责高并发服务设计、缓存治理和分布式系统建设。", - "resumeText", "5 年 Java 后端经验,负责订单与营销系统。", - "projectHighlights", "主导秒杀链路优化,峰值 TPS 提升 3 倍。", - "targetCompanyType", "互联网" - )); - - fixtures.put("sql_optimize.retrieve.analyze:v1", mapOf( - "sql", "select * from orders where user_id = 1", - "explainResult", "[{\"type\":\"ALL\",\"rows\":120000}]" - )); - fixtures.put("sql_optimize.retrieve.rewrite:v1", mapOf( - "originalSql", "select * from orders where user_id = 1", - "diagnoseJson", "{\"score\":52,\"problems\":[{\"type\":\"FULL_TABLE_SCAN\"}]}" - )); - - return Map.copyOf(fixtures); + Map variables = new LinkedHashMap<>(); + for (String variable : spec.templateVariables()) { + variables.put(variable, DEFAULT_VALUES.getOrDefault(variable, "sample-" + variable)); + } + return variables; } - private static Map mapOf(Object... keyValues) { - Map map = new LinkedHashMap<>(); - for (int i = 0; i < keyValues.length; i += 2) { - map.put(String.valueOf(keyValues[i]), keyValues[i + 1]); - } - return Map.copyOf(map); + private static Map buildDefaultValues() { + Map values = new LinkedHashMap<>(); + values.put("message", "查询智能体工具目录"); + values.put("toolsJson", """ + [{"name":"system.agent.tools.list","inputSchema":{},"requiredInputKeys":[]}] + """); + values.put("sessionContextJson", """ + {"sessionId":"session-1","recentTurns":[]} + """); + values.put("title", "Redis 缓存一致性复盘"); + values.put("content", "本文复盘缓存更新、延迟双删和补偿任务的取舍。"); + values.put("question", "请讲一下缓存一致性方案。"); + values.put("answer", "可以从旁路缓存、延迟双删和消息最终一致性展开。"); + values.put("context", "候选人正在模拟 Java 后端面试。"); + values.put("profile", "5 年 Java 后端开发,熟悉 Redis 和 MySQL。"); + values.put("jobDescription", "负责交易链路稳定性和高并发系统建设。"); + values.put("resume", "参与订单系统重构,优化接口耗时和缓存命中率。"); + values.put("target", "两周内提升系统设计表达。"); + values.put("review", "系统设计表达缺少容量估算。"); + values.put("sql", "SELECT * FROM orders WHERE user_id = 1 ORDER BY create_time DESC LIMIT 20"); + values.put("ddl", "CREATE TABLE orders(id BIGINT PRIMARY KEY, user_id BIGINT, create_time DATETIME)"); + values.put("explain", "type=ALL, rows=500000, Extra=Using filesort"); + values.put("explainResult", "type=ALL, rows=500000, Extra=Using filesort"); + values.put("originalSql", "SELECT * FROM orders WHERE user_id = 1"); + values.put("optimizedSql", "SELECT id FROM orders WHERE user_id = 1"); + values.put("diagnoseJson", "{\"problem\":\"FULL_TABLE_SCAN\"}"); + values.put("direction", "Java 后端"); + values.put("level", "中级"); + values.put("count", 5); + values.put("questionCount", 5); + values.put("answeredCount", 4); + values.put("skippedCount", 1); + values.put("totalScore", 32); + values.put("style", "追问式"); + values.put("followUpCount", 1); + values.put("targetRole", "Java 后端开发"); + values.put("targetLevel", "P6"); + values.put("city", "杭州"); + values.put("jdText", "负责高并发交易链路稳定性建设。"); + values.put("parsedJdJson", "{\"jobTitle\":\"Java 后端\"}"); + values.put("resumeText", "有订单系统和缓存治理经验。"); + values.put("projectHighlights", "接口耗时降低 40%。"); + values.put("targetCompanyType", "互联网平台"); + values.put("gapsJson", "[{\"skill\":\"Kafka\",\"priority\":\"P1\"}]"); + values.put("targetDays", 14); + values.put("weeklyHours", 12); + values.put("preferredLearningMode", "项目复盘"); + values.put("nextInterviewDate", "2026-07-20"); + values.put("interviewResult", "未通过"); + values.put("interviewNotes", "系统设计容量估算不足。"); + values.put("qaTranscriptJson", "[{\"q\":\"缓存一致性\",\"a\":\"延迟双删\"}]"); + return Map.copyOf(values); } } diff --git a/xiaou-ai/src/test/java/com/xiaou/ai/prompt/AiPromptSpecTest.java b/xiaou-ai/src/test/java/com/xiaou/ai/prompt/AiPromptSpecTest.java index fffbb539d..08c29a9ec 100644 --- a/xiaou-ai/src/test/java/com/xiaou/ai/prompt/AiPromptSpecTest.java +++ b/xiaou-ai/src/test/java/com/xiaou/ai/prompt/AiPromptSpecTest.java @@ -1,124 +1,81 @@ package com.xiaou.ai.prompt; -import com.xiaou.ai.prompt.community.CommunityPromptSpecs; -import com.xiaou.ai.prompt.interview.InterviewPromptSpecs; -import com.xiaou.ai.prompt.jobbattle.JobBattlePromptSpecs; -import com.xiaou.ai.prompt.sql.SqlOptimizePromptSpecs; +import com.xiaou.ai.prompt.admin.AdminAgentPromptSpecs; import org.junit.jupiter.api.Test; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.stream.Collectors; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class AiPromptSpecTest { @Test - void shouldRenderInterviewPromptWithNamedVariablesAndRagSection() { - String prompt = InterviewPromptSpecs.EVALUATE_ANSWER.renderUser(Map.of( - "direction", "Java后端", - "level", "高级", - "style", "压力型", - "followUpCount", 1, - "question", "你如何设计高并发库存扣减?", - "answer", "我会结合 Redis 预扣和数据库最终一致性。", - "ragSection", AiPromptSections.ragSection("库存扣减要关注幂等与超卖控制") - )); - - assertTrue(prompt.contains("Java后端")); - assertTrue(prompt.contains("高并发库存扣减")); - assertTrue(prompt.contains("超卖控制")); - } - - @Test - void shouldNotRenderNullLiteralWhenOptionalVariablesAreMissing() { - String prompt = CommunityPromptSpecs.POST_SUMMARY.renderUser(Map.of( - "content", "这是一篇关于 SQL 优化的帖子" - )); - - assertFalse(prompt.contains("null")); - assertFalse(prompt.contains("{{title}}")); - assertTrue(prompt.contains("SQL 优化")); - } - - @Test - void shouldKeepPromptVersionedAcrossDifferentScenes() { - String jobBattlePromptId = JobBattlePromptSpecs.PLAN_GENERATE.promptId(); - String sqlPromptId = SqlOptimizePromptSpecs.REWRITE.promptId(); - - assertTrue(jobBattlePromptId.startsWith("job_battle.plan_generate:")); - assertTrue(sqlPromptId.startsWith("sql_optimize.rewrite:")); - } - - @Test - void shouldRegisterUniquePromptIdsAcrossCatalog() { + void shouldRegisterUniquePromptIds() { List specs = AiPromptCatalog.all(); - Set promptIds = specs.stream() + Set ids = specs.stream() .map(AiPromptSpec::promptId) .collect(Collectors.toSet()); - assertEquals(specs.size(), promptIds.size(), "PromptId 不允许重复,避免灰度与观测混乱"); + assertEquals(specs.size(), ids.size(), "PromptSpec 不允许重复,避免治理、观测和结构化契约串线"); } @Test - void shouldKeepPromptGovernanceRulesAcrossCatalog() { + void shouldRenderAllPromptFixturesWithoutTemplateMarkers() { for (AiPromptSpec spec : AiPromptCatalog.all()) { - assertTrue(spec.systemPrompt().contains("只输出 JSON"), - () -> "system prompt 缺少 JSON-only 约束: " + spec.promptId()); - assertTrue(spec.systemPrompt().contains("不要输出 Markdown"), - () -> "system prompt 缺少 Markdown 禁止约束: " + spec.promptId()); - assertFalse(spec.systemPrompt().contains("{{"), - () -> "system prompt 不应混入模板变量: " + spec.promptId()); - assertTrue(!spec.templateVariables().isEmpty(), - () -> "user template 应至少包含一个命名变量: " + spec.promptId()); + String rendered = spec.renderUser(AiPromptFixtures.variables(spec)); + + assertFalse(rendered.contains("{{"), () -> "Prompt 样例缺少变量: " + spec.promptId()); + assertFalse(rendered.contains("}}"), () -> "Prompt 样例缺少变量: " + spec.promptId()); } } @Test - void shouldRenderAllPromptSpecsWithFixtures() { - for (AiPromptSpec spec : AiPromptCatalog.all()) { - Map variables = AiPromptFixtures.promptVariables(spec); - assertNotNull(variables, () -> "缺少 Prompt 渲染样例: " + spec.promptId()); - - String prompt = spec.renderUser(variables); - assertFalse(prompt.contains("null"), () -> "Prompt 渲染不应出现 null: " + spec.promptId()); - assertFalse(prompt.contains("{{"), () -> "Prompt 渲染后仍有未替换占位符: " + spec.promptId()); - } + void shouldExposeAdminAgentPlannerPromptContract() { + AiPromptSpec spec = AdminAgentPromptSpecs.PLAN; + + assertEquals("admin_agent.plan", spec.key()); + assertEquals("v1", spec.version()); + assertEquals(512, spec.maxCompletionTokens()); + assertEquals(Set.of("message", "sessionContextJson", "toolsJson"), spec.templateVariables()); + assertTrue(spec.systemPrompt().contains("不能执行工具")); + assertTrue(spec.systemPrompt().contains("missingFields")); + assertTrue(spec.systemPrompt().contains("外层主动作")); + assertTrue(spec.systemPrompt().contains("不要直接选择内层目标工具")); } @Test - void shouldExposeTemplateVariablesForCatalogPrompts() { - assertEquals(Set.of("title", "content"), CommunityPromptSpecs.POST_SUMMARY.templateVariables()); - assertEquals( - Set.of("direction", "level", "style", "followUpCount", "question", "answer", "ragSection"), - InterviewPromptSpecs.EVALUATE_ANSWER.templateVariables() - ); - assertEquals( - Set.of("gapsJson", "targetDays", "weeklyHours", "preferredLearningMode", "nextInterviewDate", "ragSection"), - JobBattlePromptSpecs.PLAN_GENERATE.templateVariables() - ); - assertEquals( - Set.of("beforeSql", "beforeExplain", "afterSql", "afterExplain", "explainFormat"), - SqlOptimizePromptSpecs.COMPARE.templateVariables() - ); + void shouldRejectTemplateVariablesInsideSystemPrompt() { + assertThrows(IllegalArgumentException.class, () -> AiPromptSpec.of( + "test.invalid_system", + "v1", + "不要在 system prompt 使用 {{name}}", + "用户输入:{{input}}" + )); } @Test - void shouldAllowMissingOptionalPromptVariablesToFallbackAsBlank() { - String prompt = InterviewPromptSpecs.GENERATE_QUESTIONS.renderUser(Map.of( - "direction", "Java后端", - "level", "高级", - "count", 3 + void shouldRejectPromptWithoutUserTemplateVariables() { + assertThrows(IllegalArgumentException.class, () -> AiPromptSpec.of( + "test.no_variable", + "v1", + "合法 system prompt", + "这里没有变量" )); + } - assertFalse(prompt.contains("null")); - assertFalse(prompt.contains("{{ragSection}}")); - assertTrue(prompt.contains("Java后端")); - assertTrue(prompt.contains("3 道")); + @Test + void shouldRejectNonPositiveCompletionBudget() { + assertThrows(IllegalArgumentException.class, () -> AiPromptSpec.of( + "test.invalid_budget", + "v1", + "合法 system prompt", + "用户输入:{{input}}", + 0 + )); } } diff --git a/xiaou-ai/src/test/java/com/xiaou/ai/structured/AiStructuredOutputFixtures.java b/xiaou-ai/src/test/java/com/xiaou/ai/structured/AiStructuredOutputFixtures.java index e1f7a1cad..1c5718095 100644 --- a/xiaou-ai/src/test/java/com/xiaou/ai/structured/AiStructuredOutputFixtures.java +++ b/xiaou-ai/src/test/java/com/xiaou/ai/structured/AiStructuredOutputFixtures.java @@ -22,6 +22,11 @@ static String json(AiStructuredOutputSpec spec) { private static Map buildFixtures() { Map fixtures = new LinkedHashMap<>(); + fixtures.put("admin_agent.plan:v1", + """ + {"toolName":"chat.userBan.unban","input":{"userId":88},"confidence":0.92,"missingFields":[]} + """); + fixtures.put("community.post_summary:v1", """ {"summary":"帖子复盘了 Redis 缓存一致性问题,并给出补偿方案。","keywords":["Redis","缓存一致性","补偿机制"]} diff --git a/xiaou-application/src/main/resources/application.yml b/xiaou-application/src/main/resources/application.yml index 204ae9eca..30769530e 100644 --- a/xiaou-application/src/main/resources/application.yml +++ b/xiaou-application/src/main/resources/application.yml @@ -208,6 +208,7 @@ xiaou: api-key: ${XIAOU_AI_API_KEY:} model: chat: ${XIAOU_AI_CHAT_MODEL:gpt-5.4} + max-completion-tokens: ${XIAOU_AI_MAX_COMPLETION_TOKENS:2048} embedding: ${XIAOU_AI_EMBEDDING_MODEL:text-embedding-3-small} pricing: currency: ${XIAOU_AI_PRICING_CURRENCY:USD} diff --git a/xiaou-chat/pom.xml b/xiaou-chat/pom.xml index 5ab114567..a0ef6a0ac 100644 --- a/xiaou-chat/pom.xml +++ b/xiaou-chat/pom.xml @@ -32,6 +32,12 @@ org.springframework.boot spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/xiaou-chat/src/main/java/com/xiaou/chat/controller/admin/ChatAdminController.java b/xiaou-chat/src/main/java/com/xiaou/chat/controller/admin/ChatAdminController.java index 384ba946c..7141c5797 100644 --- a/xiaou-chat/src/main/java/com/xiaou/chat/controller/admin/ChatAdminController.java +++ b/xiaou-chat/src/main/java/com/xiaou/chat/controller/admin/ChatAdminController.java @@ -1,6 +1,7 @@ package com.xiaou.chat.controller.admin; import com.xiaou.chat.domain.ChatMessage; +import com.xiaou.chat.domain.ChatUserBan; import com.xiaou.chat.dto.*; import com.xiaou.chat.service.ChatMessageService; import com.xiaou.chat.service.ChatOnlineUserService; @@ -121,6 +122,17 @@ public Result banUser(@RequestBody ChatBanUserRequest request) { request.getUserId(), request.getBanDuration(), request.getBanReason()); return Result.success(); } + + /** + * 查询用户当前生效禁言记录 + */ + @RequireAdmin + @Log(module = "聊天室管理", type = Log.OperationType.SELECT, description = "查询用户禁言状态") + @PostMapping("/users/ban/active") + public Result getActiveUserBan(@RequestBody Long userId) { + ChatUserBan ban = chatUserBanService.getActiveBan(userId); + return Result.success(toUserBanResponse(ban)); + } /** * 解除禁言 @@ -151,4 +163,18 @@ public Result sendAnnouncement(@RequestBody ChatAnnouncementRequest reques log.info("管理员发送系统公告,内容: {}", request.getContent()); return Result.success(); } + + private ChatUserBanResponse toUserBanResponse(ChatUserBan ban) { + if (ban == null) { + return null; + } + ChatUserBanResponse response = new ChatUserBanResponse(); + response.setId(ban.getId()); + response.setUserId(ban.getUserId()); + response.setBanReason(ban.getBanReason()); + response.setBanStartTime(ban.getBanStartTime()); + response.setBanEndTime(ban.getBanEndTime()); + response.setStatus(ban.getStatus()); + return response; + } } diff --git a/xiaou-chat/src/main/java/com/xiaou/chat/dto/ChatUserBanResponse.java b/xiaou-chat/src/main/java/com/xiaou/chat/dto/ChatUserBanResponse.java new file mode 100644 index 000000000..567cbe682 --- /dev/null +++ b/xiaou-chat/src/main/java/com/xiaou/chat/dto/ChatUserBanResponse.java @@ -0,0 +1,47 @@ +package com.xiaou.chat.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +/** + * 管理端用户禁言当前状态响应 + * + * @author xiaou + */ +@Data +public class ChatUserBanResponse { + + /** + * 禁言记录ID + */ + private Long id; + + /** + * 被禁言用户ID + */ + private Long userId; + + /** + * 禁言原因 + */ + private String banReason; + + /** + * 禁言开始时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date banStartTime; + + /** + * 禁言结束时间,NULL表示永久 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date banEndTime; + + /** + * 状态:1生效中 0已解除 + */ + private Integer status; +} diff --git a/xiaou-chat/src/main/java/com/xiaou/chat/service/ChatUserBanService.java b/xiaou-chat/src/main/java/com/xiaou/chat/service/ChatUserBanService.java index bce781d91..5f2b772c9 100644 --- a/xiaou-chat/src/main/java/com/xiaou/chat/service/ChatUserBanService.java +++ b/xiaou-chat/src/main/java/com/xiaou/chat/service/ChatUserBanService.java @@ -1,5 +1,6 @@ package com.xiaou.chat.service; +import com.xiaou.chat.domain.ChatUserBan; import com.xiaou.chat.dto.ChatBanUserRequest; /** @@ -18,6 +19,11 @@ public interface ChatUserBanService { * 解除禁言 */ void unbanUser(Long userId); + + /** + * 查询用户当前生效禁言记录 + */ + ChatUserBan getActiveBan(Long userId); /** * 检查用户是否被禁言 diff --git a/xiaou-chat/src/main/java/com/xiaou/chat/service/impl/ChatMessageServiceImpl.java b/xiaou-chat/src/main/java/com/xiaou/chat/service/impl/ChatMessageServiceImpl.java index 5eb8cfb52..62188d233 100644 --- a/xiaou-chat/src/main/java/com/xiaou/chat/service/impl/ChatMessageServiceImpl.java +++ b/xiaou-chat/src/main/java/com/xiaou/chat/service/impl/ChatMessageServiceImpl.java @@ -14,12 +14,14 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; /** @@ -34,6 +36,7 @@ public class ChatMessageServiceImpl implements ChatMessageService { private static final int MESSAGE_TYPE_TEXT = 1; private static final int MESSAGE_TYPE_IMAGE = 2; + private static final int IMAGE_URL_COLUMN_LENGTH = 500; private static final int RECALL_TIME_LIMIT = 120; // 撤回时间限制:2分钟 private final ChatMessageMapper chatMessageMapper; @@ -43,10 +46,11 @@ public class ChatMessageServiceImpl implements ChatMessageService { @Value("${xiaou.chat.message.max-text-length:1000}") private int maxTextLength; - @Value("${xiaou.chat.message.max-image-url-length:1024}") + @Value("${xiaou.chat.message.max-image-url-length:500}") private int maxImageUrlLength; @Override + @Transactional(rollbackFor = Exception.class) public ChatMessage sendMessage(ChatMessageRequest request, Long userId, String ipAddress, String deviceInfo) { validateMessageRequest(request); @@ -73,7 +77,7 @@ public ChatMessage sendMessage(ChatMessageRequest request, Long userId, String i // 处理回复消息 if (request.getReplyToId() != null && request.getReplyToId() > 0) { ChatMessage replyToMsg = chatMessageMapper.selectById(request.getReplyToId()); - if (replyToMsg != null) { + if (isReplyVisibleInRoom(replyToMsg, roomId)) { message.setReplyToId(request.getReplyToId()); message.setReplyToUser(replyToMsg.getUserNickname()); // 截取回复内容摘要,最多50字 @@ -81,7 +85,10 @@ public ChatMessage sendMessage(ChatMessageRequest request, Long userId, String i if (content != null && content.length() > 50) { content = content.substring(0, 50) + "..."; } - message.setReplyToContent(replyToMsg.getMessageType() == 2 ? "[图片]" : content); + message.setReplyToContent( + Integer.valueOf(MESSAGE_TYPE_IMAGE).equals(replyToMsg.getMessageType()) + ? "[图片]" + : content); } } @@ -90,10 +97,14 @@ public ChatMessage sendMessage(ChatMessageRequest request, Long userId, String i throw new BusinessException("发送消息失败"); } - log.info("用户发送消息,用户ID: {}, 消息ID: {}, 类型: {}", userId, message.getId(), request.getMessageType()); - // 查询完整消息信息(包含用户信息) - return chatMessageMapper.selectById(message.getId()); + ChatMessage persisted = chatMessageMapper.selectById(message.getId()); + if (persisted == null) { + throw new BusinessException("发送消息失败"); + } + + log.info("用户发送消息,用户ID: {}, 消息ID: {}, 类型: {}", userId, message.getId(), request.getMessageType()); + return persisted; } private void validateMessageRequest(ChatMessageRequest request) { @@ -121,7 +132,8 @@ private void validateMessageRequest(ChatMessageRequest request) { if (imageUrl == null || imageUrl.isBlank()) { throw new BusinessException("图片消息地址不能为空"); } - if (imageUrl.length() > maxImageUrlLength) { + int effectiveMaxLength = Math.min(maxImageUrlLength, IMAGE_URL_COLUMN_LENGTH); + if (imageUrl.length() > effectiveMaxLength) { throw new BusinessException("图片地址过长"); } if (!isAllowedImageUrl(imageUrl)) { @@ -154,6 +166,12 @@ private boolean isAllowedImageUrl(String imageUrl) { || normalized.startsWith("/api/files/") || normalized.startsWith("api/files/"); } + + private boolean isReplyVisibleInRoom(ChatMessage message, Long roomId) { + return message != null + && Objects.equals(roomId, message.getRoomId()) + && !Integer.valueOf(1).equals(message.getIsDeleted()); + } @Override public ChatHistoryResponse getHistory(ChatHistoryRequest request) { @@ -184,7 +202,7 @@ public ChatHistoryResponse getHistory(ChatHistoryRequest request) { public void recallMessage(Long messageId, Long userId) { ChatMessage message = chatMessageMapper.selectById(messageId); - if (message == null) { + if (message == null || Integer.valueOf(1).equals(message.getIsDeleted())) { throw new BusinessException("消息不存在"); } @@ -223,6 +241,9 @@ public void batchDeleteMessages(List ids) { } int result = chatMessageMapper.deleteBatch(ids); + if (result <= 0) { + throw new BusinessException("批量删除消息失败"); + } log.info("批量删除消息,数量: {}", result); } diff --git a/xiaou-chat/src/main/java/com/xiaou/chat/service/impl/ChatUserBanServiceImpl.java b/xiaou-chat/src/main/java/com/xiaou/chat/service/impl/ChatUserBanServiceImpl.java index 42df1377f..1fe2e0b0b 100644 --- a/xiaou-chat/src/main/java/com/xiaou/chat/service/impl/ChatUserBanServiceImpl.java +++ b/xiaou-chat/src/main/java/com/xiaou/chat/service/impl/ChatUserBanServiceImpl.java @@ -81,6 +81,12 @@ public void unbanUser(Long userId) { log.info("解除禁言成功,用户ID: {}", userId); } + + @Override + public ChatUserBan getActiveBan(Long userId) { + Long roomId = chatRoomService.getOfficialRoom().getId(); + return chatUserBanMapper.selectActiveByUserId(userId, roomId); + } @Override public boolean isUserBanned(Long userId, Long roomId) { diff --git a/xiaou-chat/src/test/java/com/xiaou/chat/service/impl/ChatMessageServiceImplTest.java b/xiaou-chat/src/test/java/com/xiaou/chat/service/impl/ChatMessageServiceImplTest.java new file mode 100644 index 000000000..9d7c02dc5 --- /dev/null +++ b/xiaou-chat/src/test/java/com/xiaou/chat/service/impl/ChatMessageServiceImplTest.java @@ -0,0 +1,637 @@ +package com.xiaou.chat.service.impl; + +import com.xiaou.chat.domain.ChatMessage; +import com.xiaou.chat.domain.ChatRoom; +import com.xiaou.chat.dto.ChatHistoryRequest; +import com.xiaou.chat.dto.ChatHistoryResponse; +import com.xiaou.chat.dto.ChatMessageRequest; +import com.xiaou.chat.mapper.ChatMessageMapper; +import com.xiaou.chat.service.ChatRoomService; +import com.xiaou.chat.service.ChatUserBanService; +import com.xiaou.common.exception.BusinessException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ChatMessageServiceImplTest { + + private static final long ROOM_ID = 10L; + private static final long USER_ID = 42L; + private static final long MESSAGE_ID = 1001L; + private static final long REPLY_ID = 900L; + private static final String IP_ADDRESS = "203.0.113.42"; + private static final String DEVICE_INFO = "chat-service-test"; + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidMessageRequests") + void shouldRejectInvalidInputBeforeExternalLookups( + String scenario, ChatMessageRequest request, String expectedMessage) { + Fixture fixture = new Fixture(); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.sendMessage(request, USER_ID, IP_ADDRESS, DEVICE_INFO) + ); + + assertEquals(expectedMessage, exception.getMessage(), scenario); + verifyNoInteractions(fixture.roomService, fixture.banService, fixture.messageMapper); + } + + @Test + void shouldStopBeforePersistenceWhenUserIsBanned() { + Fixture fixture = new Fixture(); + fixture.prepareRoom(); + when(fixture.banService.isUserBanned(USER_ID, ROOM_ID)).thenReturn(true); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.sendMessage( + textRequest("hello"), USER_ID, IP_ADDRESS, DEVICE_INFO) + ); + + assertEquals("您已被禁言,无法发送消息", exception.getMessage()); + verify(fixture.roomService).getOfficialRoom(); + verify(fixture.banService).isUserBanned(USER_ID, ROOM_ID); + verifyNoInteractions(fixture.messageMapper); + } + + @Test + void shouldNormalizeAndPersistDefaultTextMessageInOrder() { + Fixture fixture = new Fixture(); + fixture.allowSending(); + ChatMessage persisted = persistedMessage(1, "hello", null); + fixture.persistAndReload(persisted); + ChatMessageRequest request = textRequest(" hello "); + request.setMessageType(null); + request.setImageUrl("https://example.com/ignored.png"); + + ChatMessage result = fixture.service.sendMessage( + request, USER_ID, IP_ADDRESS, DEVICE_INFO); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ChatMessage.class); + InOrder order = inOrder(fixture.roomService, fixture.banService, fixture.messageMapper); + order.verify(fixture.roomService).getOfficialRoom(); + order.verify(fixture.banService).isUserBanned(USER_ID, ROOM_ID); + order.verify(fixture.messageMapper).insert(messageCaptor.capture()); + order.verify(fixture.messageMapper).selectById(MESSAGE_ID); + + ChatMessage inserted = messageCaptor.getValue(); + assertAll( + () -> assertEquals(MESSAGE_ID, inserted.getId()), + () -> assertEquals(ROOM_ID, inserted.getRoomId()), + () -> assertEquals(USER_ID, inserted.getUserId()), + () -> assertEquals(1, inserted.getMessageType()), + () -> assertEquals("hello", inserted.getContent()), + () -> assertNull(inserted.getImageUrl()), + () -> assertEquals(0, inserted.getIsDeleted()), + () -> assertEquals(IP_ADDRESS, inserted.getIpAddress()), + () -> assertEquals(DEVICE_INFO, inserted.getDeviceInfo()), + () -> assertSame(persisted, result) + ); + } + + @Test + void shouldNormalizeImageMessageAndCaption() { + Fixture fixture = new Fixture(); + fixture.allowSending(); + ChatMessage persisted = persistedMessage(2, "screenshot", "/api/files/demo.png"); + fixture.persistAndReload(persisted); + ChatMessageRequest request = imageRequest( + " screenshot ", " /api/files/demo.png "); + + ChatMessage result = fixture.service.sendMessage( + request, USER_ID, IP_ADDRESS, DEVICE_INFO); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatMessage.class); + verify(fixture.messageMapper).insert(captor.capture()); + assertAll( + () -> assertEquals(2, captor.getValue().getMessageType()), + () -> assertEquals("screenshot", captor.getValue().getContent()), + () -> assertEquals("/api/files/demo.png", captor.getValue().getImageUrl()), + () -> assertSame(persisted, result) + ); + } + + @Test + void shouldUseImagePlaceholderForBlankCaption() { + Fixture fixture = new Fixture(); + fixture.allowSending(); + fixture.persistAndReload(persistedMessage(2, "[图片]", "https://example.com/a.png")); + + fixture.service.sendMessage( + imageRequest(" ", "https://example.com/a.png"), + USER_ID, + IP_ADDRESS, + DEVICE_INFO + ); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatMessage.class); + verify(fixture.messageMapper).insert(captor.capture()); + assertEquals("[图片]", captor.getValue().getContent()); + } + + @Test + void shouldCopyValidReplyMetadataAndTruncateLongText() { + Fixture fixture = new Fixture(); + fixture.allowSending(); + ChatMessage reply = replyMessage(ROOM_ID, 0); + reply.setContent("x".repeat(60)); + reply.setUserNickname("Alice"); + when(fixture.messageMapper.selectById(REPLY_ID)).thenReturn(reply); + fixture.persistAndReload(persistedMessage(1, "reply", null)); + ChatMessageRequest request = textRequest("reply"); + request.setReplyToId(REPLY_ID); + + fixture.service.sendMessage(request, USER_ID, IP_ADDRESS, DEVICE_INFO); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatMessage.class); + verify(fixture.messageMapper).insert(captor.capture()); + assertAll( + () -> assertEquals(REPLY_ID, captor.getValue().getReplyToId()), + () -> assertEquals("Alice", captor.getValue().getReplyToUser()), + () -> assertEquals("x".repeat(50) + "...", + captor.getValue().getReplyToContent()) + ); + } + + @Test + void shouldUsePlaceholderWhenReplyingToImage() { + Fixture fixture = new Fixture(); + fixture.allowSending(); + ChatMessage reply = replyMessage(ROOM_ID, 0); + reply.setMessageType(2); + reply.setContent(null); + when(fixture.messageMapper.selectById(REPLY_ID)).thenReturn(reply); + fixture.persistAndReload(persistedMessage(1, "reply", null)); + ChatMessageRequest request = textRequest("reply"); + request.setReplyToId(REPLY_ID); + + fixture.service.sendMessage(request, USER_ID, IP_ADDRESS, DEVICE_INFO); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatMessage.class); + verify(fixture.messageMapper).insert(captor.capture()); + assertEquals("[图片]", captor.getValue().getReplyToContent()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidReplyMessages") + void shouldIgnoreReplyMetadataOutsideTheActiveRoom( + String scenario, ChatMessage invalidReply) { + Fixture fixture = new Fixture(); + fixture.allowSending(); + when(fixture.messageMapper.selectById(REPLY_ID)).thenReturn(invalidReply); + fixture.persistAndReload(persistedMessage(1, "message", null)); + ChatMessageRequest request = textRequest("message"); + request.setReplyToId(REPLY_ID); + + fixture.service.sendMessage(request, USER_ID, IP_ADDRESS, DEVICE_INFO); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatMessage.class); + verify(fixture.messageMapper).insert(captor.capture()); + assertAll(scenario, + () -> assertNull(captor.getValue().getReplyToId()), + () -> assertNull(captor.getValue().getReplyToUser()), + () -> assertNull(captor.getValue().getReplyToContent()) + ); + } + + @Test + void shouldFailWithoutReloadWhenInsertIsRejected() { + Fixture fixture = new Fixture(); + fixture.allowSending(); + when(fixture.messageMapper.insert(any(ChatMessage.class))).thenReturn(0); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.sendMessage( + textRequest("hello"), USER_ID, IP_ADDRESS, DEVICE_INFO) + ); + + assertEquals("发送消息失败", exception.getMessage()); + verify(fixture.messageMapper, never()).selectById(anyLong()); + } + + @Test + void shouldFailWhenInsertedMessageCannotBeReloaded() { + Fixture fixture = new Fixture(); + fixture.allowSending(); + when(fixture.messageMapper.insert(any(ChatMessage.class))).thenAnswer(invocation -> { + ChatMessage message = invocation.getArgument(0); + message.setId(MESSAGE_ID); + return 1; + }); + when(fixture.messageMapper.selectById(MESSAGE_ID)).thenReturn(null); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.sendMessage( + textRequest("hello"), USER_ID, IP_ADDRESS, DEVICE_INFO) + ); + + assertEquals("发送消息失败", exception.getMessage()); + } + + @Test + void shouldDeclareTransactionForInsertAndReloadConsistency() throws NoSuchMethodException { + Method sendMessage = ChatMessageServiceImpl.class.getMethod( + "sendMessage", ChatMessageRequest.class, Long.class, String.class, String.class); + AnnotationTransactionAttributeSource attributes = + new AnnotationTransactionAttributeSource(); + + assertNotNull(attributes.getTransactionAttribute(sendMessage, ChatMessageServiceImpl.class)); + } + + @Test + void shouldCapConfiguredImageUrlLimitAtDatabaseColumnLength() { + Fixture fixture = new Fixture(); + ReflectionTestUtils.setField(fixture.service, "maxImageUrlLength", 1024); + ChatMessageRequest request = imageRequest( + null, "https://example.com/" + "x".repeat(481)); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.sendMessage(request, USER_ID, IP_ADDRESS, DEVICE_INFO) + ); + + assertEquals("图片地址过长", exception.getMessage()); + verifyNoInteractions(fixture.roomService, fixture.banService, fixture.messageMapper); + } + + @Test + void shouldReturnHistoryInChronologicalOrderWithRecallState() { + Fixture fixture = new Fixture(); + fixture.prepareRoom(); + ChatHistoryRequest request = new ChatHistoryRequest(); + request.setLastMessageId(200L); + request.setPageSize(2); + ChatMessage newer = historyMessage(2L, 30_000L); + ChatMessage older = historyMessage(1L, 180_000L); + when(fixture.messageMapper.selectHistory(ROOM_ID, 200L, 2)) + .thenReturn(new ArrayList<>(List.of(newer, older))); + + ChatHistoryResponse response = fixture.service.getHistory(request); + + assertAll( + () -> assertEquals(List.of(1L, 2L), response.getMessages().stream() + .map(message -> message.getId()) + .toList()), + () -> assertFalse(response.getMessages().get(0).getCanRecall()), + () -> assertTrue(response.getMessages().get(1).getCanRecall()), + () -> assertTrue(response.getHasMore()) + ); + verify(fixture.messageMapper).selectHistory(ROOM_ID, 200L, 2); + } + + @Test + void shouldReportNoMoreHistoryWhenPageIsNotFull() { + Fixture fixture = new Fixture(); + fixture.prepareRoom(); + ChatHistoryRequest request = new ChatHistoryRequest(); + request.setPageSize(2); + when(fixture.messageMapper.selectHistory(ROOM_ID, 0L, 2)) + .thenReturn(new ArrayList<>(List.of(historyMessage(1L, 30_000L)))); + + ChatHistoryResponse response = fixture.service.getHistory(request); + + assertFalse(response.getHasMore()); + } + + @Test + void shouldRejectRecallWhenMessageDoesNotExist() { + Fixture fixture = new Fixture(); + when(fixture.messageMapper.selectById(MESSAGE_ID)).thenReturn(null); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.recallMessage(MESSAGE_ID, USER_ID) + ); + + assertEquals("消息不存在", exception.getMessage()); + verify(fixture.messageMapper, never()).deleteById(anyLong()); + } + + @Test + void shouldRejectRecallWhenMessageWasAlreadyDeleted() { + Fixture fixture = new Fixture(); + ChatMessage message = recallableMessage(USER_ID, 30_000L); + message.setIsDeleted(1); + when(fixture.messageMapper.selectById(MESSAGE_ID)).thenReturn(message); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.recallMessage(MESSAGE_ID, USER_ID) + ); + + assertEquals("消息不存在", exception.getMessage()); + verify(fixture.messageMapper, never()).deleteById(anyLong()); + } + + @Test + void shouldRejectRecallFromAnotherUser() { + Fixture fixture = new Fixture(); + when(fixture.messageMapper.selectById(MESSAGE_ID)) + .thenReturn(recallableMessage(USER_ID + 1, 30_000L)); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.recallMessage(MESSAGE_ID, USER_ID) + ); + + assertEquals("只能撤回自己的消息", exception.getMessage()); + verify(fixture.messageMapper, never()).deleteById(anyLong()); + } + + @Test + void shouldRejectRecallOutsideTimeWindow() { + Fixture fixture = new Fixture(); + when(fixture.messageMapper.selectById(MESSAGE_ID)) + .thenReturn(recallableMessage(USER_ID, 121_000L)); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.recallMessage(MESSAGE_ID, USER_ID) + ); + + assertEquals("消息发送超过2分钟,无法撤回", exception.getMessage()); + verify(fixture.messageMapper, never()).deleteById(anyLong()); + } + + @Test + void shouldRejectRecallWhenDeleteAffectsNoRows() { + Fixture fixture = new Fixture(); + when(fixture.messageMapper.selectById(MESSAGE_ID)) + .thenReturn(recallableMessage(USER_ID, 30_000L)); + when(fixture.messageMapper.deleteById(MESSAGE_ID)).thenReturn(0); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.recallMessage(MESSAGE_ID, USER_ID) + ); + + assertEquals("撤回消息失败", exception.getMessage()); + } + + @Test + void shouldRecallRecentOwnedMessage() { + Fixture fixture = new Fixture(); + when(fixture.messageMapper.selectById(MESSAGE_ID)) + .thenReturn(recallableMessage(USER_ID, 30_000L)); + when(fixture.messageMapper.deleteById(MESSAGE_ID)).thenReturn(1); + + fixture.service.recallMessage(MESSAGE_ID, USER_ID); + + InOrder order = inOrder(fixture.messageMapper); + order.verify(fixture.messageMapper).selectById(MESSAGE_ID); + order.verify(fixture.messageMapper).deleteById(MESSAGE_ID); + } + + @Test + void shouldDeleteMessageWhenMapperUpdatesARow() { + Fixture fixture = new Fixture(); + when(fixture.messageMapper.deleteById(MESSAGE_ID)).thenReturn(1); + + fixture.service.deleteMessage(MESSAGE_ID); + + verify(fixture.messageMapper).deleteById(MESSAGE_ID); + } + + @Test + void shouldRejectDeleteWhenMapperUpdatesNoRows() { + Fixture fixture = new Fixture(); + when(fixture.messageMapper.deleteById(MESSAGE_ID)).thenReturn(0); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.deleteMessage(MESSAGE_ID) + ); + + assertEquals("删除消息失败", exception.getMessage()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("emptyBatchIds") + void shouldRejectEmptyBatchDelete(String scenario, List ids) { + Fixture fixture = new Fixture(); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.batchDeleteMessages(ids) + ); + + assertEquals("请选择要删除的消息", exception.getMessage(), scenario); + verifyNoInteractions(fixture.messageMapper); + } + + @Test + void shouldRejectBatchDeleteWhenMapperUpdatesNoRows() { + Fixture fixture = new Fixture(); + List ids = List.of(1L, 2L); + when(fixture.messageMapper.deleteBatch(ids)).thenReturn(0); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.batchDeleteMessages(ids) + ); + + assertEquals("批量删除消息失败", exception.getMessage()); + } + + @Test + void shouldBatchDeleteMessages() { + Fixture fixture = new Fixture(); + List ids = List.of(1L, 2L); + when(fixture.messageMapper.deleteBatch(ids)).thenReturn(2); + + fixture.service.batchDeleteMessages(ids); + + verify(fixture.messageMapper).deleteBatch(ids); + } + + @Test + void shouldPersistSystemAnnouncement() { + Fixture fixture = new Fixture(); + fixture.prepareRoom(); + when(fixture.messageMapper.insert(any(ChatMessage.class))).thenReturn(1); + + fixture.service.sendAnnouncement("maintenance notice"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatMessage.class); + verify(fixture.messageMapper).insert(captor.capture()); + assertAll( + () -> assertEquals(ROOM_ID, captor.getValue().getRoomId()), + () -> assertEquals(0L, captor.getValue().getUserId()), + () -> assertEquals(3, captor.getValue().getMessageType()), + () -> assertEquals("maintenance notice", captor.getValue().getContent()), + () -> assertEquals(0, captor.getValue().getIsDeleted()) + ); + } + + @Test + void shouldRejectAnnouncementWhenInsertFails() { + Fixture fixture = new Fixture(); + fixture.prepareRoom(); + when(fixture.messageMapper.insert(any(ChatMessage.class))).thenReturn(0); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.sendAnnouncement("maintenance notice") + ); + + assertEquals("发送系统公告失败", exception.getMessage()); + } + + private static Stream invalidMessageRequests() { + return Stream.of( + Arguments.of("null request", null, "消息不能为空"), + Arguments.of("unsupported type", request(3, "hello", null), "不支持的消息类型"), + Arguments.of("missing text", request(1, null, null), "消息内容不能为空"), + Arguments.of("blank text", request(1, " ", null), "消息内容不能为空"), + Arguments.of("long text", request(1, "x".repeat(101), null), + "消息内容过长,最多100个字符"), + Arguments.of("missing image URL", request(2, null, null), "图片消息地址不能为空"), + Arguments.of("long image URL", + request(2, null, "https://example.com/" + "x".repeat(481)), + "图片地址过长"), + Arguments.of("invalid image scheme", request(2, null, "javascript:alert(1)"), + "图片地址格式不合法"), + Arguments.of("long image caption", + request(2, "x".repeat(101), "https://example.com/a.png"), + "图片说明过长,最多100个字符") + ); + } + + private static Stream invalidReplyMessages() { + ChatMessage otherRoom = replyMessage(ROOM_ID + 1, 0); + ChatMessage deleted = replyMessage(ROOM_ID, 1); + return Stream.of( + Arguments.of("reply does not exist", null), + Arguments.of("reply belongs to another room", otherRoom), + Arguments.of("reply has been deleted", deleted) + ); + } + + private static Stream emptyBatchIds() { + return Stream.of( + Arguments.of("null list", null), + Arguments.of("empty list", List.of()) + ); + } + + private static ChatMessageRequest textRequest(String content) { + return request(1, content, null); + } + + private static ChatMessageRequest imageRequest(String content, String imageUrl) { + return request(2, content, imageUrl); + } + + private static ChatMessageRequest request(Integer type, String content, String imageUrl) { + ChatMessageRequest request = new ChatMessageRequest(); + request.setMessageType(type); + request.setContent(content); + request.setImageUrl(imageUrl); + return request; + } + + private static ChatMessage persistedMessage(int type, String content, String imageUrl) { + ChatMessage message = new ChatMessage(); + message.setId(MESSAGE_ID); + message.setRoomId(ROOM_ID); + message.setUserId(USER_ID); + message.setMessageType(type); + message.setContent(content); + message.setImageUrl(imageUrl); + message.setIsDeleted(0); + return message; + } + + private static ChatMessage replyMessage(long roomId, int deleted) { + ChatMessage message = new ChatMessage(); + message.setId(REPLY_ID); + message.setRoomId(roomId); + message.setMessageType(1); + message.setContent("original message"); + message.setUserNickname("reply-user"); + message.setIsDeleted(deleted); + return message; + } + + private static ChatMessage historyMessage(long id, long ageMillis) { + ChatMessage message = persistedMessage(1, "message-" + id, null); + message.setId(id); + message.setCreateTime(new Date(System.currentTimeMillis() - ageMillis)); + return message; + } + + private static ChatMessage recallableMessage(long ownerId, long ageMillis) { + ChatMessage message = historyMessage(MESSAGE_ID, ageMillis); + message.setUserId(ownerId); + message.setIsDeleted(0); + return message; + } + + private static final class Fixture { + + private final ChatMessageMapper messageMapper = mock(ChatMessageMapper.class); + private final ChatRoomService roomService = mock(ChatRoomService.class); + private final ChatUserBanService banService = mock(ChatUserBanService.class); + private final ChatMessageServiceImpl service = + new ChatMessageServiceImpl(messageMapper, roomService, banService); + + private Fixture() { + ReflectionTestUtils.setField(service, "maxTextLength", 100); + ReflectionTestUtils.setField(service, "maxImageUrlLength", 500); + } + + private void prepareRoom() { + ChatRoom room = new ChatRoom(); + room.setId(ROOM_ID); + when(roomService.getOfficialRoom()).thenReturn(room); + } + + private void allowSending() { + prepareRoom(); + when(banService.isUserBanned(USER_ID, ROOM_ID)).thenReturn(false); + } + + private void persistAndReload(ChatMessage persisted) { + when(messageMapper.insert(any(ChatMessage.class))).thenAnswer(invocation -> { + ChatMessage message = invocation.getArgument(0); + message.setId(MESSAGE_ID); + return 1; + }); + when(messageMapper.selectById(MESSAGE_ID)).thenReturn(persisted); + } + } +} diff --git a/xiaou-common/src/main/java/com/xiaou/common/config/AiProperties.java b/xiaou-common/src/main/java/com/xiaou/common/config/AiProperties.java index 1885a94cd..07982cc81 100644 --- a/xiaou-common/src/main/java/com/xiaou/common/config/AiProperties.java +++ b/xiaou-common/src/main/java/com/xiaou/common/config/AiProperties.java @@ -83,6 +83,14 @@ public static class Model { */ private String chat = "gpt-4o-mini"; + /** + * 单次对话允许消耗的最大 completion token 数。 + * + *

对支持推理的模型,这个上限同时约束推理和最终输出, + * 防止复杂 Prompt 在没有边界时持续占用请求连接。

+ */ + private Integer maxCompletionTokens = 2048; + /** * 向量模型名称。 */ diff --git a/xiaou-community/src/main/java/com/xiaou/community/dto/UserBanRequest.java b/xiaou-community/src/main/java/com/xiaou/community/dto/UserBanRequest.java index ec3383a04..5d1d625c3 100644 --- a/xiaou-community/src/main/java/com/xiaou/community/dto/UserBanRequest.java +++ b/xiaou-community/src/main/java/com/xiaou/community/dto/UserBanRequest.java @@ -2,8 +2,11 @@ import lombok.Data; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; /** * 用户封禁请求 @@ -17,11 +20,14 @@ public class UserBanRequest { * 封禁原因 */ @NotBlank(message = "封禁原因不能为空") + @Size(max = 200, message = "封禁原因长度不能超过200字符") private String reason; /** * 封禁时长(小时) */ @NotNull(message = "封禁时长不能为空") + @Min(value = 1, message = "封禁时长必须在1-8760小时之间") + @Max(value = 8760, message = "封禁时长必须在1-8760小时之间") private Integer duration; -} \ No newline at end of file +} diff --git a/xiaou-community/src/test/java/com/xiaou/community/dto/UserBanRequestTest.java b/xiaou-community/src/test/java/com/xiaou/community/dto/UserBanRequestTest.java new file mode 100644 index 000000000..af8585c9d --- /dev/null +++ b/xiaou-community/src/test/java/com/xiaou/community/dto/UserBanRequestTest.java @@ -0,0 +1,36 @@ +package com.xiaou.community.dto; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class UserBanRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void shouldRejectInvalidBanDurationAndOverlongReason() { + UserBanRequest zeroDuration = request("灌水", 0); + assertTrue(hasViolation(zeroDuration, "duration")); + + UserBanRequest tooLongDuration = request("灌水", 8761); + assertTrue(hasViolation(tooLongDuration, "duration")); + + UserBanRequest overlongReason = request("a".repeat(201), 24); + assertTrue(hasViolation(overlongReason, "reason")); + } + + private boolean hasViolation(UserBanRequest request, String property) { + return validator.validate(request).stream() + .anyMatch(violation -> property.equals(violation.getPropertyPath().toString())); + } + + private UserBanRequest request(String reason, Integer duration) { + UserBanRequest request = new UserBanRequest(); + request.setReason(reason); + request.setDuration(duration); + return request; + } +} diff --git a/xiaou-filestorage/pom.xml b/xiaou-filestorage/pom.xml index 1fa434b32..b80c5cce3 100644 --- a/xiaou-filestorage/pom.xml +++ b/xiaou-filestorage/pom.xml @@ -61,6 +61,13 @@ tika-core 2.9.1 + + + + org.springframework.boot + spring-boot-starter-test + test + - \ No newline at end of file + diff --git a/xiaou-filestorage/src/main/java/com/xiaou/filestorage/service/FileSystemSettingService.java b/xiaou-filestorage/src/main/java/com/xiaou/filestorage/service/FileSystemSettingService.java index c922385a7..c2338c496 100644 --- a/xiaou-filestorage/src/main/java/com/xiaou/filestorage/service/FileSystemSettingService.java +++ b/xiaou-filestorage/src/main/java/com/xiaou/filestorage/service/FileSystemSettingService.java @@ -111,4 +111,11 @@ public interface FileSystemSettingService { * @return 是否自动备份 */ boolean isAutoBackupEnabled(); -} \ No newline at end of file + + /** + * 获取默认访问权限 + * + * @return 默认访问权限,public 或 private + */ + String getDefaultAccessLevel(); +} diff --git a/xiaou-filestorage/src/main/java/com/xiaou/filestorage/service/impl/FileStorageServiceImpl.java b/xiaou-filestorage/src/main/java/com/xiaou/filestorage/service/impl/FileStorageServiceImpl.java index d9c54ee06..2e0e30ff7 100644 --- a/xiaou-filestorage/src/main/java/com/xiaou/filestorage/service/impl/FileStorageServiceImpl.java +++ b/xiaou-filestorage/src/main/java/com/xiaou/filestorage/service/impl/FileStorageServiceImpl.java @@ -151,7 +151,7 @@ public FileUploadResult uploadSingle(MultipartFile file, String moduleName, Stri fileInfo.setUploadTime(new Date()); fileInfo.setAccessUrl(uploadResult.getAccessUrl()); fileInfo.setStatus(1); - fileInfo.setIsPublic(0); + fileInfo.setIsPublic("public".equalsIgnoreCase(fileSystemSettingService.getDefaultAccessLevel()) ? 1 : 0); fileInfo.setCreateTime(new Date()); fileInfo.setUpdateTime(new Date()); @@ -165,7 +165,9 @@ public FileUploadResult uploadSingle(MultipartFile file, String moduleName, Stri eventPublisher.publishUploadEvent(fileInfo.getId(), originalName, moduleName, businessType, fileSize); // 异步创建本地备份 - fileBackupService.createLocalBackupAsync(fileInfo); + if (fileSystemSettingService.isAutoBackupEnabled()) { + fileBackupService.createLocalBackupAsync(fileInfo); + } return uploadResult; } else { diff --git a/xiaou-filestorage/src/main/java/com/xiaou/filestorage/service/impl/FileSystemSettingServiceImpl.java b/xiaou-filestorage/src/main/java/com/xiaou/filestorage/service/impl/FileSystemSettingServiceImpl.java index f0c8303a0..476f5da15 100644 --- a/xiaou-filestorage/src/main/java/com/xiaou/filestorage/service/impl/FileSystemSettingServiceImpl.java +++ b/xiaou-filestorage/src/main/java/com/xiaou/filestorage/service/impl/FileSystemSettingServiceImpl.java @@ -222,6 +222,18 @@ public boolean isAutoBackupEnabled() { } } + @Override + public String getDefaultAccessLevel() { + try { + String accessLevel = getSettingValue("DEFAULT_ACCESS_LEVEL", + getSettingValue("defaultAccessLevel", "private")); + return "public".equalsIgnoreCase(accessLevel) ? "public" : "private"; + } catch (Exception e) { + log.error("获取默认访问权限失败: {}", e.getMessage(), e); + return "private"; + } + } + /** * 刷新缓存(如果需要) */ @@ -258,4 +270,4 @@ private void refreshCache() { private void clearCache() { this.lastCacheTime = 0; } -} \ No newline at end of file +} diff --git a/xiaou-filestorage/src/main/java/com/xiaou/filestorage/strategy/AbstractFileStorageStrategy.java b/xiaou-filestorage/src/main/java/com/xiaou/filestorage/strategy/AbstractFileStorageStrategy.java index 44bd8fb83..4d46822f3 100644 --- a/xiaou-filestorage/src/main/java/com/xiaou/filestorage/strategy/AbstractFileStorageStrategy.java +++ b/xiaou-filestorage/src/main/java/com/xiaou/filestorage/strategy/AbstractFileStorageStrategy.java @@ -29,12 +29,14 @@ public abstract class AbstractFileStorageStrategy implements FileStorageStrategy @Override public boolean initialize(Map configParams) { + this.initialized = false; try { this.configParams = configParams; boolean result = doInitialize(configParams); this.initialized = result; return result; } catch (Exception e) { + this.initialized = false; log.error("初始化存储策略失败: {}", e.getMessage(), e); return false; } @@ -136,4 +138,4 @@ protected String getConfigParam(String key, String defaultValue) { * @return 连接是否正常 */ protected abstract boolean doTestConnection(); -} \ No newline at end of file +} diff --git a/xiaou-filestorage/src/main/java/com/xiaou/filestorage/strategy/impl/LocalStorageStrategy.java b/xiaou-filestorage/src/main/java/com/xiaou/filestorage/strategy/impl/LocalStorageStrategy.java index 61edc3221..9851b940e 100644 --- a/xiaou-filestorage/src/main/java/com/xiaou/filestorage/strategy/impl/LocalStorageStrategy.java +++ b/xiaou-filestorage/src/main/java/com/xiaou/filestorage/strategy/impl/LocalStorageStrategy.java @@ -1,6 +1,5 @@ package com.xiaou.filestorage.strategy.impl; -import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.StrUtil; import com.xiaou.filestorage.dto.FileUploadResult; import com.xiaou.filestorage.strategy.AbstractFileStorageStrategy; @@ -8,9 +7,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; -import java.io.File; import java.io.FileInputStream; -import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; @@ -27,8 +24,8 @@ @Component public class LocalStorageStrategy extends AbstractFileStorageStrategy { - private String basePath; private String urlPrefix; + private Path baseDirectory; @Override public String getStorageType() { @@ -37,15 +34,14 @@ public String getStorageType() { @Override protected boolean doInitialize(Map configParams) { - this.basePath = getConfigParam("basePath", "/uploads"); + String configuredBasePath = getConfigParam("basePath", "/uploads"); this.urlPrefix = getConfigParam("urlPrefix", "http://localhost:9999/files"); // 创建基础目录 try { - Path baseDir = Paths.get(basePath); - if (!Files.exists(baseDir)) { - Files.createDirectories(baseDir); - } + Path baseDir = Paths.get(configuredBasePath).toAbsolutePath().normalize(); + Files.createDirectories(baseDir); + this.baseDirectory = baseDir.toRealPath(); return true; } catch (Exception e) { log.error("创建本地存储目录失败: {}", e.getMessage(), e); @@ -56,8 +52,9 @@ protected boolean doInitialize(Map configParams) { @Override protected boolean doTestConnection() { try { - Path baseDir = Paths.get(basePath); - return Files.exists(baseDir) && Files.isWritable(baseDir); + return baseDirectory != null + && Files.exists(baseDirectory) + && Files.isWritable(baseDirectory); } catch (Exception e) { log.error("测试本地存储连接失败: {}", e.getMessage(), e); return false; @@ -71,7 +68,7 @@ public FileUploadResult uploadFile(MultipartFile file, String storagePath) { storagePath = generateStoragePath(file.getOriginalFilename(), "default", "upload"); } - Path targetPath = Paths.get(basePath, storagePath); + Path targetPath = resolveStoragePath(storagePath); // 创建目录 Files.createDirectories(targetPath.getParent()); @@ -80,11 +77,15 @@ public FileUploadResult uploadFile(MultipartFile file, String storagePath) { file.transferTo(targetPath.toFile()); // 生成访问URL - String accessUrl = urlPrefix + "/" + storagePath.replace("\\", "/"); + String normalizedStoragePath = toStoragePath(targetPath); + String accessUrl = urlPrefix + "/" + normalizedStoragePath; long fileSize = Files.size(targetPath); - return FileUploadResult.success(storagePath, accessUrl, fileSize); + return FileUploadResult.success(normalizedStoragePath, accessUrl, fileSize); + } catch (IllegalArgumentException e) { + log.warn("本地上传文件路径被拒绝: {}", e.getMessage()); + return FileUploadResult.failure("上传文件失败: " + e.getMessage()); } catch (Exception e) { log.error("本地上传文件失败: {}", e.getMessage(), e); return FileUploadResult.failure("上传文件失败: " + e.getMessage()); @@ -98,7 +99,7 @@ public FileUploadResult uploadFile(InputStream inputStream, String fileName, Str storagePath = generateStoragePath(fileName, "default", "upload"); } - Path targetPath = Paths.get(basePath, storagePath); + Path targetPath = resolveStoragePath(storagePath); // 创建目录 Files.createDirectories(targetPath.getParent()); @@ -107,13 +108,17 @@ public FileUploadResult uploadFile(InputStream inputStream, String fileName, Str Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING); // 生成访问URL - String accessUrl = urlPrefix + "/" + storagePath.replace("\\", "/"); + String normalizedStoragePath = toStoragePath(targetPath); + String accessUrl = urlPrefix + "/" + normalizedStoragePath; // 获取文件大小 long fileSize = Files.size(targetPath); - return FileUploadResult.success(storagePath, accessUrl, fileSize); + return FileUploadResult.success(normalizedStoragePath, accessUrl, fileSize); + } catch (IllegalArgumentException e) { + log.warn("本地上传文件流路径被拒绝: {}", e.getMessage()); + return FileUploadResult.failure("上传文件流失败: " + e.getMessage()); } catch (Exception e) { log.error("本地上传文件流失败: {}", e.getMessage(), e); return FileUploadResult.failure("上传文件流失败: " + e.getMessage()); @@ -123,11 +128,14 @@ public FileUploadResult uploadFile(InputStream inputStream, String fileName, Str @Override public InputStream downloadFile(String storagePath) { try { - Path filePath = Paths.get(basePath, storagePath); + Path filePath = resolveStoragePath(storagePath); if (!Files.exists(filePath)) { return null; } return new FileInputStream(filePath.toFile()); + } catch (IllegalArgumentException e) { + log.warn("本地下载文件路径被拒绝: {}", e.getMessage()); + return null; } catch (Exception e) { log.error("本地下载文件失败: {}", e.getMessage(), e); return null; @@ -137,8 +145,11 @@ public InputStream downloadFile(String storagePath) { @Override public boolean deleteFile(String storagePath) { try { - Path filePath = Paths.get(basePath, storagePath); + Path filePath = resolveStoragePath(storagePath); return Files.deleteIfExists(filePath); + } catch (IllegalArgumentException e) { + log.warn("本地删除文件路径被拒绝: {}", e.getMessage()); + return false; } catch (Exception e) { log.error("本地删除文件失败: {}", e.getMessage(), e); return false; @@ -148,8 +159,11 @@ public boolean deleteFile(String storagePath) { @Override public boolean existsFile(String storagePath) { try { - Path filePath = Paths.get(basePath, storagePath); + Path filePath = resolveStoragePath(storagePath); return Files.exists(filePath); + } catch (IllegalArgumentException e) { + log.warn("本地检查文件路径被拒绝: {}", e.getMessage()); + return false; } catch (Exception e) { log.error("本地检查文件存在性失败: {}", e.getMessage(), e); return false; @@ -158,15 +172,20 @@ public boolean existsFile(String storagePath) { @Override public String getFileUrl(String storagePath, Integer expireHours) { - // 本地存储不需要临时URL,直接返回永久URL - return urlPrefix + "/" + storagePath.replace("\\", "/"); + try { + // 本地存储不需要临时URL,直接返回永久URL + return urlPrefix + "/" + toStoragePath(resolveStoragePath(storagePath)); + } catch (Exception e) { + log.warn("本地生成文件URL失败: {}", e.getMessage()); + return null; + } } @Override public boolean copyFile(String sourceStoragePath, String targetStoragePath) { try { - Path sourcePath = Paths.get(basePath, sourceStoragePath); - Path targetPath = Paths.get(basePath, targetStoragePath); + Path sourcePath = resolveStoragePath(sourceStoragePath); + Path targetPath = resolveStoragePath(targetStoragePath); if (!Files.exists(sourcePath)) { return false; @@ -179,6 +198,9 @@ public boolean copyFile(String sourceStoragePath, String targetStoragePath) { Files.copy(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING); return true; + } catch (IllegalArgumentException e) { + log.warn("本地复制文件路径被拒绝: {}", e.getMessage()); + return false; } catch (Exception e) { log.error("本地复制文件失败: {}", e.getMessage(), e); return false; @@ -188,14 +210,49 @@ public boolean copyFile(String sourceStoragePath, String targetStoragePath) { @Override public Long getFileSize(String storagePath) { try { - Path filePath = Paths.get(basePath, storagePath); + Path filePath = resolveStoragePath(storagePath); if (!Files.exists(filePath)) { return null; } return Files.size(filePath); + } catch (IllegalArgumentException e) { + log.warn("本地获取文件大小路径被拒绝: {}", e.getMessage()); + return null; } catch (Exception e) { log.error("本地获取文件大小失败: {}", e.getMessage(), e); return null; } } + + private Path resolveStoragePath(String storagePath) { + if (StrUtil.isBlank(storagePath)) { + throw new IllegalArgumentException("存储路径不能为空"); + } + + Path requestedPath = Paths.get(storagePath); + if (requestedPath.isAbsolute()) { + throw new IllegalArgumentException("存储路径必须是相对路径"); + } + + Path resolvedPath = baseDirectory.resolve(requestedPath).normalize(); + if (!resolvedPath.startsWith(baseDirectory)) { + throw new IllegalArgumentException("存储路径超出基础目录"); + } + if (resolvedPath.equals(baseDirectory)) { + throw new IllegalArgumentException("存储路径不能指向基础目录"); + } + + Path currentPath = baseDirectory; + for (Path pathPart : baseDirectory.relativize(resolvedPath)) { + currentPath = currentPath.resolve(pathPart); + if (Files.isSymbolicLink(currentPath)) { + throw new IllegalArgumentException("存储路径不能包含符号链接"); + } + } + return resolvedPath; + } + + private String toStoragePath(Path resolvedPath) { + return baseDirectory.relativize(resolvedPath).toString().replace("\\", "/"); + } } diff --git a/xiaou-filestorage/src/test/java/com/xiaou/filestorage/service/impl/FileStorageServiceImplTest.java b/xiaou-filestorage/src/test/java/com/xiaou/filestorage/service/impl/FileStorageServiceImplTest.java new file mode 100644 index 000000000..02d3834a1 --- /dev/null +++ b/xiaou-filestorage/src/test/java/com/xiaou/filestorage/service/impl/FileStorageServiceImplTest.java @@ -0,0 +1,153 @@ +package com.xiaou.filestorage.service.impl; + +import com.xiaou.filestorage.domain.FileInfo; +import com.xiaou.filestorage.domain.FileStorage; +import com.xiaou.filestorage.domain.StorageConfig; +import com.xiaou.filestorage.dto.FileUploadResult; +import com.xiaou.filestorage.event.FileOperationEventPublisher; +import com.xiaou.filestorage.factory.StorageStrategyFactory; +import com.xiaou.filestorage.mapper.FileInfoMapper; +import com.xiaou.filestorage.mapper.FileStorageMapper; +import com.xiaou.filestorage.mapper.StorageConfigMapper; +import com.xiaou.filestorage.service.FileBackupService; +import com.xiaou.filestorage.service.FileSystemSettingService; +import com.xiaou.filestorage.service.StorageHealthService; +import com.xiaou.filestorage.strategy.FileStorageStrategy; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FileStorageServiceImplTest { + + private static final byte[] FILE_BYTES = "hello backup".getBytes(StandardCharsets.UTF_8); + + @Mock + private FileInfoMapper fileInfoMapper; + + @Mock + private FileStorageMapper fileStorageMapper; + + @Mock + private StorageConfigMapper storageConfigMapper; + + @Mock + private StorageStrategyFactory strategyFactory; + + @Mock + private FileOperationEventPublisher eventPublisher; + + @Mock + private FileSystemSettingService fileSystemSettingService; + + @Mock + private FileBackupService fileBackupService; + + @Mock + private StorageHealthService storageHealthService; + + @Mock + private MultipartFile file; + + @Mock + private FileStorageStrategy storageStrategy; + + @InjectMocks + private FileStorageServiceImpl service; + + @BeforeEach + void setUp() throws Exception { + when(file.isEmpty()).thenReturn(false); + when(file.getOriginalFilename()).thenReturn("avatar.txt"); + when(file.getSize()).thenReturn((long) FILE_BYTES.length); + when(file.getContentType()).thenReturn("text/plain"); + when(file.getInputStream()).thenAnswer(invocation -> new ByteArrayInputStream(FILE_BYTES)); + + when(fileSystemSettingService.isFileTypeAllowed("txt")).thenReturn(true); + when(fileSystemSettingService.isFileSizeExceeded((long) FILE_BYTES.length)).thenReturn(false); + + StorageConfig config = new StorageConfig(); + config.setId(1L); + config.setStorageType("LOCAL"); + config.setConfigName("本地默认存储"); + config.setConfigParams("{}"); + config.setIsEnabled(1); + config.setIsDefault(1); + when(storageConfigMapper.selectDefault()).thenReturn(config); + + when(strategyFactory.createAndInitialize(eq(1L), eq("LOCAL"), anyMap())).thenReturn(storageStrategy); + when(storageStrategy.uploadFile(eq(file), isNull())) + .thenReturn(FileUploadResult.success("stored/avatar.txt", "/files/stored/avatar.txt", (long) FILE_BYTES.length)); + + when(fileInfoMapper.selectByMd5(any())).thenReturn(null); + when(fileInfoMapper.insert(any(FileInfo.class))).thenAnswer(invocation -> { + FileInfo fileInfo = invocation.getArgument(0); + fileInfo.setId(42L); + return 1; + }); + when(fileStorageMapper.insert(any(FileStorage.class))).thenReturn(1); + } + + @Test + void uploadSingleShouldSkipLocalBackupWhenAutoBackupDisabled() { + lenient().when(fileSystemSettingService.isAutoBackupEnabled()).thenReturn(false); + + FileUploadResult result = service.uploadSingle(file, "profile", "avatar"); + + assertTrue(result.isSuccess()); + verify(fileBackupService, never()).createLocalBackupAsync(any(FileInfo.class)); + } + + @Test + void uploadSingleShouldCreateLocalBackupWhenAutoBackupEnabled() { + lenient().when(fileSystemSettingService.isAutoBackupEnabled()).thenReturn(true); + + FileUploadResult result = service.uploadSingle(file, "profile", "avatar"); + + assertTrue(result.isSuccess()); + verify(fileBackupService).createLocalBackupAsync(any(FileInfo.class)); + } + + @Test + void uploadSingleShouldUsePublicDefaultAccessLevelWhenConfigured() { + lenient().when(fileSystemSettingService.getDefaultAccessLevel()).thenReturn("public"); + + FileUploadResult result = service.uploadSingle(file, "profile", "avatar"); + + ArgumentCaptor fileInfoCaptor = ArgumentCaptor.forClass(FileInfo.class); + assertTrue(result.isSuccess()); + verify(fileInfoMapper).insert(fileInfoCaptor.capture()); + assertEquals(1, fileInfoCaptor.getValue().getIsPublic()); + } + + @Test + void uploadSingleShouldUsePrivateDefaultAccessLevelWhenConfigured() { + lenient().when(fileSystemSettingService.getDefaultAccessLevel()).thenReturn("private"); + + FileUploadResult result = service.uploadSingle(file, "profile", "avatar"); + + ArgumentCaptor fileInfoCaptor = ArgumentCaptor.forClass(FileInfo.class); + assertTrue(result.isSuccess()); + verify(fileInfoMapper).insert(fileInfoCaptor.capture()); + assertEquals(0, fileInfoCaptor.getValue().getIsPublic()); + } +} diff --git a/xiaou-filestorage/src/test/java/com/xiaou/filestorage/strategy/AbstractFileStorageStrategyTest.java b/xiaou-filestorage/src/test/java/com/xiaou/filestorage/strategy/AbstractFileStorageStrategyTest.java new file mode 100644 index 000000000..1b00af847 --- /dev/null +++ b/xiaou-filestorage/src/test/java/com/xiaou/filestorage/strategy/AbstractFileStorageStrategyTest.java @@ -0,0 +1,96 @@ +package com.xiaou.filestorage.strategy; + +import com.xiaou.filestorage.dto.FileUploadResult; +import org.junit.jupiter.api.Test; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AbstractFileStorageStrategyTest { + + @Test + void shouldInvalidateInitializedStateWhenReinitializationThrows() { + ProbeStorageStrategy strategy = new ProbeStorageStrategy(); + + assertTrue(strategy.initialize(Map.of("endpoint", "first"))); + assertTrue(strategy.testConnection()); + assertEquals(1, strategy.connectionChecks); + + strategy.throwOnInitialize = true; + + assertFalse(strategy.initialize(Map.of("endpoint", "broken"))); + assertFalse(strategy.testConnection()); + assertEquals(1, strategy.connectionChecks); + } + + private static final class ProbeStorageStrategy extends AbstractFileStorageStrategy { + + private boolean throwOnInitialize; + private int connectionChecks; + + @Override + public String getStorageType() { + return "PROBE"; + } + + @Override + protected boolean doInitialize(Map configParams) { + if (throwOnInitialize) { + throw new IllegalStateException("invalid configuration"); + } + return true; + } + + @Override + protected boolean doTestConnection() { + connectionChecks++; + return true; + } + + @Override + public FileUploadResult uploadFile(MultipartFile file, String storagePath) { + return null; + } + + @Override + public FileUploadResult uploadFile(InputStream inputStream, String fileName, + String storagePath, String contentType) { + return null; + } + + @Override + public InputStream downloadFile(String storagePath) { + return null; + } + + @Override + public boolean deleteFile(String storagePath) { + return false; + } + + @Override + public boolean existsFile(String storagePath) { + return false; + } + + @Override + public String getFileUrl(String storagePath, Integer expireHours) { + return null; + } + + @Override + public boolean copyFile(String sourceStoragePath, String targetStoragePath) { + return false; + } + + @Override + public Long getFileSize(String storagePath) { + return null; + } + } +} diff --git a/xiaou-filestorage/src/test/java/com/xiaou/filestorage/strategy/impl/LocalStorageStrategyTest.java b/xiaou-filestorage/src/test/java/com/xiaou/filestorage/strategy/impl/LocalStorageStrategyTest.java new file mode 100644 index 000000000..93ea7a8e6 --- /dev/null +++ b/xiaou-filestorage/src/test/java/com/xiaou/filestorage/strategy/impl/LocalStorageStrategyTest.java @@ -0,0 +1,146 @@ +package com.xiaou.filestorage.strategy.impl; + +import com.xiaou.filestorage.dto.FileUploadResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LocalStorageStrategyTest { + + private static final byte[] FILE_BYTES = "file-content".getBytes(StandardCharsets.UTF_8); + + @TempDir + Path tempDir; + + private LocalStorageStrategy strategy; + private Path baseDir; + + @BeforeEach + void setUp() { + baseDir = tempDir.resolve("base"); + strategy = new LocalStorageStrategy(); + + assertTrue(strategy.initialize(Map.of( + "basePath", baseDir.toString(), + "urlPrefix", "http://localhost/files" + ))); + } + + @Test + void shouldKeepNormalFilesInsideBaseDirectory() throws Exception { + FileUploadResult uploadResult = strategy.uploadFile( + new ByteArrayInputStream(FILE_BYTES), + "note.txt", + "documents/note.txt", + "text/plain" + ); + + assertTrue(uploadResult.isSuccess()); + assertTrue(Files.exists(baseDir.resolve("documents/note.txt"))); + assertTrue(strategy.existsFile("documents/note.txt")); + assertEquals("http://localhost/files/documents/note.txt", + strategy.getFileUrl("documents/note.txt", null)); + assertTrue(strategy.copyFile("documents/note.txt", "archive/note.txt")); + assertTrue(strategy.existsFile("archive/note.txt")); + assertEquals((long) FILE_BYTES.length, strategy.getFileSize("archive/note.txt")); + + try (InputStream inputStream = strategy.downloadFile("archive/note.txt")) { + assertNotNull(inputStream); + assertArrayEquals(FILE_BYTES, inputStream.readAllBytes()); + } + + assertTrue(strategy.deleteFile("archive/note.txt")); + assertFalse(strategy.existsFile("archive/note.txt")); + } + + @Test + void shouldRejectTraversalForUploadAndLeaveOutsideFileUntouched() throws Exception { + Path outsideFile = tempDir.resolve("outside.txt"); + + FileUploadResult uploadResult = strategy.uploadFile( + new MockMultipartFile("file", "note.txt", "text/plain", FILE_BYTES), + "../outside.txt" + ); + + assertFalse(uploadResult.isSuccess()); + assertFalse(Files.exists(outsideFile)); + } + + @Test + void shouldRejectAbsolutePathForStreamUpload() { + Path outsideFile = tempDir.resolve("absolute.txt"); + + FileUploadResult uploadResult = strategy.uploadFile( + new ByteArrayInputStream(FILE_BYTES), + "note.txt", + outsideFile.toString(), + "text/plain" + ); + + assertFalse(uploadResult.isSuccess()); + assertFalse(Files.exists(outsideFile)); + } + + @Test + void shouldRejectSymbolicLinkPaths() throws Exception { + Path outsideDirectory = tempDir.resolve("outside-directory"); + Files.createDirectories(outsideDirectory); + Path linkedDirectory = baseDir.resolve("linked-directory"); + + try { + Files.createSymbolicLink(linkedDirectory, outsideDirectory); + } catch (IOException | UnsupportedOperationException | SecurityException exception) { + Assumptions.abort("当前文件系统不支持创建符号链接"); + } + + FileUploadResult uploadResult = strategy.uploadFile( + new ByteArrayInputStream(FILE_BYTES), + "note.txt", + "linked-directory/note.txt", + "text/plain" + ); + + assertFalse(uploadResult.isSuccess()); + assertFalse(Files.exists(outsideDirectory.resolve("note.txt"))); + assertFalse(strategy.deleteFile("linked-directory/note.txt")); + assertFalse(strategy.existsFile("linked-directory/note.txt")); + } + + @Test + void shouldRejectUnsafePathsAcrossReadDeleteCopySizeAndUrlOperations() throws Exception { + Path outsideFile = tempDir.resolve("outside.txt"); + Files.write(outsideFile, FILE_BYTES); + Files.write(baseDir.resolve("inside.txt"), FILE_BYTES); + + try (InputStream inputStream = strategy.downloadFile("../outside.txt")) { + assertNull(inputStream); + } + assertFalse(strategy.deleteFile("../outside.txt")); + assertFalse(strategy.existsFile("../outside.txt")); + assertNull(strategy.getFileSize("../outside.txt")); + assertNull(strategy.getFileUrl("../outside.txt", null)); + assertFalse(strategy.deleteFile(".")); + assertTrue(Files.exists(baseDir)); + assertFalse(strategy.copyFile("../outside.txt", "copied.txt")); + assertFalse(strategy.copyFile("inside.txt", "../copied.txt")); + assertTrue(Files.exists(outsideFile)); + assertFalse(Files.exists(tempDir.resolve("copied.txt"))); + } +} diff --git a/xiaou-moyu/pom.xml b/xiaou-moyu/pom.xml index 8c2e98c42..21029f2a2 100644 --- a/xiaou-moyu/pom.xml +++ b/xiaou-moyu/pom.xml @@ -26,6 +26,13 @@ xiaou-common ${revision} + + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/xiaou-moyu/src/main/java/com/xiaou/moyu/controller/admin/AdminDeveloperCalendarController.java b/xiaou-moyu/src/main/java/com/xiaou/moyu/controller/admin/AdminDeveloperCalendarController.java index 340bffc0d..21d37cee2 100644 --- a/xiaou-moyu/src/main/java/com/xiaou/moyu/controller/admin/AdminDeveloperCalendarController.java +++ b/xiaou-moyu/src/main/java/com/xiaou/moyu/controller/admin/AdminDeveloperCalendarController.java @@ -14,7 +14,6 @@ import org.springframework.web.bind.annotation.*; import jakarta.validation.Valid; -import java.time.format.DateTimeFormatter; import java.util.List; /** @@ -156,10 +155,7 @@ public Result getEventStatistics() { private DeveloperCalendarEvent convertToEntity(AdminCalendarEventRequest request) { DeveloperCalendarEvent event = new DeveloperCalendarEvent(); event.setId(request.getId()); - // 将LocalDate转换为MM-dd格式的String - if (request.getEventDate() != null) { - event.setEventDate(request.getEventDate().format(DateTimeFormatter.ofPattern("MM-dd"))); - } + event.setEventDate(request.getEventDate()); event.setEventName(request.getEventName()); event.setEventType(request.getEventType()); event.setDescription(request.getDescription()); diff --git a/xiaou-moyu/src/main/java/com/xiaou/moyu/dto/AdminCalendarEventRequest.java b/xiaou-moyu/src/main/java/com/xiaou/moyu/dto/AdminCalendarEventRequest.java index 2d8db9a24..2c068397f 100644 --- a/xiaou-moyu/src/main/java/com/xiaou/moyu/dto/AdminCalendarEventRequest.java +++ b/xiaou-moyu/src/main/java/com/xiaou/moyu/dto/AdminCalendarEventRequest.java @@ -5,8 +5,8 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; -import java.time.LocalDate; import java.util.List; /** @@ -25,8 +25,9 @@ public class AdminCalendarEventRequest { /** * 事件日期 */ - @NotNull(message = "事件日期不能为空") - private LocalDate eventDate; + @NotBlank(message = "事件日期不能为空") + @Pattern(regexp = "^(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$", message = "事件日期格式应为MM-dd") + private String eventDate; /** * 事件名称 diff --git a/xiaou-moyu/src/test/java/com/xiaou/moyu/dto/AdminCalendarEventRequestTest.java b/xiaou-moyu/src/test/java/com/xiaou/moyu/dto/AdminCalendarEventRequestTest.java new file mode 100644 index 000000000..02d0867dc --- /dev/null +++ b/xiaou-moyu/src/test/java/com/xiaou/moyu/dto/AdminCalendarEventRequestTest.java @@ -0,0 +1,35 @@ +package com.xiaou.moyu.dto; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AdminCalendarEventRequestTest { + + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void shouldAcceptMonthDayEventDateFromAdminForm() throws Exception { + String json = """ + { + "eventName": "程序员节", + "eventDate": "10-24", + "eventType": 1, + "description": "纪念程序员", + "isMajor": 1, + "sortOrder": 8, + "status": 1 + } + """; + + AdminCalendarEventRequest request = objectMapper.readValue(json, AdminCalendarEventRequest.class); + + assertEquals("10-24", request.getEventDate()); + assertTrue(validator.validate(request).isEmpty()); + } +} diff --git a/xiaou-oj/src/main/java/com/xiaou/oj/judge/JudgeService.java b/xiaou-oj/src/main/java/com/xiaou/oj/judge/JudgeService.java index 13ffb87da..c4701080a 100644 --- a/xiaou-oj/src/main/java/com/xiaou/oj/judge/JudgeService.java +++ b/xiaou-oj/src/main/java/com/xiaou/oj/judge/JudgeService.java @@ -1,5 +1,6 @@ package com.xiaou.oj.judge; +import cn.hutool.core.util.StrUtil; import com.xiaou.oj.domain.OjProblem; import com.xiaou.oj.domain.OjSubmission; import com.xiaou.oj.domain.OjTestCase; @@ -91,7 +92,7 @@ public void judge(Long submissionId) { null, strategy.getCompiledFileNames()); if (!compileResult.isAccepted() || compileResult.getExitStatus() != 0) { - String errorMsg = compileResult.getStderr() != null ? compileResult.getStderr() : compileResult.getError(); + String errorMsg = getErrorMessage(compileResult); log.info("[Judge] 编译失败: submissionId={}, error={}", submissionId, errorMsg); updateSubmission(submission, SubmissionStatus.COMPILE_ERROR, 0, 0, 0, testCases.size(), errorMsg); return; @@ -133,7 +134,7 @@ public void judge(Long submissionId) { } if (!runResult.isAccepted() || runResult.getExitStatus() != 0) { - String errorMsg = runResult.getStderr() != null ? runResult.getStderr() : runResult.getError(); + String errorMsg = getErrorMessage(runResult); log.info("[Judge] 运行错误: submissionId={}, case={}, error={}", submissionId, i + 1, errorMsg); updateSubmission(submission, SubmissionStatus.RUNTIME_ERROR, maxTime, maxMemory, passCount, testCases.size(), errorMsg); @@ -158,18 +159,7 @@ public void judge(Long submissionId) { boolean firstAc = !submissionMapper.existsAccepted(submission.getUserId(), submission.getProblemId()); updateSubmission(submission, SubmissionStatus.ACCEPTED, maxTime, maxMemory, passCount, testCases.size(), null); if (firstAc) { - problemMapper.increaseAcceptedCount(submission.getProblemId()); - // 发放积分奖励 - try { - int points = getAcPoints(problem.getDifficulty()); - pointsService.grantSystemPoints(submission.getUserId(), points, - PointsType.OJ_AC.getCode(), - "首次通过题目「" + problem.getTitle() + "」"); - log.info("[Judge] 发放积分: userId={}, points={}, problem={}", - submission.getUserId(), points, problem.getTitle()); - } catch (Exception e) { - log.error("[Judge] 发放积分失败: submissionId={}", submissionId, e); - } + handleFirstAccepted(submission, problem); } } finally { // 清理 go-judge 缓存文件 @@ -206,13 +196,36 @@ private int getAcPoints(String difficulty) { return 100; // easy 或其他 } + private void handleFirstAccepted(OjSubmission submission, OjProblem problem) { + try { + problemMapper.increaseAcceptedCount(submission.getProblemId()); + } catch (Exception e) { + log.error("[Judge] 更新题目AC数失败: submissionId={}", submission.getId(), e); + } + + try { + int points = getAcPoints(problem.getDifficulty()); + pointsService.grantSystemPoints(submission.getUserId(), points, + PointsType.OJ_AC.getCode(), + "首次通过题目「" + problem.getTitle() + "」"); + log.info("[Judge] 发放积分: userId={}, points={}, problem={}", + submission.getUserId(), points, problem.getTitle()); + } catch (Exception e) { + log.error("[Judge] 发放积分失败: submissionId={}", submission.getId(), e); + } + } + /** * 比较用户输出与期望输出 (忽略末尾空白) */ private boolean compareOutput(String actual, String expected) { if (actual == null) actual = ""; if (expected == null) expected = ""; - return actual.strip().equals(expected.strip()); + return actual.stripTrailing().equals(expected.stripTrailing()); + } + + private String getErrorMessage(ExecuteResult result) { + return StrUtil.isNotBlank(result.getStderr()) ? result.getStderr() : result.getError(); } private void updateSubmission(OjSubmission submission, SubmissionStatus status, diff --git a/xiaou-oj/src/test/java/com/xiaou/oj/judge/JudgeServiceTest.java b/xiaou-oj/src/test/java/com/xiaou/oj/judge/JudgeServiceTest.java new file mode 100644 index 000000000..404a1e359 --- /dev/null +++ b/xiaou-oj/src/test/java/com/xiaou/oj/judge/JudgeServiceTest.java @@ -0,0 +1,402 @@ +package com.xiaou.oj.judge; + +import com.xiaou.oj.domain.OjProblem; +import com.xiaou.oj.domain.OjSubmission; +import com.xiaou.oj.domain.OjTestCase; +import com.xiaou.oj.enums.JudgeLanguage; +import com.xiaou.oj.enums.SubmissionStatus; +import com.xiaou.oj.judge.config.OjJudgeProperties; +import com.xiaou.oj.judge.sandbox.GoJudgeClient; +import com.xiaou.oj.judge.sandbox.GoJudgeClient.ExecuteResult; +import com.xiaou.oj.judge.strategy.JudgeStrategy; +import com.xiaou.oj.mapper.OjProblemMapper; +import com.xiaou.oj.mapper.OjSubmissionMapper; +import com.xiaou.oj.mapper.OjTestCaseMapper; +import com.xiaou.points.enums.PointsType; +import com.xiaou.points.service.PointsService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JudgeServiceTest { + + @Mock + private GoJudgeClient goJudgeClient; + + @Mock + private OjSubmissionMapper submissionMapper; + + @Mock + private OjProblemMapper problemMapper; + + @Mock + private OjTestCaseMapper testCaseMapper; + + @Mock + private JudgeStrategy strategy; + + @Mock + private PointsService pointsService; + + private final OjJudgeProperties properties = new OjJudgeProperties(); + private JudgeService service; + + @BeforeEach + void setUp() { + service = new JudgeService( + goJudgeClient, + properties, + submissionMapper, + problemMapper, + testCaseMapper, + List.of(strategy), + pointsService + ); + + } + + @Test + void compileFailureShouldPreserveSandboxErrorWhenStderrIsBlank() { + OjSubmission submission = submission(); + prepareSubmission(submission); + when(strategy.getLanguage()).thenReturn(JudgeLanguage.JAVA); + when(strategy.getSourceFileName()).thenReturn("Main.java"); + when(strategy.getCompileArgs()).thenReturn(List.of("/usr/bin/javac", "Main.java")); + when(strategy.getCompiledFileNames()).thenReturn(List.of("Main.class")); + + ExecuteResult compileResult = new ExecuteResult(); + compileResult.setStatus("System Error"); + compileResult.setExitStatus(1); + compileResult.setStderr(""); + compileResult.setError("compiler service unavailable"); + doReturn(compileResult).when(goJudgeClient).run( + anyList(), anyMap(), isNull(), anyLong(), anyLong(), isNull(), anyList()); + + service.judge(submission.getId()); + + assertEquals(SubmissionStatus.COMPILE_ERROR.getValue(), submission.getStatus()); + assertEquals("compiler service unavailable", submission.getErrorMessage()); + assertEquals(1, submission.getTotalCount()); + verify(goJudgeClient, never()).deleteFile("compiled-file-id"); + } + + @Test + void runtimeFailureShouldPreserveSandboxErrorWhenStderrIsBlank() { + OjSubmission submission = submission().setLanguage("python"); + prepareSubmission(submission); + when(strategy.getLanguage()).thenReturn(JudgeLanguage.PYTHON); + when(strategy.getSourceFileName()).thenReturn("main.py"); + when(strategy.getCompileArgs()).thenReturn(null); + when(strategy.getRunArgs()).thenReturn(List.of("/usr/bin/python3", "main.py")); + + ExecuteResult runResult = new ExecuteResult(); + runResult.setStatus("Runtime Error"); + runResult.setExitStatus(1); + runResult.setStderr(""); + runResult.setError("sandbox process failed"); + doReturn(runResult).when(goJudgeClient).run( + anyList(), anyMap(), isNull(), anyString(), anyLong(), anyLong(), isNull(), isNull()); + + service.judge(submission.getId()); + + assertEquals(SubmissionStatus.RUNTIME_ERROR.getValue(), submission.getStatus()); + assertEquals("sandbox process failed", submission.getErrorMessage()); + assertEquals(0, submission.getPassCount()); + } + + @Test + void acceptedSubmissionShouldUpdateCountersAndCleanCompiledFiles() { + OjSubmission submission = submission(); + prepareSubmission(submission); + when(strategy.getLanguage()).thenReturn(JudgeLanguage.JAVA); + when(strategy.getSourceFileName()).thenReturn("Main.java"); + when(strategy.getCompileArgs()).thenReturn(List.of("/usr/bin/javac", "Main.java")); + when(strategy.getCompiledFileNames()).thenReturn(List.of("Main.class")); + when(strategy.getRunArgs()).thenReturn(List.of("/usr/bin/java", "Main")); + when(submissionMapper.existsAccepted(submission.getUserId(), submission.getProblemId())).thenReturn(true); + + ExecuteResult compileResult = new ExecuteResult(); + compileResult.setStatus("Accepted"); + compileResult.setExitStatus(0); + compileResult.setFileIds(Map.of("Main.class", "compiled-file-id")); + + ExecuteResult runResult = new ExecuteResult(); + runResult.setStatus("Accepted"); + runResult.setExitStatus(0); + runResult.setStdout("ok\n"); + runResult.setTimeUsed(12); + runResult.setMemoryUsed(34); + doReturn(compileResult).when(goJudgeClient).run( + anyList(), anyMap(), isNull(), anyLong(), anyLong(), isNull(), anyList()); + doReturn(runResult).when(goJudgeClient).run( + anyList(), isNull(), anyMap(), anyString(), anyLong(), anyLong(), isNull(), isNull()); + + service.judge(submission.getId()); + + assertEquals(SubmissionStatus.ACCEPTED.getValue(), submission.getStatus()); + assertEquals(1, submission.getPassCount()); + assertEquals(1, submission.getTotalCount()); + assertEquals(12L, submission.getTimeUsed()); + assertEquals(34L, submission.getMemoryUsed()); + verify(goJudgeClient).deleteFile("compiled-file-id"); + verify(problemMapper, never()).increaseAcceptedCount(submission.getProblemId()); + verify(pointsService, never()).grantSystemPoints(org.mockito.ArgumentMatchers.anyLong(), + anyInt(), anyInt(), + org.mockito.ArgumentMatchers.anyString()); + verify(submissionMapper, times(2)).updateById(submission); + } + + @Test + void timeLimitExceededShouldStopRemainingCasesAndKeepPassedCount() { + OjSubmission submission = submission(); + OjTestCase firstCase = testCase().setExpectedOutput("ok"); + OjTestCase secondCase = testCase().setId(2L).setInput("slow"); + prepareSubmission(submission, firstCase, secondCase); + when(strategy.getLanguage()).thenReturn(JudgeLanguage.JAVA); + when(strategy.getSourceFileName()).thenReturn("Main.java"); + when(strategy.getCompileArgs()).thenReturn(List.of("/usr/bin/javac", "Main.java")); + when(strategy.getCompiledFileNames()).thenReturn(List.of("Main.class")); + when(strategy.getRunArgs()).thenReturn(List.of("/usr/bin/java", "Main")); + + ExecuteResult compileResult = acceptedCompileResult(); + ExecuteResult acceptedRun = acceptedRunResult("ok\n", 12, 34); + ExecuteResult timeoutRun = new ExecuteResult(); + timeoutRun.setStatus("Time Limit Exceeded"); + timeoutRun.setExitStatus(-1); + timeoutRun.setTimeUsed(101); + timeoutRun.setMemoryUsed(40); + doReturn(compileResult).when(goJudgeClient).run( + anyList(), anyMap(), isNull(), anyLong(), anyLong(), isNull(), anyList()); + doReturn(acceptedRun, timeoutRun).when(goJudgeClient).run( + anyList(), isNull(), anyMap(), anyString(), anyLong(), anyLong(), isNull(), isNull()); + + service.judge(submission.getId()); + + assertEquals(SubmissionStatus.TIME_LIMIT_EXCEEDED.getValue(), submission.getStatus()); + assertEquals(1, submission.getPassCount()); + assertEquals(2, submission.getTotalCount()); + assertEquals(101L, submission.getTimeUsed()); + verify(goJudgeClient, times(2)).run( + anyList(), isNull(), anyMap(), anyString(), anyLong(), anyLong(), isNull(), isNull()); + } + + @Test + void memoryLimitExceededShouldStopWithNoPassedCases() { + OjSubmission submission = submission().setLanguage("python"); + prepareSubmission(submission); + when(strategy.getLanguage()).thenReturn(JudgeLanguage.PYTHON); + when(strategy.getSourceFileName()).thenReturn("main.py"); + when(strategy.getCompileArgs()).thenReturn(null); + when(strategy.getRunArgs()).thenReturn(List.of("/usr/bin/python3", "main.py")); + + ExecuteResult memoryLimitResult = new ExecuteResult(); + memoryLimitResult.setStatus("Memory Limit Exceeded"); + memoryLimitResult.setExitStatus(-1); + memoryLimitResult.setTimeUsed(9); + memoryLimitResult.setMemoryUsed(65 * 1024L); + doReturn(memoryLimitResult).when(goJudgeClient).run( + anyList(), anyMap(), isNull(), anyString(), anyLong(), anyLong(), isNull(), isNull()); + + service.judge(submission.getId()); + + assertEquals(SubmissionStatus.MEMORY_LIMIT_EXCEEDED.getValue(), submission.getStatus()); + assertEquals(0, submission.getPassCount()); + assertEquals(1, submission.getTotalCount()); + assertEquals(9L, submission.getTimeUsed()); + assertEquals(65 * 1024L, submission.getMemoryUsed()); + } + + @Test + void leadingWhitespaceDifferenceShouldBeWrongAnswer() { + OjSubmission submission = submission(); + prepareSubmission(submission, testCase().setExpectedOutput(" ok")); + when(strategy.getLanguage()).thenReturn(JudgeLanguage.JAVA); + when(strategy.getSourceFileName()).thenReturn("Main.java"); + when(strategy.getCompileArgs()).thenReturn(List.of("/usr/bin/javac", "Main.java")); + when(strategy.getCompiledFileNames()).thenReturn(List.of("Main.class")); + when(strategy.getRunArgs()).thenReturn(List.of("/usr/bin/java", "Main")); + + doReturn(acceptedCompileResult()).when(goJudgeClient).run( + anyList(), anyMap(), isNull(), anyLong(), anyLong(), isNull(), anyList()); + doReturn(acceptedRunResult("ok", 1, 2)).when(goJudgeClient).run( + anyList(), isNull(), anyMap(), anyString(), anyLong(), anyLong(), isNull(), isNull()); + + service.judge(submission.getId()); + + assertEquals(SubmissionStatus.WRONG_ANSWER.getValue(), submission.getStatus()); + assertEquals(0, submission.getPassCount()); + } + + @Test + void firstAcceptedSubmissionShouldUpdateCounterAndGrantDifficultyPoints() { + OjSubmission submission = submission(); + prepareSubmission(submission, problem().setDifficulty("medium"), testCase()); + configureJavaStrategy(); + when(submissionMapper.existsAccepted(submission.getUserId(), submission.getProblemId())).thenReturn(false); + stubAcceptedCompiledRun("ok", 11, 22); + + service.judge(submission.getId()); + + assertEquals(SubmissionStatus.ACCEPTED.getValue(), submission.getStatus()); + verify(problemMapper).increaseAcceptedCount(submission.getProblemId()); + verify(pointsService).grantSystemPoints( + submission.getUserId(), + 200, + PointsType.OJ_AC.getCode(), + "首次通过题目「test problem」" + ); + } + + @Test + void pointsFailureShouldNotOverwriteAcceptedJudgement() { + OjSubmission submission = submission(); + prepareSubmission(submission); + configureJavaStrategy(); + when(submissionMapper.existsAccepted(submission.getUserId(), submission.getProblemId())).thenReturn(false); + doThrow(new IllegalStateException("points unavailable")).when(pointsService) + .grantSystemPoints(anyLong(), anyInt(), anyInt(), anyString()); + stubAcceptedCompiledRun("ok", 11, 22); + + service.judge(submission.getId()); + + assertEquals(SubmissionStatus.ACCEPTED.getValue(), submission.getStatus()); + assertNull(submission.getErrorMessage()); + verify(problemMapper).increaseAcceptedCount(submission.getProblemId()); + verify(submissionMapper, times(2)).updateById(submission); + verify(goJudgeClient).deleteFile("compiled-file-id"); + } + + @Test + void counterFailureShouldNotOverwriteAcceptedJudgementOrSkipPoints() { + OjSubmission submission = submission(); + prepareSubmission(submission); + configureJavaStrategy(); + when(submissionMapper.existsAccepted(submission.getUserId(), submission.getProblemId())).thenReturn(false); + doThrow(new IllegalStateException("counter unavailable")).when(problemMapper) + .increaseAcceptedCount(submission.getProblemId()); + stubAcceptedCompiledRun("ok", 11, 22); + + service.judge(submission.getId()); + + assertEquals(SubmissionStatus.ACCEPTED.getValue(), submission.getStatus()); + assertNull(submission.getErrorMessage()); + verify(pointsService).grantSystemPoints( + eq(submission.getUserId()), anyInt(), eq(PointsType.OJ_AC.getCode()), anyString()); + verify(submissionMapper, times(2)).updateById(submission); + verify(goJudgeClient).deleteFile("compiled-file-id"); + } + + @Test + void runtimeExceptionShouldSetSystemErrorAndCleanCompiledFiles() { + OjSubmission submission = submission(); + prepareSubmission(submission); + configureJavaStrategy(); + doReturn(acceptedCompileResult()).when(goJudgeClient).run( + anyList(), anyMap(), isNull(), anyLong(), anyLong(), isNull(), anyList()); + doThrow(new IllegalStateException("sandbox crashed")).when(goJudgeClient).run( + anyList(), isNull(), anyMap(), anyString(), anyLong(), anyLong(), isNull(), isNull()); + + service.judge(submission.getId()); + + assertEquals(SubmissionStatus.SYSTEM_ERROR.getValue(), submission.getStatus()); + assertTrue(submission.getErrorMessage().contains("sandbox crashed")); + verify(goJudgeClient).deleteFile("compiled-file-id"); + verify(pointsService, never()).grantSystemPoints(anyLong(), anyInt(), anyInt(), anyString()); + } + + private void prepareSubmission(OjSubmission submission) { + prepareSubmission(submission, testCase()); + } + + private void prepareSubmission(OjSubmission submission, OjTestCase... testCases) { + prepareSubmission(submission, problem(), testCases); + } + + private void prepareSubmission(OjSubmission submission, OjProblem problem, OjTestCase... testCases) { + when(submissionMapper.selectById(submission.getId())).thenReturn(submission); + when(problemMapper.selectById(submission.getProblemId())).thenReturn(problem); + when(testCaseMapper.selectByProblemId(submission.getProblemId())).thenReturn(List.of(testCases)); + } + + private void configureJavaStrategy() { + when(strategy.getLanguage()).thenReturn(JudgeLanguage.JAVA); + when(strategy.getSourceFileName()).thenReturn("Main.java"); + when(strategy.getCompileArgs()).thenReturn(List.of("/usr/bin/javac", "Main.java")); + when(strategy.getCompiledFileNames()).thenReturn(List.of("Main.class")); + when(strategy.getRunArgs()).thenReturn(List.of("/usr/bin/java", "Main")); + } + + private void stubAcceptedCompiledRun(String stdout, long timeUsed, long memoryUsed) { + doReturn(acceptedCompileResult()).when(goJudgeClient).run( + anyList(), anyMap(), isNull(), anyLong(), anyLong(), isNull(), anyList()); + doReturn(acceptedRunResult(stdout, timeUsed, memoryUsed)).when(goJudgeClient).run( + anyList(), isNull(), anyMap(), anyString(), anyLong(), anyLong(), isNull(), isNull()); + } + + private ExecuteResult acceptedCompileResult() { + ExecuteResult result = new ExecuteResult(); + result.setStatus("Accepted"); + result.setExitStatus(0); + result.setFileIds(Map.of("Main.class", "compiled-file-id")); + return result; + } + + private ExecuteResult acceptedRunResult(String stdout, long timeUsed, long memoryUsed) { + ExecuteResult result = new ExecuteResult(); + result.setStatus("Accepted"); + result.setExitStatus(0); + result.setStdout(stdout); + result.setTimeUsed(timeUsed); + result.setMemoryUsed(memoryUsed); + return result; + } + + private OjSubmission submission() { + return new OjSubmission() + .setId(10L) + .setProblemId(20L) + .setUserId(30L) + .setLanguage("java") + .setCode("class Main { public static void main(String[] args) {} }"); + } + + private OjProblem problem() { + return new OjProblem() + .setId(20L) + .setTitle("test problem") + .setDifficulty("easy") + .setTimeLimit(100) + .setMemoryLimit(64); + } + + private OjTestCase testCase() { + return new OjTestCase() + .setId(1L) + .setProblemId(20L) + .setInput("input") + .setExpectedOutput("ok"); + } +} diff --git a/xiaou-points/pom.xml b/xiaou-points/pom.xml index e18526e00..288fdb2a3 100644 --- a/xiaou-points/pom.xml +++ b/xiaou-points/pom.xml @@ -39,6 +39,12 @@ com.github.ben-manes.caffeine caffeine + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/xiaou-points/src/main/java/com/xiaou/points/chain/impl/PointsCheckHandler.java b/xiaou-points/src/main/java/com/xiaou/points/chain/impl/PointsCheckHandler.java index 7db262dad..cb44a41f9 100644 --- a/xiaou-points/src/main/java/com/xiaou/points/chain/impl/PointsCheckHandler.java +++ b/xiaou-points/src/main/java/com/xiaou/points/chain/impl/PointsCheckHandler.java @@ -25,11 +25,14 @@ public class PointsCheckHandler extends RiskCheckHandler { @Override public boolean check(Long userId, LotteryContext context) { UserPointsBalance balance = pointsBalanceMapper.selectByUserId(userId); - - if (balance == null || balance.getTotalPoints() < LotteryConstants.LOTTERY_COST) { + + int currentPoints = balance == null || balance.getTotalPoints() == null + ? 0 + : balance.getTotalPoints(); + if (currentPoints < LotteryConstants.LOTTERY_COST) { log.warn("用户{}积分不足,当前积分:{},需要:{}", userId, - balance != null ? balance.getTotalPoints() : 0, + currentPoints, LotteryConstants.LOTTERY_COST); throw new BusinessException("积分不足,无法参与抽奖"); } diff --git a/xiaou-points/src/main/java/com/xiaou/points/service/impl/LotteryServiceImpl.java b/xiaou-points/src/main/java/com/xiaou/points/service/impl/LotteryServiceImpl.java index 38fdb8d04..31947a40a 100644 --- a/xiaou-points/src/main/java/com/xiaou/points/service/impl/LotteryServiceImpl.java +++ b/xiaou-points/src/main/java/com/xiaou/points/service/impl/LotteryServiceImpl.java @@ -12,6 +12,7 @@ import com.xiaou.points.enums.LotteryStatusEnum; import com.xiaou.points.enums.LotteryStrategyEnum; import com.xiaou.points.enums.PointsType; +import com.xiaou.points.enums.PrizeLevelEnum; import com.xiaou.points.event.LotteryEvent; import com.xiaou.points.event.LotteryEventPublisher; import com.xiaou.points.factory.LotteryStrategyFactory; @@ -86,7 +87,9 @@ public LotteryDrawResponse draw(LotteryDrawRequest request, Long userId, String // 3. 执行风控检查链 RiskCheckHandler checkChain = chainBuilder.buildChain(); - checkChain.check(userId, context); + if (!checkChain.check(userId, context)) { + throw new BusinessException("抽奖风险校验未通过"); + } // 4. 扣除积分 deductPoints(userId, LotteryConstants.DRAW_COST_POINTS); @@ -183,7 +186,8 @@ public Integer getTodayRemainingCount(Long userId) { if (limit == null) { return LotteryConstants.MAX_DRAW_PER_DAY; } - return Math.max(0, LotteryConstants.MAX_DRAW_PER_DAY - limit.getTodayDrawCount()); + int todayDrawCount = Objects.requireNonNullElse(limit.getTodayDrawCount(), 0); + return Math.max(0, LotteryConstants.MAX_DRAW_PER_DAY - todayDrawCount); } /** @@ -267,7 +271,7 @@ private void updateUserLimit(Long userId, LotteryPrizeConfig prize) { userLimitMapper.incrementDrawCount(userId); - if (prize.getPrizeLevel() < 8) { + if (isWinningPrize(prize)) { userLimitMapper.incrementWinCount(userId); userLimitMapper.updateContinuousNoWin(userId, 0); } else { @@ -310,20 +314,7 @@ private void publishEvent(LotteryDrawRecord record) { * 构建响应 */ private LotteryDrawResponse buildResponse(LotteryPrizeConfig prize, LotteryDrawRecord record) { - LotteryDrawResponse response = new LotteryDrawResponse(); - response.setId(record.getId()); - response.setRecordId(record.getId()); - response.setUserId(record.getUserId()); - response.setPrizeId(prize.getId()); - response.setPrizeName(prize.getPrizeName()); - response.setPrizeLevel(prize.getPrizeLevel()); - response.setPrizePoints(prize.getPrizePoints()); - response.setPrizeIcon(prize.getPrizeIcon()); - response.setStrategyType(record.getDrawStrategy()); - response.setIp(record.getDrawIp()); - response.setDevice(record.getDrawDevice()); - response.setDrawTime(record.getCreateTime()); - return response; + return convertToDrawResponse(record, prize); } private void rollbackStockIfNeeded(boolean stockDeducted, LotteryPrizeConfig prize) { @@ -335,6 +326,14 @@ private void rollbackStockIfNeeded(boolean stockDeducted, LotteryPrizeConfig pri private boolean isLimitedStock(LotteryPrizeConfig prize) { return prize.getTotalStock() != null && prize.getTotalStock() >= 0; } + + private boolean isWinningPrize(LotteryPrizeConfig prize) { + return isWinningPrizeLevel(prize.getPrizeLevel()); + } + + private boolean isWinningPrizeLevel(Integer prizeLevel) { + return prizeLevel != null && prizeLevel < PrizeLevelEnum.NO_PRIZE.getCode(); + } /** * 获取所有启用的奖品 @@ -393,7 +392,17 @@ private LotteryDrawResponse convertToDrawResponse(LotteryDrawRecord record, Lott LotteryDrawResponse response = new LotteryDrawResponse(); BeanUtil.copyProperties(record, response); response.setRecordId(record.getId()); + response.setStrategyType(record.getDrawStrategy()); + response.setIp(record.getDrawIp()); + response.setDevice(record.getDrawDevice()); response.setDrawTime(record.getCreateTime()); + + int costPoints = Objects.requireNonNullElse( + record.getCostPoints(), LotteryConstants.DRAW_COST_POINTS); + int prizePoints = Objects.requireNonNullElse(record.getPrizePoints(), 0); + response.setCostPoints(costPoints); + response.setNetProfit(prizePoints - costPoints); + response.setIsWin(isWinningPrizeLevel(record.getPrizeLevel())); if (prize != null) { response.setPrizeName(prize.getPrizeName()); diff --git a/xiaou-points/src/test/java/com/xiaou/points/chain/LotteryRiskCheckChainTest.java b/xiaou-points/src/test/java/com/xiaou/points/chain/LotteryRiskCheckChainTest.java new file mode 100644 index 000000000..c8c76384a --- /dev/null +++ b/xiaou-points/src/test/java/com/xiaou/points/chain/LotteryRiskCheckChainTest.java @@ -0,0 +1,348 @@ +package com.xiaou.points.chain; + +import com.xiaou.common.exception.BusinessException; +import com.xiaou.points.chain.impl.BlacklistCheckHandler; +import com.xiaou.points.chain.impl.CooldownCheckHandler; +import com.xiaou.points.chain.impl.PointsCheckHandler; +import com.xiaou.points.chain.impl.RateLimitCheckHandler; +import com.xiaou.points.domain.UserLotteryLimit; +import com.xiaou.points.domain.UserPointsBalance; +import com.xiaou.points.dto.lottery.LotteryContext; +import com.xiaou.points.mapper.UserLotteryLimitMapper; +import com.xiaou.points.mapper.UserPointsBalanceMapper; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.redisson.api.RRateLimiter; +import org.redisson.api.RateIntervalUnit; +import org.redisson.api.RateType; +import org.redisson.api.RedissonClient; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class LotteryRiskCheckChainTest { + + private static final long USER_ID = 42L; + private static final String IP = "203.0.113.42"; + + @Test + void shouldRunEveryRiskCheckInOrderWhenRequestIsAllowed() { + Fixture fixture = new Fixture(); + fixture.allowUser(100, allowedLimit(LocalDateTime.now().minusMinutes(2))); + fixture.allowAllRateLimits(); + + boolean allowed = fixture.chain().check(USER_ID, context(IP)); + + assertTrue(allowed); + InOrder order = inOrder(fixture.userLimitMapper, fixture.pointsBalanceMapper, fixture.redissonClient); + order.verify(fixture.userLimitMapper).selectByUserId(USER_ID); + order.verify(fixture.pointsBalanceMapper).selectByUserId(USER_ID); + order.verify(fixture.redissonClient).getRateLimiter("lottery:ratelimit:global"); + order.verify(fixture.redissonClient).getRateLimiter("lottery:ratelimit:user:" + USER_ID); + order.verify(fixture.redissonClient).getRateLimiter("lottery:ratelimit:ip:" + IP); + order.verify(fixture.userLimitMapper).selectByUserId(USER_ID); + } + + @Test + void shouldStopAtBlacklistBeforeReadingPointsOrRedis() { + Fixture fixture = new Fixture(); + UserLotteryLimit limit = allowedLimit(null); + limit.setIsBlacklist(1); + when(fixture.userLimitMapper.selectByUserId(USER_ID)).thenReturn(limit); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.chain().check(USER_ID, context(IP)) + ); + + assertEquals("您的账号已被限制,无法参与抽奖", exception.getMessage()); + verifyNoInteractions(fixture.pointsBalanceMapper, fixture.redissonClient); + verify(fixture.userLimitMapper, times(1)).selectByUserId(USER_ID); + } + + @Test + void shouldAllowBlacklistCheckWhenUserHasNoLimitRecord() { + UserLotteryLimitMapper mapper = mock(UserLotteryLimitMapper.class); + when(mapper.selectByUserId(USER_ID)).thenReturn(null); + + boolean allowed = new BlacklistCheckHandler(mapper).check(USER_ID, context(IP)); + + assertTrue(allowed); + } + + @Test + void shouldRejectMissingPointsBalanceBeforeRateLimitChecks() { + Fixture fixture = new Fixture(); + when(fixture.userLimitMapper.selectByUserId(USER_ID)).thenReturn(allowedLimit(null)); + when(fixture.pointsBalanceMapper.selectByUserId(USER_ID)).thenReturn(null); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.chain().check(USER_ID, context(IP)) + ); + + assertEquals("积分不足,无法参与抽奖", exception.getMessage()); + verifyNoInteractions(fixture.redissonClient); + } + + @Test + void shouldTreatNullTotalPointsAsInsufficientBalance() { + Fixture fixture = new Fixture(); + when(fixture.userLimitMapper.selectByUserId(USER_ID)).thenReturn(allowedLimit(null)); + when(fixture.pointsBalanceMapper.selectByUserId(USER_ID)).thenReturn(balance(null)); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.chain().check(USER_ID, context(IP)) + ); + + assertEquals("积分不足,无法参与抽奖", exception.getMessage()); + verifyNoInteractions(fixture.redissonClient); + } + + @Test + void shouldRejectGlobalRateLimitBeforeUserAndIpChecks() { + Fixture fixture = new Fixture(); + fixture.allowUser(100, allowedLimit(null)); + when(fixture.redissonClient.getRateLimiter("lottery:ratelimit:global")) + .thenReturn(fixture.globalLimiter); + when(fixture.globalLimiter.isExists()).thenReturn(true); + when(fixture.globalLimiter.tryAcquire(1)).thenReturn(false); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.chain().check(USER_ID, context(IP)) + ); + + assertEquals("系统繁忙,请稍后再试", exception.getMessage()); + verify(fixture.redissonClient, never()).getRateLimiter("lottery:ratelimit:user:" + USER_ID); + verify(fixture.redissonClient, never()).getRateLimiter("lottery:ratelimit:ip:" + IP); + } + + @Test + void shouldRejectUserRateLimitBeforeIpCheck() { + Fixture fixture = new Fixture(); + fixture.allowUser(100, allowedLimit(null)); + fixture.allowLimiter("lottery:ratelimit:global", fixture.globalLimiter); + when(fixture.redissonClient.getRateLimiter("lottery:ratelimit:user:" + USER_ID)) + .thenReturn(fixture.userLimiter); + when(fixture.userLimiter.isExists()).thenReturn(true); + when(fixture.userLimiter.tryAcquire(1)).thenReturn(false); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.chain().check(USER_ID, context(IP)) + ); + + assertEquals("操作过于频繁,请稍后再试", exception.getMessage()); + verify(fixture.redissonClient, never()).getRateLimiter("lottery:ratelimit:ip:" + IP); + } + + @Test + void shouldRejectIpRateLimitAfterGlobalAndUserChecksPass() { + Fixture fixture = new Fixture(); + fixture.allowUser(100, allowedLimit(null)); + fixture.allowLimiter("lottery:ratelimit:global", fixture.globalLimiter); + fixture.allowLimiter("lottery:ratelimit:user:" + USER_ID, fixture.userLimiter); + when(fixture.redissonClient.getRateLimiter("lottery:ratelimit:ip:" + IP)) + .thenReturn(fixture.ipLimiter); + when(fixture.ipLimiter.isExists()).thenReturn(true); + when(fixture.ipLimiter.tryAcquire(1)).thenReturn(false); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.chain().check(USER_ID, context(IP)) + ); + + assertEquals("该IP操作过于频繁,请稍后再试", exception.getMessage()); + } + + @Test + void shouldFailOpenWhenRateLimiterBackendIsUnavailable() { + Fixture fixture = new Fixture(); + fixture.allowUser(100, allowedLimit(LocalDateTime.now().minusMinutes(2))); + when(fixture.redissonClient.getRateLimiter(anyString())) + .thenThrow(new IllegalStateException("redis unavailable")); + + boolean allowed = fixture.chain().check(USER_ID, context(IP)); + + assertTrue(allowed); + verify(fixture.redissonClient, times(3)).getRateLimiter(anyString()); + } + + @Test + void shouldConfigureMissingRateLimitersBeforeAcquiringPermits() { + Fixture fixture = new Fixture(); + fixture.allowUser(100, allowedLimit(LocalDateTime.now().minusMinutes(2))); + fixture.configureMissingLimiter("lottery:ratelimit:global", fixture.globalLimiter); + fixture.configureMissingLimiter("lottery:ratelimit:user:" + USER_ID, fixture.userLimiter); + fixture.configureMissingLimiter("lottery:ratelimit:ip:" + IP, fixture.ipLimiter); + + boolean allowed = fixture.chain().check(USER_ID, context(IP)); + + assertTrue(allowed); + verify(fixture.globalLimiter).trySetRate( + RateType.OVERALL, 1000, 1, RateIntervalUnit.SECONDS); + verify(fixture.userLimiter).trySetRate( + RateType.OVERALL, 10, 60, RateIntervalUnit.SECONDS); + verify(fixture.ipLimiter).trySetRate( + RateType.OVERALL, 50, 60, RateIntervalUnit.SECONDS); + } + + @Test + void shouldSkipIpRateLimitWhenRequestHasNoIp() { + Fixture fixture = new Fixture(); + fixture.allowUser(100, allowedLimit(LocalDateTime.now().minusMinutes(2))); + fixture.allowLimiter("lottery:ratelimit:global", fixture.globalLimiter); + fixture.allowLimiter("lottery:ratelimit:user:" + USER_ID, fixture.userLimiter); + + boolean allowed = fixture.chain().check(USER_ID, context(null)); + + assertTrue(allowed); + verify(fixture.redissonClient, times(2)).getRateLimiter(anyString()); + } + + @Test + void shouldApplyLongerCooldownToHighRiskUsers() { + UserLotteryLimitMapper mapper = mock(UserLotteryLimitMapper.class); + UserLotteryLimit limit = allowedLimit(LocalDateTime.now().minusSeconds(5)); + limit.setRiskLevel(2); + when(mapper.selectByUserId(USER_ID)).thenReturn(limit); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> new CooldownCheckHandler(mapper).check(USER_ID, context(IP)) + ); + + assertTrue(exception.getMessage().startsWith("高风险用户需要等待")); + } + + @Test + void shouldAllowHighRiskUserAfterCooldownExpires() { + UserLotteryLimitMapper mapper = mock(UserLotteryLimitMapper.class); + UserLotteryLimit limit = allowedLimit(LocalDateTime.now().minusSeconds(11)); + limit.setRiskLevel(2); + limit.setTodayDrawCount(null); + when(mapper.selectByUserId(USER_ID)).thenReturn(limit); + + boolean allowed = new CooldownCheckHandler(mapper).check(USER_ID, context(IP)); + + assertTrue(allowed); + } + + @Test + void shouldAllowFirstDrawWithoutCooldownHistory() { + UserLotteryLimitMapper mapper = mock(UserLotteryLimitMapper.class); + when(mapper.selectByUserId(USER_ID)).thenReturn(null); + + boolean allowed = new CooldownCheckHandler(mapper).check(USER_ID, context(IP)); + + assertTrue(allowed); + } + + @Test + void shouldApplyContinuousDrawCooldownAtEveryTenthDraw() { + UserLotteryLimitMapper mapper = mock(UserLotteryLimitMapper.class); + UserLotteryLimit limit = allowedLimit(LocalDateTime.now().minusSeconds(20)); + limit.setTodayDrawCount(10); + when(mapper.selectByUserId(USER_ID)).thenReturn(limit); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> new CooldownCheckHandler(mapper).check(USER_ID, context(IP)) + ); + + assertTrue(exception.getMessage().startsWith("连续抽奖过多,需要冷却")); + } + + @Test + void shouldApplyNormalCooldownToRegularUsers() { + UserLotteryLimitMapper mapper = mock(UserLotteryLimitMapper.class); + when(mapper.selectByUserId(USER_ID)) + .thenReturn(allowedLimit(LocalDateTime.now().minusSeconds(1))); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> new CooldownCheckHandler(mapper).check(USER_ID, context(IP)) + ); + + assertTrue(exception.getMessage().startsWith("操作过快,请等待")); + } + + private static LotteryContext context(String ip) { + return LotteryContext.builder() + .userId(USER_ID) + .ip(ip) + .device("risk-chain-test") + .build(); + } + + private static UserLotteryLimit allowedLimit(LocalDateTime lastDrawTime) { + UserLotteryLimit limit = new UserLotteryLimit(); + limit.setUserId(USER_ID); + limit.setIsBlacklist(0); + limit.setRiskLevel(0); + limit.setTodayDrawCount(1); + limit.setLastDrawTime(lastDrawTime); + return limit; + } + + private static UserPointsBalance balance(Integer points) { + UserPointsBalance balance = new UserPointsBalance(); + balance.setUserId(USER_ID); + balance.setTotalPoints(points); + return balance; + } + + private static final class Fixture { + + private final UserLotteryLimitMapper userLimitMapper = mock(UserLotteryLimitMapper.class); + private final UserPointsBalanceMapper pointsBalanceMapper = mock(UserPointsBalanceMapper.class); + private final RedissonClient redissonClient = mock(RedissonClient.class); + private final RRateLimiter globalLimiter = mock(RRateLimiter.class); + private final RRateLimiter userLimiter = mock(RRateLimiter.class); + private final RRateLimiter ipLimiter = mock(RRateLimiter.class); + + private RiskCheckHandler chain() { + PointsCheckHandler points = new PointsCheckHandler(pointsBalanceMapper); + RateLimitCheckHandler rateLimit = new RateLimitCheckHandler(redissonClient); + CooldownCheckHandler cooldown = new CooldownCheckHandler(userLimitMapper); + BlacklistCheckHandler blacklist = new BlacklistCheckHandler(userLimitMapper); + return new RiskCheckChainBuilder(points, rateLimit, cooldown, blacklist).buildChain(); + } + + private void allowUser(Integer points, UserLotteryLimit limit) { + when(userLimitMapper.selectByUserId(USER_ID)).thenReturn(limit); + when(pointsBalanceMapper.selectByUserId(USER_ID)).thenReturn(balance(points)); + } + + private void allowAllRateLimits() { + allowLimiter("lottery:ratelimit:global", globalLimiter); + allowLimiter("lottery:ratelimit:user:" + USER_ID, userLimiter); + allowLimiter("lottery:ratelimit:ip:" + IP, ipLimiter); + } + + private void allowLimiter(String key, RRateLimiter limiter) { + when(redissonClient.getRateLimiter(key)).thenReturn(limiter); + when(limiter.isExists()).thenReturn(true); + when(limiter.tryAcquire(1)).thenReturn(true); + } + + private void configureMissingLimiter(String key, RRateLimiter limiter) { + when(redissonClient.getRateLimiter(key)).thenReturn(limiter); + when(limiter.isExists()).thenReturn(false); + when(limiter.tryAcquire(1)).thenReturn(true); + } + } +} diff --git a/xiaou-points/src/test/java/com/xiaou/points/service/impl/LotteryServiceImplTest.java b/xiaou-points/src/test/java/com/xiaou/points/service/impl/LotteryServiceImplTest.java new file mode 100644 index 000000000..9529886f6 --- /dev/null +++ b/xiaou-points/src/test/java/com/xiaou/points/service/impl/LotteryServiceImplTest.java @@ -0,0 +1,624 @@ +package com.xiaou.points.service.impl; + +import com.xiaou.common.core.domain.PageResult; +import com.xiaou.common.exception.BusinessException; +import com.xiaou.points.chain.RiskCheckChainBuilder; +import com.xiaou.points.chain.RiskCheckHandler; +import com.xiaou.points.constant.LotteryConstants; +import com.xiaou.points.domain.LotteryDrawRecord; +import com.xiaou.points.domain.LotteryPrizeConfig; +import com.xiaou.points.domain.UserLotteryLimit; +import com.xiaou.points.domain.UserPointsBalance; +import com.xiaou.points.domain.UserPointsDetail; +import com.xiaou.points.dto.lottery.LotteryContext; +import com.xiaou.points.dto.lottery.LotteryDrawRequest; +import com.xiaou.points.dto.lottery.LotteryDrawResponse; +import com.xiaou.points.dto.lottery.LotteryPrizeResponse; +import com.xiaou.points.dto.lottery.LotteryRecordQueryRequest; +import com.xiaou.points.dto.lottery.LotteryStatisticsResponse; +import com.xiaou.points.enums.PointsType; +import com.xiaou.points.event.LotteryEvent; +import com.xiaou.points.event.LotteryEventPublisher; +import com.xiaou.points.factory.LotteryStrategyFactory; +import com.xiaou.points.mapper.LotteryDrawRecordMapper; +import com.xiaou.points.mapper.LotteryPrizeConfigMapper; +import com.xiaou.points.mapper.UserLotteryLimitMapper; +import com.xiaou.points.mapper.UserPointsBalanceMapper; +import com.xiaou.points.mapper.UserPointsDetailMapper; +import com.xiaou.points.service.LotteryEmergencyService; +import com.xiaou.points.service.LotteryStockService; +import com.xiaou.points.strategy.LotteryStrategy; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class LotteryServiceImplTest { + + private static final long USER_ID = 42L; + private static final long PRIZE_ID = 7L; + private static final long RECORD_ID = 9001L; + private static final String IP = "203.0.113.42"; + private static final String DEVICE = "lottery-service-test"; + private static final String STRATEGY = "ALIAS_METHOD"; + private static final String LOCK_KEY = LotteryConstants.LOCK_USER_DRAW + USER_ID; + + @Test + void shouldRejectDrawBeforeLockingWhenCircuitIsBroken() { + Fixture fixture = new Fixture(); + when(fixture.emergencyService.isCircuitBroken()).thenReturn(true); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.draw(request(), USER_ID, IP, DEVICE) + ); + + assertEquals("系统维护中,暂停抽奖服务", exception.getMessage()); + verifyNoInteractions(fixture.redissonClient, fixture.chainBuilder, + fixture.pointsBalanceMapper, fixture.stockService); + } + + @Test + void shouldStopBeforeBuildingContextWhenUserLockIsUnavailable() throws InterruptedException { + Fixture fixture = new Fixture(); + fixture.prepareLock(false); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.draw(request(), USER_ID, IP, DEVICE) + ); + + assertEquals("操作过于频繁,请稍后再试", exception.getMessage()); + verify(fixture.redissonClient).getLock(LOCK_KEY); + verify(fixture.lock).tryLock(3, TimeUnit.SECONDS); + verify(fixture.lock).isHeldByCurrentThread(); + verify(fixture.lock, never()).unlock(); + verifyNoInteractions(fixture.chainBuilder, fixture.prizeConfigMapper, + fixture.pointsBalanceMapper, fixture.stockService); + } + + @Test + void shouldStopMutationsAndReleaseLockWhenRiskCheckThrows() throws InterruptedException { + Fixture fixture = new Fixture(); + fixture.prepareThroughRisk(limitedPrize()); + when(fixture.riskCheck.check(eq(USER_ID), any(LotteryContext.class))) + .thenThrow(new BusinessException("风险检查拒绝")); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.draw(request(), USER_ID, IP, DEVICE) + ); + + assertEquals("风险检查拒绝", exception.getMessage()); + verify(fixture.pointsBalanceMapper, never()).deductPoints(anyLong(), any(Integer.class)); + verifyNoInteractions(fixture.strategyFactory, fixture.stockService, + fixture.drawRecordMapper, fixture.eventPublisher); + verify(fixture.lock).unlock(); + } + + @Test + void shouldHonorFalseRiskResultAsARejection() throws InterruptedException { + Fixture fixture = new Fixture(); + fixture.prepareThroughRisk(limitedPrize()); + when(fixture.riskCheck.check(eq(USER_ID), any(LotteryContext.class))).thenReturn(false); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.draw(request(), USER_ID, IP, DEVICE) + ); + + assertEquals("抽奖风险校验未通过", exception.getMessage()); + verify(fixture.pointsBalanceMapper, never()).deductPoints(anyLong(), any(Integer.class)); + verifyNoInteractions(fixture.strategyFactory, fixture.stockService, + fixture.drawRecordMapper, fixture.eventPublisher); + verify(fixture.lock).unlock(); + } + + @Test + void shouldStopBeforeStrategyWhenAtomicPointDeductionFails() throws InterruptedException { + Fixture fixture = new Fixture(); + fixture.prepareThroughRisk(limitedPrize()); + when(fixture.pointsBalanceMapper.deductPoints( + USER_ID, LotteryConstants.DRAW_COST_POINTS)).thenReturn(0); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.draw(request(), USER_ID, IP, DEVICE) + ); + + assertEquals("积分不足", exception.getMessage()); + verifyNoInteractions(fixture.pointsDetailMapper, fixture.strategyFactory, + fixture.stockService, fixture.drawRecordMapper, fixture.eventPublisher); + verify(fixture.lock).unlock(); + } + + @Test + void shouldStopRewardAndRecordingWhenStockIsUnavailable() throws InterruptedException { + Fixture fixture = new Fixture(); + LotteryPrizeConfig prize = limitedPrize(); + fixture.prepareSuccessfulDependencies(prize); + when(fixture.stockService.deductStock(PRIZE_ID)).thenReturn(false); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.draw(request(), USER_ID, IP, DEVICE) + ); + + assertEquals("奖品库存不足", exception.getMessage()); + verify(fixture.stockService, never()).rollbackStock(anyLong()); + verify(fixture.pointsBalanceMapper, never()).addPoints(anyLong(), any(Integer.class)); + verify(fixture.userLimitMapper, never()).incrementDrawCount(anyLong()); + verifyNoInteractions(fixture.drawRecordMapper, fixture.eventPublisher); + verify(fixture.lock).unlock(); + } + + @Test + void shouldExecuteWinningDrawInOrderAndReturnRecordedOutcome() throws InterruptedException { + Fixture fixture = new Fixture(); + LotteryPrizeConfig prize = limitedPrize(); + fixture.prepareSuccessfulDependencies(prize); + + LotteryDrawResponse response = fixture.service.draw(request(), USER_ID, IP, DEVICE); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(LotteryContext.class); + ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(LotteryDrawRecord.class); + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LotteryEvent.class); + InOrder order = inOrder(fixture.riskCheck, fixture.pointsBalanceMapper, + fixture.strategy, fixture.stockService, fixture.userLimitMapper, + fixture.drawRecordMapper, fixture.eventPublisher); + order.verify(fixture.riskCheck).check(eq(USER_ID), contextCaptor.capture()); + order.verify(fixture.pointsBalanceMapper) + .deductPoints(USER_ID, LotteryConstants.DRAW_COST_POINTS); + order.verify(fixture.strategy).draw(USER_ID, fixture.activePrizes); + order.verify(fixture.stockService).deductStock(PRIZE_ID); + order.verify(fixture.pointsBalanceMapper).addPoints(USER_ID, prize.getPrizePoints()); + order.verify(fixture.userLimitMapper).incrementDrawCount(USER_ID); + order.verify(fixture.drawRecordMapper).insert(recordCaptor.capture()); + order.verify(fixture.eventPublisher).publish(eventCaptor.capture()); + + LotteryContext context = contextCaptor.getValue(); + LotteryDrawRecord record = recordCaptor.getValue(); + assertAll( + () -> assertEquals(STRATEGY, context.getStrategyType()), + () -> assertEquals(IP, context.getIp()), + () -> assertEquals(DEVICE, context.getDevice()), + () -> assertSame(fixture.activePrizes, context.getPrizes()), + () -> assertEquals(RECORD_ID, record.getId()), + () -> assertEquals(USER_ID, record.getUserId()), + () -> assertEquals(LotteryConstants.DRAW_COST_POINTS, record.getCostPoints()), + () -> assertSame(record, eventCaptor.getValue().getDrawRecord()), + () -> assertEquals(LotteryEvent.EventType.DRAW_COMPLETED, + eventCaptor.getValue().getType()), + () -> assertEquals(RECORD_ID, response.getRecordId()), + () -> assertEquals(RECORD_ID, response.getId()), + () -> assertEquals(PRIZE_ID, response.getPrizeId()), + () -> assertEquals(prize.getPrizeName(), response.getPrizeName()), + () -> assertEquals(LotteryConstants.DRAW_COST_POINTS, response.getCostPoints()), + () -> assertEquals(prize.getPrizePoints() - LotteryConstants.DRAW_COST_POINTS, + response.getNetProfit()), + () -> assertTrue(response.getIsWin()), + () -> assertNotNull(response.getDrawTime()) + ); + + ArgumentCaptor detailCaptor = ArgumentCaptor.forClass(UserPointsDetail.class); + verify(fixture.pointsDetailMapper, times(2)).insert(detailCaptor.capture()); + List details = detailCaptor.getAllValues(); + assertAll( + () -> assertEquals(-LotteryConstants.DRAW_COST_POINTS, + details.get(0).getPointsChange()), + () -> assertEquals(PointsType.LOTTERY_COST.getCode(), details.get(0).getPointsType()), + () -> assertEquals(prize.getPrizePoints(), details.get(1).getPointsChange()), + () -> assertEquals(PointsType.LOTTERY_REWARD.getCode(), details.get(1).getPointsType()) + ); + verify(fixture.stockService, never()).rollbackStock(anyLong()); + verify(fixture.lock).unlock(); + } + + @Test + void shouldMarkNoPrizeOutcomeWithoutIssuingReward() throws InterruptedException { + Fixture fixture = new Fixture(); + LotteryPrizeConfig prize = unlimitedNoPrize(); + fixture.prepareSuccessfulDependencies(prize); + + LotteryDrawResponse response = fixture.service.draw(request(), USER_ID, IP, DEVICE); + + assertAll( + () -> assertFalse(response.getIsWin()), + () -> assertEquals(-LotteryConstants.DRAW_COST_POINTS, response.getNetProfit()), + () -> assertEquals(LotteryConstants.DRAW_COST_POINTS, response.getCostPoints()) + ); + verify(fixture.pointsBalanceMapper, never()).addPoints(anyLong(), any(Integer.class)); + verify(fixture.pointsDetailMapper, times(1)).insert(any(UserPointsDetail.class)); + verify(fixture.userLimitMapper).updateContinuousNoWin(USER_ID, 3); + verify(fixture.lock).unlock(); + } + + @Test + void shouldReturnTheSameOutcomeContractFromDrawHistory() { + Fixture fixture = new Fixture(); + LotteryPrizeConfig prize = limitedPrize(); + LocalDateTime drawTime = LocalDateTime.of(2026, 7, 10, 14, 30); + LotteryDrawRecord record = new LotteryDrawRecord(); + record.setId(RECORD_ID); + record.setUserId(USER_ID); + record.setPrizeId(PRIZE_ID); + record.setPrizeLevel(prize.getPrizeLevel()); + record.setPrizePoints(prize.getPrizePoints()); + record.setCostPoints(LotteryConstants.DRAW_COST_POINTS); + record.setDrawStrategy(STRATEGY); + record.setDrawIp(IP); + record.setDrawDevice(DEVICE); + record.setCreateTime(drawTime); + LotteryRecordQueryRequest request = new LotteryRecordQueryRequest(); + when(fixture.drawRecordMapper.selectByUserId(USER_ID, request)) + .thenReturn(List.of(record)); + when(fixture.prizeConfigMapper.selectBatchIds(List.of(PRIZE_ID))) + .thenReturn(List.of(prize)); + + PageResult result = + fixture.service.getUserDrawRecords(request, USER_ID); + + assertEquals(USER_ID, request.getUserId()); + assertEquals(1, result.getRecords().size()); + LotteryDrawResponse response = result.getRecords().get(0); + assertAll( + () -> assertEquals(RECORD_ID, response.getRecordId()), + () -> assertEquals(RECORD_ID, response.getId()), + () -> assertEquals(USER_ID, response.getUserId()), + () -> assertEquals(PRIZE_ID, response.getPrizeId()), + () -> assertEquals(prize.getPrizeName(), response.getPrizeName()), + () -> assertEquals(prize.getPrizeIcon(), response.getPrizeIcon()), + () -> assertEquals(STRATEGY, response.getStrategyType()), + () -> assertEquals(IP, response.getIp()), + () -> assertEquals(DEVICE, response.getDevice()), + () -> assertEquals(LotteryConstants.DRAW_COST_POINTS, response.getCostPoints()), + () -> assertEquals(prize.getPrizePoints() - LotteryConstants.DRAW_COST_POINTS, + response.getNetProfit()), + () -> assertTrue(response.getIsWin()), + () -> assertEquals(drawTime, response.getDrawTime()) + ); + verify(fixture.drawRecordMapper).selectByUserId(USER_ID, request); + verify(fixture.prizeConfigMapper).selectBatchIds(List.of(PRIZE_ID)); + } + + @Test + void shouldMapActivePrizeListToPublicResponse() { + Fixture fixture = new Fixture(); + LotteryPrizeConfig prize = limitedPrize(); + prize.setPrizeDesc("测试描述"); + when(fixture.prizeConfigMapper.selectAllActive()).thenReturn(List.of(prize)); + + List responses = fixture.service.getPrizeList(); + + assertEquals(1, responses.size()); + LotteryPrizeResponse response = responses.get(0); + assertAll( + () -> assertEquals(PRIZE_ID, response.getPrizeId()), + () -> assertEquals(prize.getPrizeName(), response.getPrizeName()), + () -> assertEquals(prize.getPrizeLevel(), response.getPrizeLevel()), + () -> assertEquals(prize.getPrizePoints(), response.getPrizePoints()), + () -> assertEquals(prize.getPrizeIcon(), response.getPrizeIcon()), + () -> assertEquals(prize.getPrizeDesc(), response.getPrizeDesc()), + () -> assertEquals(prize.getCurrentProbability(), response.getProbability()) + ); + } + + @Test + void shouldReturnExistingUserLotteryStatistics() { + Fixture fixture = new Fixture(); + UserLotteryLimit limit = userLimit(); + when(fixture.userLimitMapper.selectByUserId(USER_ID)).thenReturn(limit); + + LotteryStatisticsResponse response = fixture.service.getUserStatistics(USER_ID); + + assertAll( + () -> assertEquals(limit.getTotalDrawCount(), response.getTotalDrawCount()), + () -> assertEquals(limit.getTotalWinCount(), response.getTotalWinCount()), + () -> assertEquals(limit.getTodayDrawCount(), response.getTodayDrawCount()), + () -> assertEquals(limit.getTodayWinCount(), response.getTodayWinCount()), + () -> assertEquals(limit.getMaxContinuousNoWin(), response.getMaxContinuousNoWin()), + () -> assertEquals(limit.getCurrentContinuousNoWin(), + response.getCurrentContinuousNoWin()) + ); + verify(fixture.userLimitMapper, never()).insert(any(UserLotteryLimit.class)); + } + + @Test + void shouldCreateZeroedLotteryStatisticsForFirstTimeUser() { + Fixture fixture = new Fixture(); + when(fixture.userLimitMapper.selectByUserId(USER_ID)).thenReturn(null); + + LotteryStatisticsResponse response = fixture.service.getUserStatistics(USER_ID); + + ArgumentCaptor limitCaptor = + ArgumentCaptor.forClass(UserLotteryLimit.class); + verify(fixture.userLimitMapper).insert(limitCaptor.capture()); + UserLotteryLimit inserted = limitCaptor.getValue(); + assertAll( + () -> assertEquals(USER_ID, inserted.getUserId()), + () -> assertEquals(0, inserted.getTodayDrawCount()), + () -> assertEquals(0, inserted.getTotalDrawCount()), + () -> assertEquals(0, inserted.getCurrentContinuousNoWin()), + () -> assertEquals(0, inserted.getIsBlacklist()), + () -> assertEquals(0, inserted.getRiskLevel()), + () -> assertEquals(0, response.getTodayDrawCount()), + () -> assertEquals(0, response.getTotalDrawCount()) + ); + } + + @Test + void shouldReturnConfiguredLotteryRules() { + Fixture fixture = new Fixture(); + + String rules = fixture.service.getLotteryRules(); + + assertAll( + () -> assertTrue(rules.contains(String.valueOf(LotteryConstants.DRAW_COST_POINTS))), + () -> assertTrue(rules.contains(String.valueOf(LotteryConstants.MAX_DRAW_PER_DAY))), + () -> assertTrue(rules.contains(String.valueOf( + LotteryConstants.DRAW_COOLDOWN_SECONDS))) + ); + } + + @Test + void shouldReturnFullRemainingCountForFirstTimeUser() { + Fixture fixture = new Fixture(); + when(fixture.userLimitMapper.selectByUserId(USER_ID)).thenReturn(null); + + Integer remaining = fixture.service.getTodayRemainingCount(USER_ID); + + assertEquals(LotteryConstants.MAX_DRAW_PER_DAY, remaining); + } + + @Test + void shouldClampRemainingCountAtZero() { + Fixture fixture = new Fixture(); + UserLotteryLimit limit = userLimit(); + limit.setTodayDrawCount(LotteryConstants.MAX_DRAW_PER_DAY + 3); + when(fixture.userLimitMapper.selectByUserId(USER_ID)).thenReturn(limit); + + Integer remaining = fixture.service.getTodayRemainingCount(USER_ID); + + assertEquals(0, remaining); + } + + @Test + void shouldTreatMissingTodayDrawCountAsZero() { + Fixture fixture = new Fixture(); + UserLotteryLimit limit = userLimit(); + limit.setTodayDrawCount(null); + when(fixture.userLimitMapper.selectByUserId(USER_ID)).thenReturn(limit); + + Integer remaining = fixture.service.getTodayRemainingCount(USER_ID); + + assertEquals(LotteryConstants.MAX_DRAW_PER_DAY, remaining); + } + + @Test + void shouldReturnEmptyHistoryWithoutPrizeLookup() { + Fixture fixture = new Fixture(); + LotteryRecordQueryRequest request = new LotteryRecordQueryRequest(); + when(fixture.drawRecordMapper.selectByUserId(USER_ID, request)) + .thenReturn(List.of()); + + PageResult result = + fixture.service.getUserDrawRecords(request, USER_ID); + + assertTrue(result.getRecords().isEmpty()); + assertEquals(USER_ID, request.getUserId()); + verifyNoInteractions(fixture.prizeConfigMapper); + } + + @Test + void shouldRollbackLimitedStockWhenRecordingFails() throws InterruptedException { + Fixture fixture = new Fixture(); + fixture.prepareSuccessfulDependencies(limitedPrize()); + when(fixture.drawRecordMapper.insert(any(LotteryDrawRecord.class))) + .thenThrow(new IllegalStateException("database unavailable")); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.draw(request(), USER_ID, IP, DEVICE) + ); + + assertEquals("抽奖失败,请稍后再试", exception.getMessage()); + verify(fixture.stockService).rollbackStock(PRIZE_ID); + verifyNoInteractions(fixture.eventPublisher); + verify(fixture.lock).unlock(); + } + + @Test + void shouldNotRollbackUnlimitedStockWhenRecordingFails() throws InterruptedException { + Fixture fixture = new Fixture(); + fixture.prepareSuccessfulDependencies(unlimitedNoPrize()); + when(fixture.drawRecordMapper.insert(any(LotteryDrawRecord.class))) + .thenThrow(new IllegalStateException("database unavailable")); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.draw(request(), USER_ID, IP, DEVICE) + ); + + assertEquals("抽奖失败,请稍后再试", exception.getMessage()); + verify(fixture.stockService, never()).rollbackStock(anyLong()); + verifyNoInteractions(fixture.eventPublisher); + verify(fixture.lock).unlock(); + } + + @Test + void shouldPreserveInterruptFlagWhenLockWaitIsInterrupted() throws InterruptedException { + Fixture fixture = new Fixture(); + when(fixture.redissonClient.getLock(LOCK_KEY)).thenReturn(fixture.lock); + when(fixture.lock.tryLock(3, TimeUnit.SECONDS)) + .thenThrow(new InterruptedException("interrupted")); + + try { + BusinessException exception = assertThrows( + BusinessException.class, + () -> fixture.service.draw(request(), USER_ID, IP, DEVICE) + ); + + assertEquals("系统繁忙,请稍后再试", exception.getMessage()); + assertTrue(Thread.currentThread().isInterrupted()); + verifyNoInteractions(fixture.chainBuilder, fixture.pointsBalanceMapper, + fixture.stockService, fixture.drawRecordMapper, fixture.eventPublisher); + verify(fixture.lock, never()).unlock(); + } finally { + Thread.interrupted(); + } + } + + private static LotteryDrawRequest request() { + LotteryDrawRequest request = new LotteryDrawRequest(); + request.setStrategyType(STRATEGY); + return request; + } + + private static LotteryPrizeConfig limitedPrize() { + LotteryPrizeConfig prize = basePrize(); + prize.setPrizeLevel(3); + prize.setPrizePoints(250); + prize.setTotalStock(100); + return prize; + } + + private static LotteryPrizeConfig unlimitedNoPrize() { + LotteryPrizeConfig prize = basePrize(); + prize.setPrizeName("未中奖"); + prize.setPrizeLevel(8); + prize.setPrizePoints(0); + prize.setTotalStock(-1); + return prize; + } + + private static LotteryPrizeConfig basePrize() { + LotteryPrizeConfig prize = new LotteryPrizeConfig(); + prize.setId(PRIZE_ID); + prize.setPrizeName("测试奖品"); + prize.setPrizeIcon("test-icon"); + prize.setCurrentProbability(new BigDecimal("0.25")); + return prize; + } + + private static UserLotteryLimit userLimit() { + UserLotteryLimit limit = new UserLotteryLimit(); + limit.setUserId(USER_ID); + limit.setTodayDrawCount(2); + limit.setWeekDrawCount(2); + limit.setMonthDrawCount(2); + limit.setTotalDrawCount(2); + limit.setTodayWinCount(1); + limit.setTotalWinCount(1); + limit.setMaxContinuousNoWin(2); + limit.setCurrentContinuousNoWin(2); + limit.setIsBlacklist(0); + limit.setRiskLevel(0); + return limit; + } + + private static UserPointsBalance balance(int points) { + UserPointsBalance balance = new UserPointsBalance(); + balance.setUserId(USER_ID); + balance.setTotalPoints(points); + return balance; + } + + private static final class Fixture { + + private final LotteryPrizeConfigMapper prizeConfigMapper = + mock(LotteryPrizeConfigMapper.class); + private final LotteryDrawRecordMapper drawRecordMapper = + mock(LotteryDrawRecordMapper.class); + private final UserLotteryLimitMapper userLimitMapper = + mock(UserLotteryLimitMapper.class); + private final UserPointsBalanceMapper pointsBalanceMapper = + mock(UserPointsBalanceMapper.class); + private final UserPointsDetailMapper pointsDetailMapper = + mock(UserPointsDetailMapper.class); + private final LotteryStrategyFactory strategyFactory = + mock(LotteryStrategyFactory.class); + private final RiskCheckChainBuilder chainBuilder = mock(RiskCheckChainBuilder.class); + private final LotteryEventPublisher eventPublisher = mock(LotteryEventPublisher.class); + private final RedissonClient redissonClient = mock(RedissonClient.class); + private final LotteryStockService stockService = mock(LotteryStockService.class); + private final LotteryEmergencyService emergencyService = + mock(LotteryEmergencyService.class); + private final RLock lock = mock(RLock.class); + private final RiskCheckHandler riskCheck = mock(RiskCheckHandler.class); + private final LotteryStrategy strategy = mock(LotteryStrategy.class); + private final List activePrizes = new java.util.ArrayList<>(); + private final LotteryServiceImpl service = new LotteryServiceImpl( + prizeConfigMapper, + drawRecordMapper, + userLimitMapper, + pointsBalanceMapper, + pointsDetailMapper, + strategyFactory, + chainBuilder, + eventPublisher, + redissonClient, + stockService, + emergencyService + ); + + private void prepareLock(boolean acquired) throws InterruptedException { + when(redissonClient.getLock(LOCK_KEY)).thenReturn(lock); + when(lock.tryLock(3, TimeUnit.SECONDS)).thenReturn(acquired); + when(lock.isHeldByCurrentThread()).thenReturn(acquired); + } + + private void prepareThroughRisk(LotteryPrizeConfig prize) throws InterruptedException { + prepareLock(true); + activePrizes.clear(); + activePrizes.add(prize); + when(prizeConfigMapper.selectAllActive()).thenReturn(activePrizes); + when(userLimitMapper.selectByUserId(USER_ID)).thenReturn(userLimit()); + when(pointsBalanceMapper.selectByUserId(USER_ID)).thenReturn(balance(1000)); + when(chainBuilder.buildChain()).thenReturn(riskCheck); + when(riskCheck.check(eq(USER_ID), any(LotteryContext.class))).thenReturn(true); + } + + private void prepareSuccessfulDependencies(LotteryPrizeConfig prize) + throws InterruptedException { + prepareThroughRisk(prize); + when(pointsBalanceMapper.selectByUserId(USER_ID)) + .thenReturn(balance(1000), balance(900), balance(1150)); + when(pointsBalanceMapper.deductPoints( + USER_ID, LotteryConstants.DRAW_COST_POINTS)).thenReturn(1); + when(pointsBalanceMapper.addPoints(USER_ID, prize.getPrizePoints())).thenReturn(1); + when(pointsDetailMapper.insert(any(UserPointsDetail.class))).thenReturn(1); + when(strategyFactory.getStrategy(STRATEGY)).thenReturn(strategy); + when(strategy.draw(USER_ID, activePrizes)).thenReturn(prize); + when(stockService.deductStock(PRIZE_ID)).thenReturn(true); + when(drawRecordMapper.insert(any(LotteryDrawRecord.class))).thenAnswer(invocation -> { + LotteryDrawRecord record = invocation.getArgument(0); + record.setId(RECORD_ID); + return 1; + }); + } + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AbstractReadonlyAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AbstractReadonlyAgentTool.java new file mode 100644 index 000000000..404ad45ea --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AbstractReadonlyAgentTool.java @@ -0,0 +1,86 @@ +package com.xiaou.system.agent; + +import com.xiaou.system.dto.AgentChatArtifact; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 只读 AgentTool 通用基类,统一 definition、preview 和结果包装。 + * + * @author xiaou + */ +public abstract class AbstractReadonlyAgentTool implements AgentTool { + + private final AgentToolDefinition definition; + + protected AbstractReadonlyAgentTool(AgentToolDefinition definition) { + if (!isReadonly(definition)) { + throw new IllegalArgumentException("AbstractReadonlyAgentTool only accepts readonly definitions"); + } + this.definition = definition; + } + + @Override + public final AgentToolDefinition definition() { + return definition; + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary(readonlyPreviewSummary()); + return preview; + } + + protected AgentToolCall call(String summary, Map input) { + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition.getName()); + call.setSummary(summary); + call.setInput(input == null ? new LinkedHashMap<>() : new LinkedHashMap<>(input)); + return call; + } + + protected AgentChatArtifact artifact(String type, String title, Map data) { + return new AgentChatArtifact(type, title, data == null ? Map.of() : data); + } + + protected AgentToolResult success(String summary, AgentChatArtifact artifact, List nextActions) { + return success(summary, artifact == null ? List.of() : List.of(artifact), nextActions); + } + + protected AgentToolResult success(String summary, List artifacts, List nextActions) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary(summary); + if (artifacts != null) { + result.getArtifacts().addAll(artifacts); + } + if (nextActions != null) { + result.getNextActions().addAll(nextActions); + } + return result; + } + + protected boolean containsAny(String value, List keywords) { + String normalized = normalize(value); + return keywords != null && keywords.stream().anyMatch(normalized::contains); + } + + protected String normalize(String value) { + return value == null ? "" : value.trim(); + } + + protected String readonlyPreviewSummary() { + return definition.getTitle() + "是只读动作,不需要写入预览。"; + } + + private boolean isReadonly(AgentToolDefinition definition) { + return definition != null + && "readonly".equalsIgnoreCase(normalize(definition.getRiskLevel())) + && "READONLY".equalsIgnoreCase(normalize(definition.getRiskCategory())) + && !definition.isDestructive() + && !definition.isConfirmationRequired(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentChatErrorCode.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentChatErrorCode.java new file mode 100644 index 000000000..ba9d708da --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentChatErrorCode.java @@ -0,0 +1,26 @@ +package com.xiaou.system.agent; + +/** + * 管理员智能体统一错误码。 + * + * @author xiaou + */ +public enum AgentChatErrorCode { + + EMPTY_MESSAGE, + TOOL_NOT_FOUND, + PLAN_CLARIFICATION_REQUIRED, + POLICY_REJECTED, + PREVIEW_BLOCKED, + SCHEMA_VALIDATION_FAILED, + AUDIT_NOT_FOUND, + AUDIT_OPERATOR_MISMATCH, + AUDIT_STATUS_NOT_PREVIEW, + AUDIT_PAYLOAD_INVALID, + CONFIRMATION_MISMATCH, + TOOL_EXECUTION_FAILED; + + public String code() { + return name(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentChatOrchestrator.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentChatOrchestrator.java new file mode 100644 index 000000000..6f738bc33 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentChatOrchestrator.java @@ -0,0 +1,776 @@ +package com.xiaou.system.agent; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.system.dto.AgentAuditPreviewRequest; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentAuditResultRequest; +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.dto.AgentChatConfirmation; +import com.xiaou.system.dto.AgentChatRequest; +import com.xiaou.system.dto.AgentChatResponse; +import com.xiaou.system.service.SysAgentAuditService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** + * 管理员智能体后端运行时。前端只需要调用 chat 这一个深接口。 + * + * @author xiaou + */ +@Slf4j +@Service +public class AgentChatOrchestrator { + + private static final TypeReference> MAP_TYPE = new TypeReference<>() { + }; + private static final TypeReference> ARTIFACT_LIST_TYPE = new TypeReference<>() { + }; + private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() { + }; + private static final String RECOVERY_QUERY_STATE = "查询目标对象当前状态,确认是否发生部分写入。"; + private static final String RECOVERY_RETRY_WITH_NEW_PREVIEW = "修复外部依赖、权限或输入数据后,重新发起同一自然语言请求生成新的预览。"; + + private final AgentToolRegistry toolRegistry; + private final AgentPlanResolver planResolver; + private final AgentPolicyEngine policyEngine; + private final SysAgentAuditService auditService; + private final ObjectMapper objectMapper; + private final AgentSessionContextStore sessionContextStore; + private final AgentToolMetricsRecorder metricsRecorder; + + @Autowired + public AgentChatOrchestrator( + AgentToolRegistry toolRegistry, + AgentPlanResolver planResolver, + AgentPolicyEngine policyEngine, + SysAgentAuditService auditService, + ObjectMapper objectMapper, + AgentSessionContextStore sessionContextStore, + AgentToolMetricsRecorder metricsRecorder + ) { + this.toolRegistry = toolRegistry; + this.planResolver = planResolver; + this.policyEngine = policyEngine; + this.auditService = auditService; + this.objectMapper = objectMapper; + this.sessionContextStore = sessionContextStore; + this.metricsRecorder = metricsRecorder; + } + + public AgentChatOrchestrator( + AgentToolRegistry toolRegistry, + AgentPlanResolver planResolver, + AgentPolicyEngine policyEngine, + SysAgentAuditService auditService, + ObjectMapper objectMapper, + AgentSessionContextStore sessionContextStore + ) { + this( + toolRegistry, + planResolver, + policyEngine, + auditService, + objectMapper, + sessionContextStore, + null + ); + } + + public AgentChatOrchestrator( + AgentToolRegistry toolRegistry, + AgentPlanResolver planResolver, + AgentPolicyEngine policyEngine, + SysAgentAuditService auditService, + ObjectMapper objectMapper + ) { + this(toolRegistry, planResolver, policyEngine, auditService, objectMapper, new AgentSessionContextStore()); + } + + public AgentChatResponse chat(AgentChatRequest request, AgentOperator operator) { + AgentChatRequest safeRequest = request == null ? new AgentChatRequest() : request; + String sessionId = normalize(safeRequest.getSessionId()); + Long currentOperatorId = operator == null ? null : operator.id(); + AgentRuntimeTrace trace = AgentRuntimeTrace.start(); + AgentSessionSnapshot sessionSnapshot = sessionContextStore.snapshot(sessionId, currentOperatorId); + AgentExecutionContext context = new AgentExecutionContext( + sessionId, + normalize(safeRequest.getMessage()), + operator, + trace, + sessionSnapshot + ); + context.markTrace("request.received", "done", + StringUtils.hasText(safeRequest.getAuditId()) ? "continue audited action" : "start new action"); + context.markTrace("session.loaded", "done", + "recentTurns=" + sessionSnapshot.getRecentTurns().size()); + + AgentChatResponse response; + if (StringUtils.hasText(safeRequest.getAuditId())) { + response = continueAuditedAction(safeRequest, context); + } else { + response = startNewAction(safeRequest, context); + } + sessionContextStore.record(safeRequest, response, currentOperatorId); + log.info("管理员智能体请求完成 traceId={}, status={}, toolName={}, operatorId={}", + response.getTraceId(), response.getStatus(), response.getToolName(), operatorId(context)); + return response; + } + + private AgentChatResponse startNewAction(AgentChatRequest request, AgentExecutionContext context) { + if (!StringUtils.hasText(context.message())) { + return rejected(context, AgentChatErrorCode.EMPTY_MESSAGE, "你还没有输入要我处理的管理员请求。"); + } + + AgentPlanResolution plan = planResolver.resolvePlan(context); + if (plan.isResolved()) { + context.markTrace("plan.resolved", "done", safeToolName(plan.getResolvedCall())); + return requireRegisteredTool(plan.getResolvedCall()) + .map(registered -> handleResolvedCall(request, context, registered)) + .orElseGet(() -> toolNotFound(context, "计划解析器返回了未注册的后端工具,已拒绝执行。")); + } + if (plan.getErrorCode() != null) { + context.markTrace("plan.clarification", "blocked", safe(plan.getMessage())); + return rejectedByPlan(context, plan); + } + return toolNotFound(context, "我还没有找到可安全执行这个请求的后端工具。"); + } + + private AgentChatResponse rejectedByPlan(AgentExecutionContext context, AgentPlanResolution plan) { + AgentChatResponse response = rejected(context, plan.getErrorCode(), plan.getMessage()); + response.getNextActions().addAll(plan.getNextActions()); + return response; + } + + private Optional requireRegisteredTool(AgentResolvedToolCall resolved) { + if (resolved == null || resolved.call() == null) { + return Optional.empty(); + } + + String callToolName = normalize(resolved.call().getToolName()); + String definitionToolName = resolved.tool() == null || resolved.tool().definition() == null + ? "" + : normalize(resolved.tool().definition().getName()); + String toolName = StringUtils.hasText(callToolName) ? callToolName : definitionToolName; + if (!StringUtils.hasText(toolName)) { + return Optional.empty(); + } + + return toolRegistry.find(toolName) + .map(tool -> { + resolved.call().setToolName(tool.definition().getName()); + return new AgentResolvedToolCall(tool, resolved.call()); + }); + } + + private AgentChatResponse toolNotFound(AgentExecutionContext context, String message) { + context.markTrace("tool.not_found", "rejected", message); + AgentChatResponse response = rejected(context, AgentChatErrorCode.TOOL_NOT_FOUND, message); + response.getArtifacts().add(new com.xiaou.system.dto.AgentChatArtifact( + "toolCatalog", + "当前已注册工具", + Map.of("tools", toolRegistry.definitions()) + )); + return response; + } + + private AgentChatResponse handleResolvedCall( + AgentChatRequest request, + AgentExecutionContext context, + AgentResolvedToolCall resolved + ) { + AgentTool tool = resolved.tool(); + AgentToolCall call = resolved.call(); + AgentToolDefinition definition = tool.definition(); + context.markTrace("tool.registered", "done", definition.getName()); + AgentPolicyDecision decision = policyEngine.evaluate(definition, call.getInput(), context.operator()); + context.markTrace("policy.evaluated", + decision.isAllowed() ? (decision.isConfirmationRequired() ? "confirmation_required" : "allowed") : "rejected", + definition.getName()); + if (!decision.isAllowed()) { + return rejectedByPolicy(context, definition, decision); + } + + if (decision.isConfirmationRequired()) { + return previewAndRequireConfirmation(request, context, tool, call, decision); + } + + AgentToolResult result = executeToolSafely(tool, call, context); + if (!result.isSuccess()) { + appendFailureRecoveryAdvice(result.getNextActions(), null); + appendFailureRecoveryArtifact(result.getArtifacts(), null, definition, result.getErrorMessage()); + } + AgentChatResponse response = base(context); + response.setStatus(result.isSuccess() ? "answered" : "error"); + response.setAnswer(result.getSummary()); + response.setToolName(definition.getName()); + response.setRiskLevel(definition.getRiskLevel()); + response.setRiskCategory(definition.getRiskCategory()); + response.setDiff(result.getDiff()); + response.setArtifacts(result.getArtifacts()); + response.setNextActions(result.getNextActions()); + if (!result.isSuccess()) { + response.setErrorCode(AgentChatErrorCode.TOOL_EXECUTION_FAILED.code()); + response.setErrorMessage(result.getErrorMessage()); + } + return response; + } + + private AgentChatResponse previewAndRequireConfirmation( + AgentChatRequest request, + AgentExecutionContext context, + AgentTool tool, + AgentToolCall call, + AgentPolicyDecision decision + ) { + AgentToolDefinition definition = tool.definition(); + AgentToolPreview preview; + long startNanos = System.nanoTime(); + try { + preview = tool.preview(call, context); + } catch (Exception e) { + recordToolMetric(definition, context, "preview", "error", System.nanoTime() - startNanos); + log.warn("智能体工具预览失败 tool={}, message={}", definition.getName(), e.getMessage()); + context.markTrace("tool.previewed", "error", exceptionMessage(e)); + return error(context, AgentChatErrorCode.TOOL_EXECUTION_FAILED, definition, + "工具预览失败:" + exceptionMessage(e), exceptionMessage(e)); + } + recordToolMetric(definition, context, "preview", preview.isExecutable() ? "success" : "blocked", + System.nanoTime() - startNanos); + context.markTrace("tool.previewed", preview.isExecutable() ? "done" : "blocked", definition.getName()); + + if (!preview.isExecutable()) { + context.markTrace("preview.blocked", "rejected", + StringUtils.hasText(preview.getBlockedReason()) ? preview.getBlockedReason() : preview.getSummary()); + AgentChatResponse response = rejected( + context, + AgentChatErrorCode.PREVIEW_BLOCKED, + StringUtils.hasText(preview.getBlockedReason()) ? preview.getBlockedReason() : preview.getSummary() + ); + response.setToolName(definition.getName()); + response.setRiskLevel(definition.getRiskLevel()); + response.setRiskCategory(definition.getRiskCategory()); + response.setPlan(preview.getPlan()); + response.setDiff(preview.getDiff()); + response.setArtifacts(preview.getArtifacts()); + return response; + } + + String confirmationId = "agent-confirmation-" + UUID.randomUUID(); + String idempotencyKey = "agent-idempotency-" + UUID.randomUUID(); + + AgentAuditPreviewRequest auditRequest = new AgentAuditPreviewRequest(); + auditRequest.setConfirmationId(confirmationId); + auditRequest.setIdempotencyKey(idempotencyKey); + auditRequest.setUserMessage(limit(request.getMessage(), 1000)); + auditRequest.setIntent(definition.getIntent()); + auditRequest.setActionId(definition.getName()); + auditRequest.setRoute(definition.getRoute()); + auditRequest.setRiskLevel(definition.getRiskLevel()); + auditRequest.setRiskCategory(definition.getRiskCategory()); + auditRequest.setSummary(preview.getSummary()); + auditRequest.setPayloadJson(toJson(call.getInput())); + auditRequest.setDiffJson(toJson(preview.getDiff())); + auditRequest.setPlanJson(toJson(preview.getPlan())); + + AgentAuditResponse audit = auditService.createPreview(auditRequest, operatorId(context), operatorName(context)); + context.markTrace("audit.preview_created", "done", audit.getAuditId()); + + AgentChatConfirmation confirmation = new AgentChatConfirmation(); + confirmation.setAuditId(audit.getAuditId()); + confirmation.setConfirmationId(confirmationId); + confirmation.setRequiredText(decision.getConfirmationText()); + confirmation.setPrompt("这是写入/破坏性动作。确认无误后请输入:" + decision.getConfirmationText()); + + AgentChatResponse response = base(context); + response.setStatus("confirm_required"); + response.setAnswer(preview.getSummary() + " 确认无误后请输入:" + decision.getConfirmationText()); + response.setAuditId(audit.getAuditId()); + response.setIdempotencyKey(audit.getIdempotencyKey()); + response.setToolName(definition.getName()); + response.setRiskLevel(definition.getRiskLevel()); + response.setRiskCategory(definition.getRiskCategory()); + response.setPlan(preview.getPlan()); + response.setDiff(preview.getDiff()); + response.setArtifacts(preview.getArtifacts()); + response.setConfirmation(confirmation); + response.getNextActions().addAll(decision.getNextActions()); + return response; + } + + private AgentChatResponse continueAuditedAction(AgentChatRequest request, AgentExecutionContext context) { + AgentAuditResponse audit = auditService.getByAuditId(request.getAuditId()); + if (audit == null) { + context.markTrace("audit.loaded", "missing", request.getAuditId()); + return rejected(context, AgentChatErrorCode.AUDIT_NOT_FOUND, "没有找到这次待确认的智能体审计记录。"); + } + context.markTrace("audit.loaded", "done", audit.getAuditId()); + + Long currentOperatorId = operatorId(context); + if (currentOperatorId == null || audit.getOperatorId() == null || !audit.getOperatorId().equals(currentOperatorId)) { + AgentChatResponse response = rejected(context, AgentChatErrorCode.AUDIT_OPERATOR_MISMATCH, "这次预览不是由当前管理员发起,不能继续执行。"); + response.setAuditId(audit.getAuditId()); + response.setIdempotencyKey(audit.getIdempotencyKey()); + response.setToolName(audit.getActionId()); + response.setRiskLevel(audit.getRiskLevel()); + response.setRiskCategory(audit.getRiskCategory()); + return response; + } + + if (isCancelRequest(request)) { + if (!"PREVIEW".equals(audit.getStatus())) { + return auditStatusRejected(context, audit, "这次审计记录不是待确认状态,不能继续处理。", audit.getStatus()); + } + AgentAuditResponse cancelled; + try { + cancelled = auditService.cancel(audit.getAuditId(), "管理员通过统一聊天接口取消"); + } catch (IllegalStateException e) { + return auditStatusRejected(context, audit, "这次审计记录状态已变化,取消请求没有生效。", exceptionMessage(e)); + } + context.markTrace("audit.cancelled", "done", cancelled.getAuditId()); + AgentChatResponse response = base(context); + response.setStatus("cancelled"); + response.setAnswer("已取消本次智能体预览,没有执行写入动作。"); + response.setAuditId(cancelled.getAuditId()); + response.setIdempotencyKey(cancelled.getIdempotencyKey()); + return response; + } + + if (!"PREVIEW".equals(audit.getStatus())) { + if (isTerminalStatus(audit.getStatus())) { + return replayTerminalAudit(context, audit); + } + return auditStatusRejected(context, audit, "这次审计记录不是待确认状态,不能继续处理。", audit.getStatus()); + } + + AgentTool tool = toolRegistry.find(audit.getActionId()).orElse(null); + if (tool == null) { + context.markTrace("tool.not_found", "rejected", audit.getActionId()); + AgentChatResponse response = rejected(context, AgentChatErrorCode.TOOL_NOT_FOUND, "审计记录中的工具已不存在,不能继续执行。"); + response.setAuditId(audit.getAuditId()); + response.setIdempotencyKey(audit.getIdempotencyKey()); + response.setToolName(audit.getActionId()); + response.setRiskLevel(audit.getRiskLevel()); + response.setRiskCategory(audit.getRiskCategory()); + return response; + } + + Map input; + try { + input = parsePayload(audit.getPayloadJson()); + } catch (IllegalArgumentException e) { + context.markTrace("audit.payload_parsed", "error", exceptionMessage(e)); + AgentChatResponse response = rejected(context, AgentChatErrorCode.AUDIT_PAYLOAD_INVALID, "审计记录中的工具输入无法解析,不能继续执行。"); + response.setAuditId(audit.getAuditId()); + response.setIdempotencyKey(audit.getIdempotencyKey()); + response.setToolName(tool.definition().getName()); + response.setRiskLevel(tool.definition().getRiskLevel()); + response.setRiskCategory(tool.definition().getRiskCategory()); + return response; + } + AgentPolicyDecision decision = policyEngine.evaluate(tool.definition(), input, context.operator()); + context.markTrace("policy.evaluated", + decision.isAllowed() ? "confirmation_required" : "rejected", + tool.definition().getName()); + if (!decision.isAllowed()) { + AgentChatResponse response = rejectedByPolicy(context, tool.definition(), decision); + response.setAuditId(audit.getAuditId()); + response.setIdempotencyKey(audit.getIdempotencyKey()); + return response; + } + + if (!policyEngine.confirmationMatches(decision.getConfirmationText(), request.getConfirmationText())) { + context.markTrace("confirmation.checked", "rejected", "confirmation text mismatch"); + AgentChatResponse response = rejected(context, AgentChatErrorCode.CONFIRMATION_MISMATCH, "强确认文本不匹配,写入动作没有执行。"); + response.setAuditId(audit.getAuditId()); + response.setIdempotencyKey(audit.getIdempotencyKey()); + response.setToolName(tool.definition().getName()); + response.setRiskLevel(tool.definition().getRiskLevel()); + response.setRiskCategory(tool.definition().getRiskCategory()); + response.getNextActions().add("请输入完全一致的确认文本:" + decision.getConfirmationText()); + return response; + } + + try { + auditService.confirm(audit.getAuditId()); + } catch (IllegalStateException e) { + AgentAuditResponse latest = auditService.getByAuditId(audit.getAuditId()); + if (latest != null && isTerminalStatus(latest.getStatus())) { + return replayTerminalAudit(context, latest); + } + return auditStatusRejected(context, audit, "这次审计记录状态已变化,写入动作没有执行。", exceptionMessage(e)); + } + context.markTrace("audit.confirmed", "done", audit.getAuditId()); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setSummary(audit.getSummary()); + call.setInput(input); + + AgentToolResult result = executeToolSafely(tool, call, context); + if (!result.isSuccess()) { + appendFailureRecoveryAdvice(result.getNextActions(), audit.getAuditId()); + appendFailureRecoveryArtifact(result.getArtifacts(), audit, tool.definition(), result.getErrorMessage()); + } + AgentAuditResultRequest resultRequest = new AgentAuditResultRequest(); + resultRequest.setSuccess(result.isSuccess()); + resultRequest.setResultJson(toJson(Map.of( + "summary", safe(result.getSummary()), + "artifacts", result.getArtifacts(), + "nextActions", result.getNextActions() + ))); + resultRequest.setErrorMessage(result.getErrorMessage()); + AgentAuditResponse updatedAudit = auditService.recordResult(audit.getAuditId(), resultRequest); + context.markTrace("audit.result_recorded", result.isSuccess() ? "done" : "error", updatedAudit.getAuditId()); + + AgentChatResponse response = base(context); + response.setStatus(result.isSuccess() ? "executed" : "error"); + response.setAnswer(result.getSummary()); + response.setAuditId(updatedAudit.getAuditId()); + response.setIdempotencyKey(updatedAudit.getIdempotencyKey()); + response.setToolName(tool.definition().getName()); + response.setRiskLevel(tool.definition().getRiskLevel()); + response.setRiskCategory(tool.definition().getRiskCategory()); + response.setDiff(result.getDiff()); + response.setArtifacts(result.getArtifacts()); + response.setNextActions(result.getNextActions()); + if (!result.isSuccess()) { + response.setErrorCode(AgentChatErrorCode.TOOL_EXECUTION_FAILED.code()); + response.setErrorMessage(result.getErrorMessage()); + } + return response; + } + + private AgentChatResponse auditStatusRejected( + AgentExecutionContext context, + AgentAuditResponse audit, + String message, + String detail + ) { + context.markTrace("audit.status_checked", "rejected", detail); + AgentChatResponse response = rejected(context, AgentChatErrorCode.AUDIT_STATUS_NOT_PREVIEW, message); + response.setAuditId(audit.getAuditId()); + response.setIdempotencyKey(audit.getIdempotencyKey()); + response.setToolName(audit.getActionId()); + response.setRiskLevel(audit.getRiskLevel()); + response.setRiskCategory(audit.getRiskCategory()); + return response; + } + + private AgentChatResponse replayTerminalAudit(AgentExecutionContext context, AgentAuditResponse audit) { + context.markTrace("audit.idempotent_replay", "done", audit.getAuditId()); + Map resultData = parseResultJson(audit.getResultJson()); + AgentChatResponse response = base(context); + boolean success = "EXECUTED".equals(audit.getStatus()); + response.setStatus(success ? "executed" : "error"); + response.setAnswer(resolveReplaySummary(audit, resultData, success)); + response.setAuditId(audit.getAuditId()); + response.setIdempotencyKey(audit.getIdempotencyKey()); + response.setToolName(audit.getActionId()); + response.setRiskLevel(audit.getRiskLevel()); + response.setRiskCategory(audit.getRiskCategory()); + response.setArtifacts(convertArtifacts(resultData.get("artifacts"))); + response.setNextActions(convertStrings(resultData.get("nextActions"))); + if (!success) { + appendFailureRecoveryAdvice(response.getNextActions(), audit.getAuditId()); + appendFailureRecoveryArtifact(response.getArtifacts(), audit, null, audit.getErrorMessage()); + response.setErrorCode(AgentChatErrorCode.TOOL_EXECUTION_FAILED.code()); + response.setErrorMessage(StringUtils.hasText(audit.getErrorMessage()) ? audit.getErrorMessage() : response.getAnswer()); + } + response.getNextActions().add("这是一次幂等重放,目标工具没有再次执行。"); + return response; + } + + private boolean isTerminalStatus(String status) { + return "EXECUTED".equals(status) || "FAILED".equals(status); + } + + private boolean isCancelRequest(AgentChatRequest request) { + String message = normalize(request.getMessage()); + return "取消".equals(message) || "cancel".equalsIgnoreCase(message); + } + + private AgentChatResponse base(AgentExecutionContext context) { + AgentChatResponse response = new AgentChatResponse(); + response.setSessionId(context.sessionId()); + response.setTraceId(context.traceId()); + if (context.trace() != null) { + response.setTrace(context.trace().steps()); + } + return response; + } + + private AgentChatResponse rejectedByPolicy(AgentExecutionContext context, AgentToolDefinition definition, AgentPolicyDecision decision) { + context.markTrace("policy.rejected", "rejected", decision.getRejectionReason()); + AgentChatErrorCode errorCode = decision.getErrorCode() == null + ? AgentChatErrorCode.POLICY_REJECTED + : decision.getErrorCode(); + AgentChatResponse response = rejected(context, errorCode, decision.getRejectionReason()); + response.setToolName(definition.getName()); + response.setRiskLevel(definition.getRiskLevel()); + response.setRiskCategory(definition.getRiskCategory()); + response.getNextActions().addAll(decision.getNextActions()); + return response; + } + + private AgentToolResult executeToolSafely(AgentTool tool, AgentToolCall call, AgentExecutionContext context) { + String toolName = tool == null || tool.definition() == null ? "" : tool.definition().getName(); + AgentToolDefinition definition = tool == null ? null : tool.definition(); + long startNanos = System.nanoTime(); + try { + AgentToolResult result = tool.execute(call, context); + recordToolMetric(definition, context, "execute", result.isSuccess() ? "success" : "error", + System.nanoTime() - startNanos); + context.markTrace("tool.executed", result.isSuccess() ? "done" : "error", toolName); + return result; + } catch (Exception e) { + recordToolMetric(definition, context, "execute", "error", System.nanoTime() - startNanos); + log.warn("智能体工具执行失败 tool={}, message={}", toolName, e.getMessage()); + context.markTrace("tool.executed", "error", toolName + ": " + exceptionMessage(e)); + AgentToolResult result = new AgentToolResult(); + result.setSuccess(false); + result.setSummary("工具执行失败:" + exceptionMessage(e)); + result.setErrorMessage(exceptionMessage(e)); + appendFailureRecoveryAdvice(result.getNextActions(), null); + return result; + } + } + + private AgentChatResponse error( + AgentExecutionContext context, + AgentChatErrorCode code, + AgentToolDefinition definition, + String answer, + String errorMessage + ) { + context.markTrace("response.error", "error", code.code()); + AgentChatResponse response = base(context); + response.setStatus("error"); + response.setAnswer(answer); + response.setErrorCode(code.code()); + response.setErrorMessage(errorMessage); + if (definition != null) { + response.setToolName(definition.getName()); + response.setRiskLevel(definition.getRiskLevel()); + response.setRiskCategory(definition.getRiskCategory()); + } + response.getNextActions().add("请先查询当前状态,确认后台数据是否稳定。"); + return response; + } + + private void recordToolMetric( + AgentToolDefinition definition, + AgentExecutionContext context, + String phase, + String outcome, + long durationNanos + ) { + if (metricsRecorder == null) { + return; + } + AgentToolMetricSample sample = new AgentToolMetricSample(); + sample.setToolName(definition == null ? "" : definition.getName()); + sample.setPhase(phase); + sample.setOutcome(outcome); + sample.setRiskLevel(definition == null ? "" : definition.getRiskLevel()); + sample.setRiskCategory(definition == null ? "" : definition.getRiskCategory()); + sample.setDurationNanos(durationNanos); + sample.setTraceId(context == null ? "" : context.traceId()); + sample.setSessionId(context == null ? "" : context.sessionId()); + metricsRecorder.recordInvocation(sample); + } + + private AgentChatResponse rejected(AgentExecutionContext context, AgentChatErrorCode code, String message) { + context.markTrace("response.rejected", "rejected", code.code()); + AgentChatResponse response = base(context); + response.setStatus("rejected"); + response.setAnswer(message); + response.setErrorCode(code.code()); + response.setErrorMessage(message); + response.getNextActions().add("请尝试更明确地说明要查询或操作的后台对象。"); + return response; + } + + private Map parsePayload(String payloadJson) { + if (!StringUtils.hasText(payloadJson)) { + return new LinkedHashMap<>(); + } + try { + return objectMapper.readValue(payloadJson, MAP_TYPE); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("智能体审计 payload 无法解析", e); + } + } + + private Map parseResultJson(String resultJson) { + if (!StringUtils.hasText(resultJson)) { + return new LinkedHashMap<>(); + } + try { + return objectMapper.readValue(resultJson, MAP_TYPE); + } catch (JsonProcessingException e) { + return new LinkedHashMap<>(); + } + } + + private String resolveReplaySummary(AgentAuditResponse audit, Map resultData, boolean success) { + Object summaryValue = resultData.get("summary"); + String summary = normalize(summaryValue == null ? "" : String.valueOf(summaryValue)); + if (StringUtils.hasText(summary)) { + return summary; + } + if (StringUtils.hasText(audit.getSummary())) { + return audit.getSummary(); + } + return success ? "该写入动作此前已执行成功。" : "该写入动作此前已执行失败。"; + } + + private java.util.List convertArtifacts(Object value) { + if (value == null) { + return new java.util.ArrayList<>(); + } + try { + return objectMapper.convertValue(value, ARTIFACT_LIST_TYPE); + } catch (IllegalArgumentException e) { + return new java.util.ArrayList<>(); + } + } + + private java.util.List convertStrings(Object value) { + if (value == null) { + return new java.util.ArrayList<>(); + } + try { + return objectMapper.convertValue(value, STRING_LIST_TYPE); + } catch (IllegalArgumentException e) { + return new java.util.ArrayList<>(); + } + } + + private void appendFailureRecoveryAdvice(java.util.List nextActions, String auditId) { + if (nextActions == null) { + return; + } + addIfMissing(nextActions, RECOVERY_QUERY_STATE); + if (StringUtils.hasText(auditId)) { + addIfMissing(nextActions, "查看审计详情 " + auditId + ",核对 payload、result 和 errorMessage。"); + } + addIfMissing(nextActions, RECOVERY_RETRY_WITH_NEW_PREVIEW); + } + + private void addIfMissing(java.util.List nextActions, String action) { + if (StringUtils.hasText(action) && !nextActions.contains(action)) { + nextActions.add(action); + } + } + + private void appendFailureRecoveryArtifact( + java.util.List artifacts, + AgentAuditResponse audit, + AgentToolDefinition definition, + String errorMessage + ) { + if (artifacts == null || artifacts.stream().anyMatch(artifact -> "failureRecovery".equals(artifact.getType()))) { + return; + } + Map data = new LinkedHashMap<>(); + putIfPresent(data, "auditId", audit == null ? null : audit.getAuditId()); + putIfPresent(data, "idempotencyKey", audit == null ? null : audit.getIdempotencyKey()); + putIfPresent(data, "toolName", resolveToolName(audit, definition)); + putIfPresent(data, "riskLevel", resolveRiskLevel(audit, definition)); + putIfPresent(data, "riskCategory", resolveRiskCategory(audit, definition)); + putIfPresent(data, "status", audit == null ? null : audit.getStatus()); + putIfPresent(data, "errorMessage", errorMessage); + data.put("retryPolicy", "NEW_PREVIEW_REQUIRED"); + data.put("sameAuditRetryAllowed", false); + data.put("requiresNewPreview", true); + data.put("recommendedActions", java.util.List.of( + RECOVERY_QUERY_STATE, + RECOVERY_RETRY_WITH_NEW_PREVIEW + )); + artifacts.add(new AgentChatArtifact("failureRecovery", "失败恢复上下文", data)); + } + + private String resolveToolName(AgentAuditResponse audit, AgentToolDefinition definition) { + if (audit != null && StringUtils.hasText(audit.getActionId())) { + return audit.getActionId(); + } + return definition == null ? "" : safe(definition.getName()); + } + + private String resolveRiskLevel(AgentAuditResponse audit, AgentToolDefinition definition) { + if (audit != null && StringUtils.hasText(audit.getRiskLevel())) { + return audit.getRiskLevel(); + } + return definition == null ? "" : safe(definition.getRiskLevel()); + } + + private String resolveRiskCategory(AgentAuditResponse audit, AgentToolDefinition definition) { + if (audit != null && StringUtils.hasText(audit.getRiskCategory())) { + return audit.getRiskCategory(); + } + return definition == null ? "" : safe(definition.getRiskCategory()); + } + + private void putIfPresent(Map data, String key, String value) { + if (StringUtils.hasText(value)) { + data.put(key, value); + } + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("智能体数据无法序列化", e); + } + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } + + private String limit(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } + + private String safe(String value) { + return value == null ? "" : value; + } + + private String safeToolName(AgentResolvedToolCall resolved) { + if (resolved == null) { + return ""; + } + if (resolved.call() != null && StringUtils.hasText(resolved.call().getToolName())) { + return resolved.call().getToolName(); + } + if (resolved.tool() != null && resolved.tool().definition() != null) { + return safe(resolved.tool().definition().getName()); + } + return ""; + } + + private Long operatorId(AgentExecutionContext context) { + return context.operator() == null ? null : context.operator().id(); + } + + private String operatorName(AgentExecutionContext context) { + return context.operator() == null ? "" : context.operator().name(); + } + + private String exceptionMessage(Exception e) { + return StringUtils.hasText(e.getMessage()) ? e.getMessage() : e.getClass().getSimpleName(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentExecutionContext.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentExecutionContext.java new file mode 100644 index 000000000..00f2589bf --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentExecutionContext.java @@ -0,0 +1,39 @@ +package com.xiaou.system.agent; + +/** + * 单次智能体执行上下文。 + * + * @author xiaou + */ +public record AgentExecutionContext( + String sessionId, + String message, + AgentOperator operator, + AgentRuntimeTrace trace, + AgentSessionSnapshot sessionSnapshot +) { + + public AgentExecutionContext(String sessionId, String message, AgentOperator operator) { + this(sessionId, message, operator, AgentRuntimeTrace.start(), emptySnapshot(sessionId)); + } + + public AgentExecutionContext(String sessionId, String message, AgentOperator operator, AgentRuntimeTrace trace) { + this(sessionId, message, operator, trace, emptySnapshot(sessionId)); + } + + public String traceId() { + return trace == null ? "" : trace.traceId(); + } + + public void markTrace(String stage, String status, String detail) { + if (trace != null) { + trace.mark(stage, status, detail); + } + } + + private static AgentSessionSnapshot emptySnapshot(String sessionId) { + AgentSessionSnapshot snapshot = new AgentSessionSnapshot(); + snapshot.setSessionId(sessionId == null ? "" : sessionId.trim()); + return snapshot; + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentOperator.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentOperator.java new file mode 100644 index 000000000..7d10da2a6 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentOperator.java @@ -0,0 +1,57 @@ +package com.xiaou.system.agent; + +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; + +/** + * 当前智能体操作者。 + * + * @author xiaou + */ +public record AgentOperator( + Long id, + String name, + String tenantId, + List roles, + List permissions +) { + + public AgentOperator(Long id, String name) { + this(id, name, "", List.of(), List.of()); + } + + public AgentOperator { + tenantId = normalize(tenantId); + roles = normalizeList(roles); + permissions = normalizeList(permissions); + } + + public boolean hasRole(String role) { + return roles.contains(normalize(role)); + } + + public boolean hasPermission(String permission) { + String normalizedPermission = normalize(permission); + return permissions.contains(normalizedPermission) || permissions.contains("*"); + } + + private static String normalize(String value) { + return value == null ? "" : value.trim(); + } + + private static List normalizeList(List values) { + if (values == null || values.isEmpty()) { + return List.of(); + } + LinkedHashSet normalized = new LinkedHashSet<>(); + for (String value : values) { + if (StringUtils.hasText(value)) { + normalized.add(value.trim()); + } + } + return List.copyOf(new ArrayList<>(normalized)); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentPlanResolution.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentPlanResolution.java new file mode 100644 index 000000000..8aa3cace4 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentPlanResolution.java @@ -0,0 +1,52 @@ +package com.xiaou.system.agent; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * 智能体计划解析结果,可表达候选工具调用或需要澄清的原因。 + * + * @author xiaou + */ +@Data +public class AgentPlanResolution { + + private AgentResolvedToolCall resolvedCall; + + private AgentChatErrorCode errorCode; + + private String message; + + private List nextActions = new ArrayList<>(); + + public boolean isResolved() { + return resolvedCall != null; + } + + public Optional resolved() { + return Optional.ofNullable(resolvedCall); + } + + public static AgentPlanResolution resolved(AgentResolvedToolCall resolvedCall) { + AgentPlanResolution resolution = new AgentPlanResolution(); + resolution.setResolvedCall(resolvedCall); + return resolution; + } + + public static AgentPlanResolution empty() { + return new AgentPlanResolution(); + } + + public static AgentPlanResolution clarification(String message, List nextActions) { + AgentPlanResolution resolution = new AgentPlanResolution(); + resolution.setErrorCode(AgentChatErrorCode.PLAN_CLARIFICATION_REQUIRED); + resolution.setMessage(message); + if (nextActions != null) { + resolution.getNextActions().addAll(nextActions); + } + return resolution; + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentPlanResolver.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentPlanResolver.java new file mode 100644 index 000000000..b1f12fea2 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentPlanResolver.java @@ -0,0 +1,23 @@ +package com.xiaou.system.agent; + +import java.util.Optional; + +/** + * 管理员智能体计划解析器,只负责生成受限工具调用候选。 + * + * @author xiaou + */ +public interface AgentPlanResolver { + + Optional resolve(String message); + + default AgentPlanResolution resolvePlan(AgentExecutionContext context) { + return resolvePlan(context == null ? "" : context.message()); + } + + default AgentPlanResolution resolvePlan(String message) { + return resolve(message) + .map(AgentPlanResolution::resolved) + .orElseGet(AgentPlanResolution::empty); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentPolicyDecision.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentPolicyDecision.java new file mode 100644 index 000000000..06902ec17 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentPolicyDecision.java @@ -0,0 +1,56 @@ +package com.xiaou.system.agent; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 智能体策略决策。 + * + * @author xiaou + */ +@Data +public class AgentPolicyDecision { + + private boolean allowed; + + private boolean confirmationRequired; + + private String confirmationText; + + private AgentChatErrorCode errorCode; + + private String rejectionReason; + + private List nextActions = new ArrayList<>(); + + public static AgentPolicyDecision allowReadonly() { + AgentPolicyDecision decision = new AgentPolicyDecision(); + decision.setAllowed(true); + decision.setConfirmationRequired(false); + return decision; + } + + public static AgentPolicyDecision requireConfirmation(String confirmationText) { + AgentPolicyDecision decision = new AgentPolicyDecision(); + decision.setAllowed(true); + decision.setConfirmationRequired(true); + decision.setConfirmationText(confirmationText); + decision.getNextActions().add("确认无误后,按要求输入强确认文本。"); + return decision; + } + + public static AgentPolicyDecision reject(String reason) { + return reject(AgentChatErrorCode.POLICY_REJECTED, reason); + } + + public static AgentPolicyDecision reject(AgentChatErrorCode errorCode, String reason) { + AgentPolicyDecision decision = new AgentPolicyDecision(); + decision.setAllowed(false); + decision.setErrorCode(errorCode); + decision.setRejectionReason(reason); + decision.getNextActions().add("换一种更明确的管理员请求,或先查询当前状态。"); + return decision; + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentPolicyEngine.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentPolicyEngine.java new file mode 100644 index 000000000..1b4c44cbd --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentPolicyEngine.java @@ -0,0 +1,220 @@ +package com.xiaou.system.agent; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 智能体策略引擎,集中处理风险、确认和执行准入。 + * + * @author xiaou + */ +@Component +public class AgentPolicyEngine { + + public AgentPolicyDecision evaluate(AgentToolDefinition definition, Map input) { + return evaluate(definition, input, null); + } + + public AgentPolicyDecision evaluate(AgentToolDefinition definition, Map input, AgentOperator operator) { + if (definition == null) { + return AgentPolicyDecision.reject("工具不存在"); + } + + AgentPolicyDecision schemaDecision = validateInput(definition, input); + if (!schemaDecision.isAllowed()) { + return schemaDecision; + } + + AgentPolicyDecision accessDecision = validateAccess(definition, input, operator); + if (!accessDecision.isAllowed()) { + return accessDecision; + } + + if (!requiresConfirmation(definition)) { + return AgentPolicyDecision.allowReadonly(); + } + + if (!StringUtils.hasText(definition.getConfirmationText())) { + return AgentPolicyDecision.reject("写入工具缺少强确认文本配置"); + } + + return AgentPolicyDecision.requireConfirmation(definition.getConfirmationText()); + } + + public boolean confirmationMatches(String expected, String actual) { + return StringUtils.hasText(expected) + && StringUtils.hasText(actual) + && expected.trim().equals(actual.trim()); + } + + private AgentPolicyDecision validateInput(AgentToolDefinition definition, Map input) { + Map safeInput = input == null ? Map.of() : input; + List requiredKeys = definition.getRequiredInputKeys() == null ? List.of() : definition.getRequiredInputKeys(); + for (String key : requiredKeys) { + if (!safeInput.containsKey(key) || safeInput.get(key) == null) { + return AgentPolicyDecision.reject( + AgentChatErrorCode.SCHEMA_VALIDATION_FAILED, + "工具输入缺少必填字段: " + key + ); + } + } + + Map schema = definition.getInputSchema() == null ? Map.of() : definition.getInputSchema(); + for (String key : safeInput.keySet()) { + if (!schema.containsKey(key)) { + return AgentPolicyDecision.reject( + AgentChatErrorCode.SCHEMA_VALIDATION_FAILED, + "工具输入包含未声明字段: " + key + ); + } + } + + for (Map.Entry entry : schema.entrySet()) { + String key = entry.getKey(); + if (!safeInput.containsKey(key) || safeInput.get(key) == null) { + continue; + } + if (!(entry.getValue() instanceof Map fieldSchema)) { + continue; + } + AgentPolicyDecision decision = validateField(key, safeInput.get(key), fieldSchema); + if (!decision.isAllowed()) { + return decision; + } + } + return AgentPolicyDecision.allowReadonly(); + } + + private AgentPolicyDecision validateAccess(AgentToolDefinition definition, Map input, AgentOperator operator) { + List missingPermissions = requiredPermissions(definition).stream() + .filter(permission -> operator == null || !operator.hasPermission(permission)) + .toList(); + if (!missingPermissions.isEmpty()) { + return AgentPolicyDecision.reject("当前管理员缺少权限: " + String.join("、", missingPermissions)); + } + + List requiredRoles = requiredRoles(definition); + if (!requiredRoles.isEmpty() && requiredRoles.stream().noneMatch(role -> operator != null && operator.hasRole(role))) { + return AgentPolicyDecision.reject("当前管理员缺少角色,至少需要其一: " + String.join("、", requiredRoles)); + } + + if (requiresSameTenant(definition)) { + String operatorTenantId = operator == null ? "" : normalize(operator.tenantId()); + String inputTenantId = input == null ? "" : normalize(String.valueOf(input.getOrDefault("tenantId", ""))); + if (!StringUtils.hasText(operatorTenantId) || !StringUtils.hasText(inputTenantId) || !operatorTenantId.equals(inputTenantId)) { + return AgentPolicyDecision.reject("当前管理员不能操作其它租户范围内的数据"); + } + } + + return AgentPolicyDecision.allowReadonly(); + } + + private List requiredPermissions(AgentToolDefinition definition) { + return definition.getRequiredPermissions() == null ? List.of() : definition.getRequiredPermissions().stream() + .map(this::normalize) + .filter(StringUtils::hasText) + .toList(); + } + + private List requiredRoles(AgentToolDefinition definition) { + return definition.getRequiredRoles() == null ? List.of() : definition.getRequiredRoles().stream() + .map(this::normalize) + .filter(StringUtils::hasText) + .toList(); + } + + private boolean requiresSameTenant(AgentToolDefinition definition) { + return "SAME_TENANT".equalsIgnoreCase(normalize(definition.getTenantScope())); + } + + private boolean requiresConfirmation(AgentToolDefinition definition) { + return definition.isConfirmationRequired() + || definition.isDestructive() + || isWriteRisk(definition); + } + + private boolean isWriteRisk(AgentToolDefinition definition) { + String riskLevel = normalize(definition.getRiskLevel()).toLowerCase(Locale.ROOT); + String riskCategory = normalize(definition.getRiskCategory()).toUpperCase(Locale.ROOT); + return (StringUtils.hasText(riskLevel) && !"readonly".equals(riskLevel)) + || (StringUtils.hasText(riskCategory) && !"READONLY".equals(riskCategory)); + } + + private AgentPolicyDecision validateField(String key, Object value, Map fieldSchema) { + Object typeValue = fieldSchema.get("type"); + String type = typeValue == null ? "" : String.valueOf(typeValue); + if (StringUtils.hasText(type) && !matchesType(value, type)) { + return AgentPolicyDecision.reject( + AgentChatErrorCode.SCHEMA_VALIDATION_FAILED, + "工具输入字段 " + key + " 类型不符合 schema: " + type + ); + } + + if (value instanceof Number number) { + Object minimum = fieldSchema.get("minimum"); + if (minimum instanceof Number min && number.doubleValue() < min.doubleValue()) { + return AgentPolicyDecision.reject( + AgentChatErrorCode.SCHEMA_VALIDATION_FAILED, + "工具输入字段 " + key + " 必须大于等于 " + min + ); + } + Object maximum = fieldSchema.get("maximum"); + if (maximum instanceof Number max && number.doubleValue() > max.doubleValue()) { + return AgentPolicyDecision.reject( + AgentChatErrorCode.SCHEMA_VALIDATION_FAILED, + "工具输入字段 " + key + " 必须小于等于 " + max + ); + } + } + + if (value instanceof CharSequence text) { + Object minLength = fieldSchema.get("minLength"); + if (minLength instanceof Number min && text.length() < min.intValue()) { + return AgentPolicyDecision.reject( + AgentChatErrorCode.SCHEMA_VALIDATION_FAILED, + "工具输入字段 " + key + " 长度必须大于等于 " + min + ); + } + Object maxLength = fieldSchema.get("maxLength"); + if (maxLength instanceof Number max && text.length() > max.intValue()) { + return AgentPolicyDecision.reject( + AgentChatErrorCode.SCHEMA_VALIDATION_FAILED, + "工具输入字段 " + key + " 长度必须小于等于 " + max + ); + } + } + + Object enumValues = fieldSchema.get("enum"); + if (enumValues instanceof Collection allowedValues + && !allowedValues.isEmpty() + && !allowedValues.contains(value)) { + return AgentPolicyDecision.reject( + AgentChatErrorCode.SCHEMA_VALIDATION_FAILED, + "工具输入字段 " + key + " 不在允许范围: " + allowedValues + ); + } + + return AgentPolicyDecision.allowReadonly(); + } + + private boolean matchesType(Object value, String type) { + return switch (type) { + case "integer" -> value instanceof Number number && Math.rint(number.doubleValue()) == number.doubleValue(); + case "number" -> value instanceof Number; + case "string" -> value instanceof CharSequence; + case "boolean" -> value instanceof Boolean; + case "array" -> value instanceof Collection; + case "object" -> value instanceof Map; + default -> true; + }; + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentResolvedToolCall.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentResolvedToolCall.java new file mode 100644 index 000000000..9ef9954f0 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentResolvedToolCall.java @@ -0,0 +1,9 @@ +package com.xiaou.system.agent; + +/** + * 已解析出的工具和调用参数。 + * + * @author xiaou + */ +public record AgentResolvedToolCall(AgentTool tool, AgentToolCall call) { +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentRuntimeTrace.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentRuntimeTrace.java new file mode 100644 index 000000000..211a4a9ac --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentRuntimeTrace.java @@ -0,0 +1,47 @@ +package com.xiaou.system.agent; + +import com.xiaou.system.dto.AgentChatTraceStep; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 单次智能体请求的轻量运行追踪。 + * + * @author xiaou + */ +public class AgentRuntimeTrace { + + private final String traceId; + + private final long startNanos; + + private final List steps = new ArrayList<>(); + + private AgentRuntimeTrace(String traceId) { + this.traceId = traceId; + this.startNanos = System.nanoTime(); + } + + public static AgentRuntimeTrace start() { + return new AgentRuntimeTrace("agent-trace-" + UUID.randomUUID()); + } + + public String traceId() { + return traceId; + } + + public void mark(String stage, String status, String detail) { + AgentChatTraceStep step = new AgentChatTraceStep(); + step.setStage(stage); + step.setStatus(status); + step.setDetail(detail); + step.setElapsedMs(Math.max(0L, (System.nanoTime() - startNanos) / 1_000_000L)); + steps.add(step); + } + + public List steps() { + return new ArrayList<>(steps); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionContextRepository.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionContextRepository.java new file mode 100644 index 000000000..366ed8281 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionContextRepository.java @@ -0,0 +1,13 @@ +package com.xiaou.system.agent; + +/** + * 管理员智能体会话上下文存储端口,可由内存、Redis 或数据库实现。 + * + * @author xiaou + */ +public interface AgentSessionContextRepository { + + AgentSessionSnapshot snapshot(String sessionId); + + void appendTurn(String sessionId, AgentSessionTurn turn, int maxRecentTurns); +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionContextStore.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionContextStore.java new file mode 100644 index 000000000..54d6f9bd2 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionContextStore.java @@ -0,0 +1,101 @@ +package com.xiaou.system.agent; + +import com.xiaou.system.dto.AgentChatRequest; +import com.xiaou.system.dto.AgentChatResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * 后端会话上下文门面,负责把聊天请求/响应转换为可持久化的会话摘要。 + * + * @author xiaou + */ +@Component +public class AgentSessionContextStore { + + private final AgentSessionContextRepository repository; + private final AgentSessionProperties properties; + + @Autowired + public AgentSessionContextStore(AgentSessionContextRepository repository, AgentSessionProperties properties) { + this.repository = repository; + this.properties = properties == null ? new AgentSessionProperties() : properties; + } + + public AgentSessionContextStore(AgentSessionContextRepository repository) { + this(repository, new AgentSessionProperties()); + } + + public AgentSessionContextStore() { + this(new InMemoryAgentSessionContextRepository(), new AgentSessionProperties()); + } + + public AgentSessionSnapshot snapshot(String sessionId) { + return snapshot(sessionId, normalize(sessionId)); + } + + public AgentSessionSnapshot snapshot(String sessionId, Long operatorId) { + return snapshot(sessionId, scopedSessionId(sessionId, operatorId)); + } + + private AgentSessionSnapshot snapshot(String sessionId, String repositorySessionId) { + AgentSessionSnapshot snapshot = new AgentSessionSnapshot(); + String normalizedSessionId = normalize(sessionId); + snapshot.setSessionId(normalizedSessionId); + if (!StringUtils.hasText(normalizedSessionId) || !StringUtils.hasText(repositorySessionId)) { + return snapshot; + } + + AgentSessionSnapshot storedSnapshot = repository.snapshot(repositorySessionId); + if (storedSnapshot == null) { + return snapshot; + } + storedSnapshot.setSessionId(normalizedSessionId); + return storedSnapshot; + } + + public void record(AgentChatRequest request, AgentChatResponse response) { + String sessionId = request == null ? "" : normalize(request.getSessionId()); + record(request, response, sessionId); + } + + public void record(AgentChatRequest request, AgentChatResponse response, Long operatorId) { + String sessionId = request == null ? "" : request.getSessionId(); + record(request, response, scopedSessionId(sessionId, operatorId)); + } + + private void record(AgentChatRequest request, AgentChatResponse response, String repositorySessionId) { + String sessionId = request == null ? "" : normalize(request.getSessionId()); + if (!StringUtils.hasText(sessionId) || !StringUtils.hasText(repositorySessionId) || response == null) { + return; + } + + AgentSessionTurn turn = new AgentSessionTurn(); + turn.setMessage(request.getMessage()); + turn.setStatus(response.getStatus()); + turn.setAnswer(response.getAnswer()); + turn.setToolName(response.getToolName()); + turn.setAuditId(response.getAuditId()); + turn.setTraceId(response.getTraceId()); + + repository.appendTurn(repositorySessionId, turn, maxRecentTurns()); + } + + private int maxRecentTurns() { + return Math.max(properties.getMaxRecentTurns(), 0); + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } + + private String scopedSessionId(String sessionId, Long operatorId) { + String normalizedSessionId = normalize(sessionId); + if (!StringUtils.hasText(normalizedSessionId)) { + return ""; + } + String operatorNamespace = operatorId == null ? "anonymous" : String.valueOf(operatorId); + return "operator:" + operatorNamespace + ":" + normalizedSessionId; + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionProperties.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionProperties.java new file mode 100644 index 000000000..90b230b07 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionProperties.java @@ -0,0 +1,36 @@ +package com.xiaou.system.agent; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 管理员智能体会话上下文配置。 + * + * @author xiaou + */ +@Data +@Component +@ConfigurationProperties(prefix = "xiaou.admin-agent.session") +public class AgentSessionProperties { + + /** + * 会话上下文存储实现:memory / redis / db。 + */ + private String repository = "memory"; + + /** + * 每个会话保留的最近轮数。 + */ + private int maxRecentTurns = 6; + + /** + * Redis Key 前缀。 + */ + private String redisKeyPrefix = "xiaou:admin:agent:session"; + + /** + * Redis 会话上下文 TTL,单位秒。小于等于 0 表示不设置过期时间。 + */ + private long ttlSeconds = 86400L; +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionSnapshot.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionSnapshot.java new file mode 100644 index 000000000..b4c00f2ea --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionSnapshot.java @@ -0,0 +1,19 @@ +package com.xiaou.system.agent; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 管理员智能体会话上下文快照。 + * + * @author xiaou + */ +@Data +public class AgentSessionSnapshot { + + private String sessionId; + + private List recentTurns = new ArrayList<>(); +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionTurn.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionTurn.java new file mode 100644 index 000000000..5e2afdd6c --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentSessionTurn.java @@ -0,0 +1,24 @@ +package com.xiaou.system.agent; + +import lombok.Data; + +/** + * 管理员智能体单轮会话摘要。 + * + * @author xiaou + */ +@Data +public class AgentSessionTurn { + + private String message; + + private String status; + + private String answer; + + private String toolName; + + private String auditId; + + private String traceId; +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentTool.java new file mode 100644 index 000000000..d1a86a5dc --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentTool.java @@ -0,0 +1,28 @@ +package com.xiaou.system.agent; + +import java.util.Optional; + +/** + * 管理员智能体工具适配器。 + * + * @author xiaou + */ +public interface AgentTool { + + AgentToolDefinition definition(); + + /** + * 可选的离线确定性解析提示。正常运行时由 LLM 根据 definition/schema 规划, + * 只有模型不可用或规划无结果时才使用该兜底能力。 + * + * @param message 用户自然语言请求 + * @return 确定性工具调用,无法解析时返回空 + */ + default Optional resolve(String message) { + return Optional.empty(); + } + + AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context); + + AgentToolResult execute(AgentToolCall call, AgentExecutionContext context); +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolCall.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolCall.java new file mode 100644 index 000000000..56f4bc4d2 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolCall.java @@ -0,0 +1,21 @@ +package com.xiaou.system.agent; + +import lombok.Data; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 已解析出的工具调用。 + * + * @author xiaou + */ +@Data +public class AgentToolCall { + + private String toolName; + + private String summary; + + private Map input = new LinkedHashMap<>(); +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolCatalogService.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolCatalogService.java new file mode 100644 index 000000000..a1011558f --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolCatalogService.java @@ -0,0 +1,24 @@ +package com.xiaou.system.agent; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 智能体工具目录服务,用于运行时自描述已注册的后端工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentToolCatalogService { + + private final ObjectProvider toolRegistryProvider; + + public List definitions() { + AgentToolRegistry registry = toolRegistryProvider.getIfAvailable(); + return registry == null ? List.of() : registry.definitions(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolDefinition.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolDefinition.java new file mode 100644 index 000000000..1a8ef529c --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolDefinition.java @@ -0,0 +1,47 @@ +package com.xiaou.system.agent; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 可被智能体调用的后端工具定义。 + * + * @author xiaou + */ +@Data +public class AgentToolDefinition { + + private String name; + + private String title; + + private String description; + + private String intent; + + private String route; + + private String riskLevel = "readonly"; + + private String riskCategory = "READONLY"; + + private boolean destructive; + + private boolean confirmationRequired; + + private String confirmationText; + + private Map inputSchema = new LinkedHashMap<>(); + + private List requiredInputKeys = new ArrayList<>(); + + private List requiredPermissions = new ArrayList<>(); + + private List requiredRoles = new ArrayList<>(); + + private String tenantScope = "ANY"; +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolDefinitionBuilder.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolDefinitionBuilder.java new file mode 100644 index 000000000..ea2ed4d48 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolDefinitionBuilder.java @@ -0,0 +1,131 @@ +package com.xiaou.system.agent; + +import org.springframework.util.StringUtils; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * AgentTool 定义构造器,集中维护通用风险、权限和 schema 默认值。 + * + * @author xiaou + */ +public final class AgentToolDefinitionBuilder { + + private final AgentToolDefinition definition; + + private AgentToolDefinitionBuilder(String name, String title) { + definition = new AgentToolDefinition(); + definition.setName(trim(name)); + definition.setTitle(trim(title)); + definition.setIntent(trim(name)); + definition.setInputSchema(new LinkedHashMap<>()); + } + + public static AgentToolDefinitionBuilder readonly(String name, String title) { + return new AgentToolDefinitionBuilder(name, title) + .risk("readonly", "READONLY", false, false, null); + } + + public static AgentToolDefinitionBuilder write(String name, String title) { + return new AgentToolDefinitionBuilder(name, title) + .risk("medium", "WRITE", true, true, null); + } + + public AgentToolDefinitionBuilder description(String description) { + definition.setDescription(trim(description)); + return this; + } + + public AgentToolDefinitionBuilder intent(String intent) { + definition.setIntent(trim(intent)); + return this; + } + + public AgentToolDefinitionBuilder route(String route) { + definition.setRoute(trim(route)); + return this; + } + + public AgentToolDefinitionBuilder input(String key, Map schema, boolean required) { + String normalizedKey = trim(key); + definition.getInputSchema().put(normalizedKey, schema == null ? Map.of() : new LinkedHashMap<>(schema)); + if (required && !definition.getRequiredInputKeys().contains(normalizedKey)) { + definition.getRequiredInputKeys().add(normalizedKey); + } + return this; + } + + public AgentToolDefinitionBuilder permission(String permission) { + String normalizedPermission = trim(permission); + if (!definition.getRequiredPermissions().contains(normalizedPermission)) { + definition.getRequiredPermissions().add(normalizedPermission); + } + return this; + } + + public AgentToolDefinitionBuilder permissions(Collection permissions) { + if (permissions == null) { + return this; + } + permissions.forEach(this::permission); + return this; + } + + public AgentToolDefinitionBuilder role(String role) { + String normalizedRole = trim(role); + if (!definition.getRequiredRoles().contains(normalizedRole)) { + definition.getRequiredRoles().add(normalizedRole); + } + return this; + } + + public AgentToolDefinitionBuilder tenantScope(String tenantScope) { + definition.setTenantScope(trim(tenantScope)); + return this; + } + + public AgentToolDefinitionBuilder confirmationText(String confirmationText) { + definition.setConfirmationText(trim(confirmationText)); + return this; + } + + public AgentToolDefinition build() { + validateText(definition.getName(), "AgentTool definition.name must not be blank"); + validateText(definition.getTitle(), definition.getName() + " title must not be blank"); + validateText(definition.getDescription(), definition.getName() + " description must not be blank"); + validateText(definition.getIntent(), definition.getName() + " intent must not be blank"); + validateText(definition.getRoute(), definition.getName() + " route must not be blank"); + if (definition.getRequiredPermissions().isEmpty()) { + throw new IllegalStateException(definition.getName() + " must declare at least one permission"); + } + if (definition.isConfirmationRequired()) { + validateText(definition.getConfirmationText(), definition.getName() + " confirmationText must not be blank"); + } + return definition; + } + + private AgentToolDefinitionBuilder risk(String riskLevel, + String riskCategory, + boolean destructive, + boolean confirmationRequired, + String confirmationText) { + definition.setRiskLevel(riskLevel); + definition.setRiskCategory(riskCategory); + definition.setDestructive(destructive); + definition.setConfirmationRequired(confirmationRequired); + definition.setConfirmationText(confirmationText); + return this; + } + + private void validateText(String value, String message) { + if (!StringUtils.hasText(value)) { + throw new IllegalStateException(message); + } + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolMetricSample.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolMetricSample.java new file mode 100644 index 000000000..7282a381b --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolMetricSample.java @@ -0,0 +1,28 @@ +package com.xiaou.system.agent; + +import lombok.Data; + +/** + * 单次工具调用指标样本。trace/session 只用于排障上下文,不进入指标标签。 + * + * @author xiaou + */ +@Data +public class AgentToolMetricSample { + + private String toolName; + + private String phase; + + private String outcome; + + private String riskLevel; + + private String riskCategory; + + private long durationNanos; + + private String traceId; + + private String sessionId; +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolMetricsRecorder.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolMetricsRecorder.java new file mode 100644 index 000000000..13667e537 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolMetricsRecorder.java @@ -0,0 +1,52 @@ +package com.xiaou.system.agent; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.time.Duration; + +/** + * 管理员智能体工具调用指标记录器。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentToolMetricsRecorder { + + private final MeterRegistry meterRegistry; + + public void recordInvocation(AgentToolMetricSample sample) { + if (sample == null) { + return; + } + + Timer.builder("xiaou.agent.tool.duration") + .description("管理员智能体后端工具调用耗时") + .tag("tool", safeTag(sample.getToolName())) + .tag("phase", safeTag(sample.getPhase())) + .tag("outcome", safeTag(sample.getOutcome())) + .tag("risk_level", safeTag(sample.getRiskLevel())) + .tag("risk_category", safeTag(sample.getRiskCategory())) + .register(meterRegistry) + .record(Duration.ofNanos(Math.max(sample.getDurationNanos(), 0L))); + + Counter.builder("xiaou.agent.tool.invocations") + .description("管理员智能体后端工具调用次数") + .tag("tool", safeTag(sample.getToolName())) + .tag("phase", safeTag(sample.getPhase())) + .tag("outcome", safeTag(sample.getOutcome())) + .tag("risk_level", safeTag(sample.getRiskLevel())) + .tag("risk_category", safeTag(sample.getRiskCategory())) + .register(meterRegistry) + .increment(); + } + + private String safeTag(String value) { + return StringUtils.hasText(value) ? value.trim() : "unknown"; + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolPreview.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolPreview.java new file mode 100644 index 000000000..8016dbb4f --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolPreview.java @@ -0,0 +1,30 @@ +package com.xiaou.system.agent; + +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.dto.AgentChatDiffItem; +import com.xiaou.system.dto.AgentChatPlanStep; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 写入工具执行前预览。 + * + * @author xiaou + */ +@Data +public class AgentToolPreview { + + private boolean executable = true; + + private String blockedReason; + + private String summary; + + private List plan = new ArrayList<>(); + + private List diff = new ArrayList<>(); + + private List artifacts = new ArrayList<>(); +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolRegistry.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolRegistry.java new file mode 100644 index 000000000..1e80db240 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolRegistry.java @@ -0,0 +1,54 @@ +package com.xiaou.system.agent; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 智能体工具注册表。新增能力只需要新增 AgentTool Bean。 + * + * @author xiaou + */ +@Component +public class AgentToolRegistry { + + private final Map tools; + + public AgentToolRegistry(List toolList) { + this.tools = new LinkedHashMap<>(); + for (AgentTool tool : toolList) { + String name = tool == null || tool.definition() == null ? "" : tool.definition().getName(); + if (!StringUtils.hasText(name)) { + throw new IllegalStateException("AgentTool definition.name must not be blank"); + } + if (this.tools.containsKey(name)) { + throw new IllegalStateException("Duplicate AgentTool name: " + name); + } + this.tools.put(name, tool); + } + } + + public Optional resolve(String message) { + for (AgentTool tool : tools.values()) { + Optional call = tool.resolve(message); + if (call.isPresent()) { + return Optional.of(new AgentResolvedToolCall(tool, call.get())); + } + } + return Optional.empty(); + } + + public Optional find(String name) { + return Optional.ofNullable(tools.get(name)); + } + + public List definitions() { + return tools.values().stream() + .map(AgentTool::definition) + .toList(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolResult.java b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolResult.java new file mode 100644 index 000000000..0a77dfc59 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/AgentToolResult.java @@ -0,0 +1,29 @@ +package com.xiaou.system.agent; + +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.dto.AgentChatDiffItem; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 工具执行结果。 + * + * @author xiaou + */ +@Data +public class AgentToolResult { + + private boolean success; + + private String summary; + + private String errorMessage; + + private List diff = new ArrayList<>(); + + private List artifacts = new ArrayList<>(); + + private List nextActions = new ArrayList<>(); +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/DbAgentSessionContextRepository.java b/xiaou-system/src/main/java/com/xiaou/system/agent/DbAgentSessionContextRepository.java new file mode 100644 index 000000000..b267fb1ef --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/DbAgentSessionContextRepository.java @@ -0,0 +1,94 @@ +package com.xiaou.system.agent; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.system.domain.SysAgentSessionContext; +import com.xiaou.system.mapper.SysAgentSessionContextMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 数据库会话上下文实现。用于需要跨实例持久化最近对话窗口的部署。 + * + * @author xiaou + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "xiaou.admin-agent.session", name = "repository", havingValue = "db") +public class DbAgentSessionContextRepository implements AgentSessionContextRepository { + + private static final TypeReference> TURN_LIST_TYPE = new TypeReference<>() { + }; + + private final ObjectMapper objectMapper; + private final SysAgentSessionContextMapper sessionContextMapper; + + @Override + public synchronized AgentSessionSnapshot snapshot(String sessionId) { + AgentSessionSnapshot snapshot = new AgentSessionSnapshot(); + String normalizedSessionId = normalize(sessionId); + snapshot.setSessionId(normalizedSessionId); + if (!StringUtils.hasText(normalizedSessionId)) { + return snapshot; + } + + SysAgentSessionContext record = sessionContextMapper.selectBySessionId(normalizedSessionId); + if (record == null || !StringUtils.hasText(record.getTurnsJson())) { + return snapshot; + } + + try { + snapshot.setRecentTurns(objectMapper.readValue(record.getTurnsJson(), TURN_LIST_TYPE)); + return snapshot; + } catch (JsonProcessingException e) { + log.warn("读取管理员智能体 DB 会话上下文失败 sessionId={}, message={}", normalizedSessionId, e.getMessage()); + return snapshot; + } + } + + @Override + public synchronized void appendTurn(String sessionId, AgentSessionTurn turn, int maxRecentTurns) { + String normalizedSessionId = normalize(sessionId); + if (!StringUtils.hasText(normalizedSessionId) || turn == null) { + return; + } + + List turns = new ArrayList<>(snapshot(normalizedSessionId).getRecentTurns()); + turns.add(turn); + turns = limitRecentTurns(turns, maxRecentTurns); + + SysAgentSessionContext record = new SysAgentSessionContext(); + record.setSessionId(normalizedSessionId); + try { + record.setTurnsJson(objectMapper.writeValueAsString(turns)); + } catch (JsonProcessingException e) { + log.warn("序列化管理员智能体 DB 会话上下文失败 sessionId={}, message={}", normalizedSessionId, e.getMessage()); + return; + } + LocalDateTime now = LocalDateTime.now(); + record.setCreatedTime(now); + record.setUpdatedTime(now); + sessionContextMapper.upsert(record); + } + + private List limitRecentTurns(List turns, int maxRecentTurns) { + int maxTurns = Math.max(maxRecentTurns, 0); + if (turns.size() <= maxTurns) { + return turns; + } + return new ArrayList<>(turns.subList(turns.size() - maxTurns, turns.size())); + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/DeterministicAgentPlanResolver.java b/xiaou-system/src/main/java/com/xiaou/system/agent/DeterministicAgentPlanResolver.java new file mode 100644 index 000000000..1f4f7a23b --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/DeterministicAgentPlanResolver.java @@ -0,0 +1,23 @@ +package com.xiaou.system.agent; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * 模型不可用或模型没有给出可执行计划时的离线确定性兜底解析器。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class DeterministicAgentPlanResolver implements AgentPlanResolver { + + private final AgentToolRegistry toolRegistry; + + @Override + public Optional resolve(String message) { + return toolRegistry.resolve(message); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/InMemoryAgentSessionContextRepository.java b/xiaou-system/src/main/java/com/xiaou/system/agent/InMemoryAgentSessionContextRepository.java new file mode 100644 index 000000000..678b97c51 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/InMemoryAgentSessionContextRepository.java @@ -0,0 +1,63 @@ +package com.xiaou.system.agent; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 进程内会话上下文实现。生产可替换为 Redis/DB 实现而不影响运行时主链路。 + * + * @author xiaou + */ +@Component +@ConditionalOnProperty(prefix = "xiaou.admin-agent.session", name = "repository", havingValue = "memory", matchIfMissing = true) +public class InMemoryAgentSessionContextRepository implements AgentSessionContextRepository { + + private final Map> sessions = new ConcurrentHashMap<>(); + + @Override + public AgentSessionSnapshot snapshot(String sessionId) { + AgentSessionSnapshot snapshot = new AgentSessionSnapshot(); + String normalizedSessionId = normalize(sessionId); + snapshot.setSessionId(normalizedSessionId); + if (!StringUtils.hasText(normalizedSessionId)) { + return snapshot; + } + + Deque turns = sessions.get(normalizedSessionId); + if (turns == null) { + return snapshot; + } + synchronized (turns) { + snapshot.setRecentTurns(new ArrayList<>(turns)); + } + return snapshot; + } + + @Override + public void appendTurn(String sessionId, AgentSessionTurn turn, int maxRecentTurns) { + String normalizedSessionId = normalize(sessionId); + if (!StringUtils.hasText(normalizedSessionId) || turn == null) { + return; + } + + Deque turns = sessions.computeIfAbsent(normalizedSessionId, key -> new ArrayDeque<>()); + synchronized (turns) { + turns.addLast(turn); + int maxTurns = Math.max(maxRecentTurns, 0); + while (turns.size() > maxTurns) { + turns.removeFirst(); + } + } + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/LlmAgentPlanResolver.java b/xiaou-system/src/main/java/com/xiaou/system/agent/LlmAgentPlanResolver.java new file mode 100644 index 000000000..0ee261bee --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/LlmAgentPlanResolver.java @@ -0,0 +1,299 @@ +package com.xiaou.system.agent; + +import cn.hutool.json.JSONUtil; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.ai.prompt.admin.AdminAgentPromptSpecs; +import com.xiaou.ai.structured.admin.AdminAgentStructuredOutputSpecs; +import com.xiaou.ai.support.AiExecutionSupport; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 受限 LLM 计划解析器:模型只生成候选工具调用,执行权仍在后端运行时。 + * + * @author xiaou + */ +@Primary +@Component +public class LlmAgentPlanResolver implements AgentPlanResolver { + + private static final String SCENE_NAME = "admin.agent.plan"; + private static final double MIN_CONFIDENCE = 0.6D; + private static final TypeReference> MAP_TYPE = new TypeReference<>() { + }; + + private final DeterministicAgentPlanResolver deterministicResolver; + private final AgentToolRegistry toolRegistry; + private final AiExecutionSupport aiExecutionSupport; + private final ObjectMapper objectMapper; + private final boolean deterministicFallbackEnabled; + + @Autowired + public LlmAgentPlanResolver( + DeterministicAgentPlanResolver deterministicResolver, + AgentToolRegistry toolRegistry, + AiExecutionSupport aiExecutionSupport, + ObjectMapper objectMapper + ) { + this(deterministicResolver, toolRegistry, aiExecutionSupport, objectMapper, true); + } + + /** + * 构造 planner;live 验收可关闭 deterministic fallback,避免外部模型失败时伪装成成功。 + */ + public LlmAgentPlanResolver( + DeterministicAgentPlanResolver deterministicResolver, + AgentToolRegistry toolRegistry, + AiExecutionSupport aiExecutionSupport, + ObjectMapper objectMapper, + boolean deterministicFallbackEnabled + ) { + this.deterministicResolver = deterministicResolver; + this.toolRegistry = toolRegistry; + this.aiExecutionSupport = aiExecutionSupport; + this.objectMapper = objectMapper; + this.deterministicFallbackEnabled = deterministicFallbackEnabled; + } + + @Override + public Optional resolve(String message) { + return resolvePlan(message).resolved(); + } + + @Override + public AgentPlanResolution resolvePlan(String message) { + return resolvePlan(message, new AgentSessionSnapshot()); + } + + @Override + public AgentPlanResolution resolvePlan(AgentExecutionContext context) { + if (context == null) { + return resolvePlan(""); + } + return resolvePlan(context.message(), context.sessionSnapshot()); + } + + private AgentPlanResolution resolvePlan(String message, AgentSessionSnapshot sessionSnapshot) { + if (!deterministicFallbackEnabled) { + return resolveStrictlyWithLlm(message, sessionSnapshot); + } + + AgentPlanResolution llmResolution = aiExecutionSupport.chatWithFallback( + SCENE_NAME, + AdminAgentPromptSpecs.PLAN, + buildPromptVariables(message, sessionSnapshot), + this::parseCandidate, + () -> resolveDeterministically(message) + ); + if (llmResolution != null && (llmResolution.isResolved() || llmResolution.getErrorCode() != null)) { + return llmResolution; + } + return resolveDeterministically(message); + } + + private AgentPlanResolution resolveStrictlyWithLlm(String message, AgentSessionSnapshot sessionSnapshot) { + if (!aiExecutionSupport.isChatAvailable()) { + throw new IllegalStateException("Live planner requires an available AI runtime"); + } + String response = aiExecutionSupport.chat( + SCENE_NAME, + AdminAgentPromptSpecs.PLAN, + buildPromptVariables(message, sessionSnapshot) + ); + if (!StringUtils.hasText(response)) { + throw new IllegalStateException("Live planner returned an empty response"); + } + return parseCandidate(response); + } + + private AgentPlanResolution resolveDeterministically(String message) { + return deterministicResolver.resolve(message) + .map(AgentPlanResolution::resolved) + .orElseGet(AgentPlanResolution::empty); + } + + private AgentPlanResolution parseCandidate(String content) { + Map candidate; + try { + String json = extractJson(content); + if (!AdminAgentStructuredOutputSpecs.PLAN.validateObject(JSONUtil.parseObj(json)).valid()) { + return AgentPlanResolution.empty(); + } + candidate = objectMapper.readValue(json, MAP_TYPE); + } catch (JsonProcessingException | IllegalArgumentException e) { + return AgentPlanResolution.empty(); + } + + if (hasMissingFields(candidate)) { + return clarification(missingFields(candidate)); + } + + if (confidence(candidate) < MIN_CONFIDENCE) { + return AgentPlanResolution.empty(); + } + + String toolName = stringValue(candidate.get("toolName")); + if (!StringUtils.hasText(toolName)) { + return AgentPlanResolution.empty(); + } + + return toolRegistry.find(toolName) + .map(tool -> { + Map input = filterInput(tool.definition(), candidate.get("input")); + List missingRequiredKeys = missingRequiredKeys(tool.definition(), input); + if (!missingRequiredKeys.isEmpty()) { + return clarification(missingRequiredKeys); + } + + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setSummary("LLM planner candidate"); + call.setInput(input); + return AgentPlanResolution.resolved(new AgentResolvedToolCall(tool, call)); + }) + .orElseGet(AgentPlanResolution::empty); + } + + private Map filterInput(AgentToolDefinition definition, Object inputValue) { + if (!(inputValue instanceof Map rawInput)) { + return new LinkedHashMap<>(); + } + + Map schema = definition.getInputSchema() == null ? Map.of() : definition.getInputSchema(); + Map filtered = new LinkedHashMap<>(); + for (String key : schema.keySet()) { + if (rawInput.containsKey(key)) { + filtered.put(key, rawInput.get(key)); + } + } + return filtered; + } + + private List missingRequiredKeys(AgentToolDefinition definition, Map input) { + List requiredInputKeys = definition.getRequiredInputKeys() == null + ? List.of() + : definition.getRequiredInputKeys(); + Map safeInput = input == null ? Map.of() : input; + return requiredInputKeys.stream() + .filter(key -> !safeInput.containsKey(key) || safeInput.get(key) == null) + .toList(); + } + + private AgentPlanResolution clarification(List missingFields) { + return AgentPlanResolution.clarification( + "还需要补充这些信息后才能继续:" + String.join("、", missingFields), + missingFields.stream() + .map(field -> "请补充字段:" + field) + .toList() + ); + } + + private Map buildPromptVariables(String message, AgentSessionSnapshot sessionSnapshot) { + Map variables = new LinkedHashMap<>(); + variables.put("message", message == null ? "" : message.trim()); + try { + variables.put("toolsJson", objectMapper.writeValueAsString(toolRegistry.definitions().stream() + .map(this::toolDescriptor) + .toList())); + variables.put("sessionContextJson", objectMapper.writeValueAsString(sessionDescriptor(sessionSnapshot))); + return variables; + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("智能体 planner prompt 构造失败", e); + } + } + + private Map sessionDescriptor(AgentSessionSnapshot sessionSnapshot) { + AgentSessionSnapshot safeSnapshot = sessionSnapshot == null ? new AgentSessionSnapshot() : sessionSnapshot; + Map descriptor = new LinkedHashMap<>(); + descriptor.put("sessionId", stringValue(safeSnapshot.getSessionId())); + descriptor.put("recentTurns", safeSnapshot.getRecentTurns() == null ? List.of() : safeSnapshot.getRecentTurns().stream() + .map(this::turnDescriptor) + .toList()); + return descriptor; + } + + private Map turnDescriptor(AgentSessionTurn turn) { + Map descriptor = new LinkedHashMap<>(); + if (turn == null) { + return descriptor; + } + descriptor.put("message", limit(turn.getMessage(), 300)); + descriptor.put("status", stringValue(turn.getStatus())); + descriptor.put("toolName", stringValue(turn.getToolName())); + descriptor.put("auditId", stringValue(turn.getAuditId())); + descriptor.put("answer", limit(turn.getAnswer(), 300)); + return descriptor; + } + + private Map toolDescriptor(AgentToolDefinition definition) { + Map descriptor = new LinkedHashMap<>(); + descriptor.put("name", definition.getName()); + descriptor.put("title", definition.getTitle()); + descriptor.put("description", definition.getDescription()); + descriptor.put("riskLevel", definition.getRiskLevel()); + descriptor.put("riskCategory", definition.getRiskCategory()); + descriptor.put("inputSchema", definition.getInputSchema()); + descriptor.put("requiredInputKeys", definition.getRequiredInputKeys()); + descriptor.put("requiredPermissions", definition.getRequiredPermissions()); + descriptor.put("requiredRoles", definition.getRequiredRoles()); + descriptor.put("tenantScope", definition.getTenantScope()); + return descriptor; + } + + private boolean hasMissingFields(Map candidate) { + return !missingFields(candidate).isEmpty(); + } + + private List missingFields(Map candidate) { + Object missingFields = candidate.get("missingFields"); + if (!(missingFields instanceof List list)) { + return List.of(); + } + return list.stream() + .map(this::stringValue) + .filter(StringUtils::hasText) + .toList(); + } + + private double confidence(Map candidate) { + Object value = candidate.get("confidence"); + if (value instanceof Number number) { + return number.doubleValue(); + } + return 0D; + } + + private String extractJson(String content) { + if (!StringUtils.hasText(content)) { + throw new IllegalArgumentException("empty planner response"); + } + String text = content.trim(); + int start = text.indexOf('{'); + int end = text.lastIndexOf('}'); + if (start < 0 || end < start) { + throw new IllegalArgumentException("planner response is not json"); + } + return text.substring(start, end + 1); + } + + private String stringValue(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String limit(String value, int maxLength) { + String text = stringValue(value); + if (text.length() <= maxLength) { + return text; + } + return text.substring(0, maxLength); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/RedisAgentSessionContextRepository.java b/xiaou-system/src/main/java/com/xiaou/system/agent/RedisAgentSessionContextRepository.java new file mode 100644 index 000000000..b3b156a64 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/RedisAgentSessionContextRepository.java @@ -0,0 +1,111 @@ +package com.xiaou.system.agent; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * Redis 会话上下文实现。通过配置显式启用,默认仍使用内存实现。 + * + * @author xiaou + */ +@Slf4j +@Component +@ConditionalOnProperty(prefix = "xiaou.admin-agent.session", name = "repository", havingValue = "redis") +public class RedisAgentSessionContextRepository implements AgentSessionContextRepository { + + private static final TypeReference> TURN_LIST_TYPE = new TypeReference<>() { + }; + + private final ObjectMapper objectMapper; + private final AgentSessionProperties properties; + private final StringRedisTemplate stringRedisTemplate; + + public RedisAgentSessionContextRepository(ObjectMapper objectMapper, + AgentSessionProperties properties, + ObjectProvider stringRedisTemplateProvider) { + this.objectMapper = objectMapper; + this.properties = properties; + this.stringRedisTemplate = stringRedisTemplateProvider.getIfAvailable(); + } + + @Override + public synchronized AgentSessionSnapshot snapshot(String sessionId) { + AgentSessionSnapshot snapshot = new AgentSessionSnapshot(); + String normalizedSessionId = normalize(sessionId); + snapshot.setSessionId(normalizedSessionId); + if (!StringUtils.hasText(normalizedSessionId) || stringRedisTemplate == null) { + return snapshot; + } + + String json = stringRedisTemplate.opsForValue().get(redisKey(normalizedSessionId)); + if (!StringUtils.hasText(json)) { + return snapshot; + } + + try { + snapshot.setRecentTurns(objectMapper.readValue(json, TURN_LIST_TYPE)); + return snapshot; + } catch (JsonProcessingException e) { + log.warn("读取管理员智能体 Redis 会话上下文失败 sessionId={}, message={}", normalizedSessionId, e.getMessage()); + return snapshot; + } + } + + @Override + public synchronized void appendTurn(String sessionId, AgentSessionTurn turn, int maxRecentTurns) { + String normalizedSessionId = normalize(sessionId); + if (!StringUtils.hasText(normalizedSessionId) || turn == null || stringRedisTemplate == null) { + return; + } + + List turns = new ArrayList<>(snapshot(normalizedSessionId).getRecentTurns()); + turns.add(turn); + turns = limitRecentTurns(turns, maxRecentTurns); + + String key = redisKey(normalizedSessionId); + try { + stringRedisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(turns)); + applyTtl(key); + } catch (JsonProcessingException e) { + log.warn("保存管理员智能体 Redis 会话上下文失败 sessionId={}, message={}", normalizedSessionId, e.getMessage()); + } + } + + private List limitRecentTurns(List turns, int maxRecentTurns) { + int maxTurns = Math.max(maxRecentTurns, 0); + if (turns.size() <= maxTurns) { + return turns; + } + return new ArrayList<>(turns.subList(turns.size() - maxTurns, turns.size())); + } + + private void applyTtl(String key) { + long ttlSeconds = properties == null ? 0L : properties.getTtlSeconds(); + if (ttlSeconds > 0L) { + stringRedisTemplate.expire(key, Duration.ofSeconds(ttlSeconds)); + } + } + + private String redisKey(String sessionId) { + String prefix = properties == null ? "" : normalize(properties.getRedisKeyPrefix()); + if (!StringUtils.hasText(prefix)) { + prefix = "xiaou:admin:agent:session"; + } + return prefix + ":" + sessionId; + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentAuditDetailAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentAuditDetailAgentTool.java new file mode 100644 index 000000000..535c4abd9 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentAuditDetailAgentTool.java @@ -0,0 +1,150 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.service.SysAgentAuditService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 查询单条管理员智能体审计记录的通用只读工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentAuditDetailAgentTool implements AgentTool { + + private static final Pattern AUDIT_ID_PATTERN = Pattern.compile("(agent-audit-[A-Za-z0-9_-]+)"); + + private final SysAgentAuditService auditService; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.audit.detail"); + definition.setTitle("查询智能体审计详情"); + definition.setDescription("按 auditId 查询单条管理员智能体审计记录,返回计划、输入、结果和状态信息。"); + definition.setIntent("system.agent.audit.detail"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of( + "auditId", Map.of("type", "string", "minLength", 1, "maxLength", 120) + )); + definition.setRequiredInputKeys(List.of("auditId")); + definition.setRequiredPermissions(List.of("agent:runtime:audit:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (!containsAny(normalized, List.of("智能体审计详情", "审计详情", "智能体记录详情", "执行详情", "确认详情"))) { + return Optional.empty(); + } + + String auditId = extractAuditId(normalized); + if (!StringUtils.hasText(auditId)) { + return Optional.empty(); + } + + Map input = new LinkedHashMap<>(); + input.put("auditId", auditId); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("查询管理员智能体审计详情"); + call.setInput(input); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("查询智能体审计详情是只读动作,不需要写入预览。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + String auditId = normalize(call.getInput().get("auditId")); + AgentAuditResponse audit = auditService.getByAuditId(auditId); + if (audit == null) { + AgentToolResult missing = new AgentToolResult(); + missing.setSuccess(false); + missing.setSummary("没有找到对应的智能体审计记录。"); + missing.setErrorMessage("智能体审计记录不存在: " + auditId); + missing.getNextActions().add("请先查询最近的智能体审计记录,确认 auditId 是否正确。"); + return missing; + } + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("已查询到智能体审计记录 " + audit.getAuditId() + ",当前状态:" + audit.getStatusText() + "。"); + result.getArtifacts().add(new AgentChatArtifact( + "agentAuditDetail", + "智能体审计详情", + toDetail(audit) + )); + result.getNextActions().add("如果这是待确认记录,可以继续输入强确认文本执行,或输入“取消”放弃。"); + return result; + } + + private Map toDetail(AgentAuditResponse audit) { + Map detail = new LinkedHashMap<>(); + detail.put("auditId", audit.getAuditId()); + detail.put("confirmationId", audit.getConfirmationId()); + detail.put("userMessage", audit.getUserMessage()); + detail.put("intent", audit.getIntent()); + detail.put("actionId", audit.getActionId()); + detail.put("route", audit.getRoute()); + detail.put("riskLevel", audit.getRiskLevel()); + detail.put("riskCategory", audit.getRiskCategory()); + detail.put("status", audit.getStatus()); + detail.put("statusText", audit.getStatusText()); + detail.put("summary", audit.getSummary()); + detail.put("payloadJson", audit.getPayloadJson()); + detail.put("diffJson", audit.getDiffJson()); + detail.put("planJson", audit.getPlanJson()); + detail.put("resultJson", audit.getResultJson()); + detail.put("errorMessage", audit.getErrorMessage()); + detail.put("operatorId", audit.getOperatorId()); + detail.put("operatorName", audit.getOperatorName()); + detail.put("confirmedTime", audit.getConfirmedTime()); + detail.put("executedTime", audit.getExecutedTime()); + detail.put("createdTime", audit.getCreatedTime()); + detail.put("updatedTime", audit.getUpdatedTime()); + return detail; + } + + private String extractAuditId(String value) { + Matcher matcher = AUDIT_ID_PATTERN.matcher(value); + if (!matcher.find()) { + return ""; + } + return matcher.group(1); + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream().anyMatch(value::contains); + } + + private String normalize(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentAuditListAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentAuditListAgentTool.java new file mode 100644 index 000000000..12001a3aa --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentAuditListAgentTool.java @@ -0,0 +1,188 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.common.core.domain.PageResult; +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentAuditQueryRequest; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.service.SysAgentAuditService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 查询管理员智能体审计记录的通用只读工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentAuditListAgentTool implements AgentTool { + + private static final Pattern NUMBER_PATTERN = Pattern.compile("(\\d+)"); + private static final Map STATUS_KEYWORDS = Map.of( + "待确认", "PREVIEW", + "已确认", "CONFIRMED", + "已取消", "CANCELLED", + "已执行", "EXECUTED", + "成功", "EXECUTED", + "失败", "FAILED" + ); + + private final SysAgentAuditService auditService; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.audit.list"); + definition.setTitle("查询智能体审计记录"); + definition.setDescription("分页查询管理员智能体最近审计记录,支持按状态和工具名过滤。"); + definition.setIntent("system.agent.audit.list"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of( + "pageNum", Map.of("type", "integer", "minimum", 1, "default", 1), + "pageSize", Map.of("type", "integer", "minimum", 1, "maximum", 20, "default", 10), + "status", Map.of( + "type", "string", + "enum", List.of("PREVIEW", "CONFIRMED", "CANCELLED", "EXECUTED", "FAILED") + ), + "actionId", Map.of("type", "string", "maxLength", 160) + )); + definition.setRequiredInputKeys(List.of()); + definition.setRequiredPermissions(List.of("agent:runtime:audit:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (containsAny(normalized, List.of("审计详情", "记录详情", "执行详情", "确认详情"))) { + return Optional.empty(); + } + if (!containsAny(normalized, List.of("智能体审计", "智能体记录", "agent审计", "agent记录", "执行记录", "待确认记录", "失败记录"))) { + return Optional.empty(); + } + + Map input = new LinkedHashMap<>(); + input.put("pageNum", 1); + input.put("pageSize", clamp(firstNumber(normalized, 10), 1, 20)); + String status = resolveStatus(normalized); + if (StringUtils.hasText(status)) { + input.put("status", status); + } + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("查询管理员智能体审计记录"); + call.setInput(input); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("查询智能体审计记录是只读动作,不需要写入预览。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + AgentAuditQueryRequest query = new AgentAuditQueryRequest(); + query.setPageNum(clamp(intValue(call.getInput().get("pageNum"), 1), 1, Integer.MAX_VALUE)); + query.setPageSize(clamp(intValue(call.getInput().get("pageSize"), 10), 1, 20)); + query.setStatus(stringValue(call.getInput().get("status"))); + query.setActionId(stringValue(call.getInput().get("actionId"))); + + PageResult page = auditService.getAuditPage(query); + List> records = page.getRecords() == null ? List.of() : page.getRecords().stream() + .map(this::toSummary) + .toList(); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary(records.isEmpty() + ? "没有查询到匹配的智能体审计记录。" + : "已查询到最近 " + records.size() + " 条智能体审计记录。"); + result.getArtifacts().add(new AgentChatArtifact( + "agentAuditList", + "智能体审计记录", + Map.of( + "pageNum", page.getPageNum() == null ? query.getPageNum() : page.getPageNum(), + "pageSize", page.getPageSize() == null ? query.getPageSize() : page.getPageSize(), + "total", page.getTotal() == null ? records.size() : page.getTotal(), + "records", records + ) + )); + result.getNextActions().add("可以继续按状态过滤,例如:查询失败的智能体审计记录。"); + return result; + } + + private Map toSummary(AgentAuditResponse audit) { + Map item = new LinkedHashMap<>(); + item.put("auditId", audit.getAuditId()); + item.put("actionId", audit.getActionId()); + item.put("intent", audit.getIntent()); + item.put("status", audit.getStatus()); + item.put("statusText", audit.getStatusText()); + item.put("riskCategory", audit.getRiskCategory()); + item.put("summary", audit.getSummary()); + item.put("operatorName", audit.getOperatorName()); + item.put("createdTime", audit.getCreatedTime()); + item.put("executedTime", audit.getExecutedTime()); + item.put("errorMessage", audit.getErrorMessage()); + return item; + } + + private String resolveStatus(String message) { + return STATUS_KEYWORDS.entrySet().stream() + .filter(entry -> message.contains(entry.getKey())) + .map(Map.Entry::getValue) + .findFirst() + .orElse(""); + } + + private int firstNumber(String message, int fallback) { + Matcher matcher = NUMBER_PATTERN.matcher(message); + if (!matcher.find()) { + return fallback; + } + return Integer.parseInt(matcher.group(1)); + } + + private int intValue(Object value, int fallback) { + if (value instanceof Number number) { + return number.intValue(); + } + return fallback; + } + + private int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream().anyMatch(value::contains); + } + + private String stringValue(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentFailureRecoverySupport.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentFailureRecoverySupport.java new file mode 100644 index 000000000..537144d05 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentFailureRecoverySupport.java @@ -0,0 +1,165 @@ +package com.xiaou.system.agent.tools; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentChatArtifact; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 失败恢复上下文的通用解析与 legacy fallback。 + * + * @author xiaou + */ +final class AgentFailureRecoverySupport { + + static final String RECOVERY_QUERY_STATE = "查询目标对象当前状态,确认是否发生部分写入。"; + static final String RECOVERY_RETRY_WITH_NEW_PREVIEW = "修复外部依赖、权限或输入数据后,重新发起同一自然语言请求生成新的预览。"; + static final String RETRY_POLICY_NEW_PREVIEW_REQUIRED = "NEW_PREVIEW_REQUIRED"; + + private static final Pattern AUDIT_ID_PATTERN = Pattern.compile("(agent-audit-[A-Za-z0-9_-]+)"); + private static final TypeReference> MAP_TYPE = new TypeReference<>() { + }; + private static final TypeReference> ARTIFACT_LIST_TYPE = new TypeReference<>() { + }; + + private AgentFailureRecoverySupport() { + } + + static AgentChatArtifact resolveRecoveryArtifact(AgentAuditResponse audit, ObjectMapper objectMapper) { + return parseRecoveryArtifact(audit == null ? null : audit.getResultJson(), objectMapper) + .orElseGet(() -> fallbackRecoveryArtifact(audit)); + } + + static Optional parseRecoveryArtifact(String resultJson, ObjectMapper objectMapper) { + if (!StringUtils.hasText(resultJson)) { + return Optional.empty(); + } + try { + ObjectMapper mapper = objectMapper == null ? new ObjectMapper() : objectMapper; + Map result = mapper.readValue(resultJson, MAP_TYPE); + Object artifactsValue = result.get("artifacts"); + if (artifactsValue == null) { + return Optional.empty(); + } + List artifacts = mapper.convertValue(artifactsValue, ARTIFACT_LIST_TYPE); + return artifacts.stream() + .filter(artifact -> "failureRecovery".equals(artifact.getType())) + .findFirst(); + } catch (IllegalArgumentException | JsonProcessingException ignored) { + return Optional.empty(); + } + } + + static AgentChatArtifact fallbackRecoveryArtifact(AgentAuditResponse audit) { + Map data = new LinkedHashMap<>(); + putIfPresent(data, "auditId", audit == null ? null : audit.getAuditId()); + putIfPresent(data, "idempotencyKey", audit == null ? null : audit.getIdempotencyKey()); + putIfPresent(data, "toolName", audit == null ? null : audit.getActionId()); + putIfPresent(data, "riskLevel", audit == null ? null : audit.getRiskLevel()); + putIfPresent(data, "riskCategory", audit == null ? null : audit.getRiskCategory()); + putIfPresent(data, "status", audit == null ? null : audit.getStatus()); + putIfPresent(data, "errorMessage", audit == null ? null : audit.getErrorMessage()); + data.put("retryPolicy", RETRY_POLICY_NEW_PREVIEW_REQUIRED); + data.put("sameAuditRetryAllowed", false); + data.put("requiresNewPreview", true); + data.put("recommendedActions", List.of( + RECOVERY_QUERY_STATE, + RECOVERY_RETRY_WITH_NEW_PREVIEW + )); + return new AgentChatArtifact("failureRecovery", "失败恢复上下文", data); + } + + static void appendRecommendedActions(AgentToolResult result, AgentChatArtifact recovery) { + if (result == null) { + return; + } + for (String action : recommendedActions(recovery)) { + addIfMissing(result.getNextActions(), action); + } + } + + static List recommendedActions(AgentChatArtifact recovery) { + Object recommendedActions = recovery == null || recovery.getData() == null + ? null + : recovery.getData().get("recommendedActions"); + List actions = new ArrayList<>(); + if (recommendedActions instanceof List list) { + for (Object action : list) { + String value = normalize(action); + if (StringUtils.hasText(value) && !actions.contains(value)) { + actions.add(value); + } + } + } + if (actions.isEmpty()) { + actions.add(RECOVERY_QUERY_STATE); + actions.add(RECOVERY_RETRY_WITH_NEW_PREVIEW); + } + return actions; + } + + static String extractAuditId(String value) { + Matcher matcher = AUDIT_ID_PATTERN.matcher(normalize(value)); + if (!matcher.find()) { + return ""; + } + return matcher.group(1); + } + + static boolean containsAny(String value, List keywords) { + String normalized = normalize(value); + return keywords.stream().anyMatch(normalized::contains); + } + + static String stringValue(Map data, String key) { + if (data == null || !data.containsKey(key)) { + return ""; + } + return normalize(data.get(key)); + } + + static boolean booleanValue(Map data, String key, boolean defaultValue) { + if (data == null || !data.containsKey(key)) { + return defaultValue; + } + Object value = data.get(key); + if (value instanceof Boolean booleanValue) { + return booleanValue; + } + String text = normalize(value); + if ("true".equalsIgnoreCase(text)) { + return true; + } + if ("false".equalsIgnoreCase(text)) { + return false; + } + return defaultValue; + } + + static String normalize(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + static void addIfMissing(List nextActions, String action) { + if (nextActions != null && StringUtils.hasText(action) && !nextActions.contains(action)) { + nextActions.add(action); + } + } + + private static void putIfPresent(Map data, String key, String value) { + if (StringUtils.hasText(value)) { + data.put(key, value); + } + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentOperatorSelfAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentOperatorSelfAgentTool.java new file mode 100644 index 000000000..fd3fbe79c --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentOperatorSelfAgentTool.java @@ -0,0 +1,138 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentOperator; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * 查询当前智能体操作者上下文的通用只读工具。 + * + * @author xiaou + */ +@Component +public class AgentOperatorSelfAgentTool implements AgentTool { + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.operator.self"); + definition.setTitle("查询当前操作者上下文"); + definition.setDescription("查询当前请求在智能体后端运行时中的操作者身份、角色、权限和租户上下文。"); + definition.setIntent("system.agent.operator.self"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of()); + definition.setRequiredInputKeys(List.of()); + definition.setRequiredPermissions(List.of("agent:runtime:operator:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message).toLowerCase(Locale.ROOT); + boolean asksSelfAccess = containsAny(normalized, List.of( + "我在智能体", + "当前智能体操作者", + "当前操作者", + "当前操作员", + "我的角色和权限", + "我的权限", + "我的角色", + "operator self" + )); + boolean asksRuntimeAccess = containsAny(normalized, List.of("我", "当前")) + && containsAny(normalized, List.of("智能体", "agent", "运行时")) + && containsAny(normalized, List.of("角色", "权限", "操作者", "操作员")); + if (!asksSelfAccess && !asksRuntimeAccess) { + return Optional.empty(); + } + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("查询当前智能体操作者上下文"); + call.setInput(new LinkedHashMap<>()); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("查询当前操作者上下文是只读动作,不需要写入预览。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + AgentOperator operator = context == null ? null : context.operator(); + List roles = sorted(operator == null ? List.of() : operator.roles()); + List permissions = sorted(operator == null ? List.of() : operator.permissions()); + + Map data = new LinkedHashMap<>(); + data.put("operatorId", operator == null ? null : operator.id()); + data.put("operatorName", operator == null ? "" : normalize(operator.name())); + data.put("tenantId", operator == null ? "" : normalize(operator.tenantId())); + data.put("roleCount", roles.size()); + data.put("permissionCount", permissions.size()); + data.put("roles", roles); + data.put("permissions", permissions); + data.put("hasWildcardPermission", permissions.contains("*")); + data.put("selfOnly", true); + data.put("source", "AgentExecutionContext.operator"); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary(summary(operator, roles, permissions)); + result.getArtifacts().add(new AgentChatArtifact( + "agentOperatorSelf", + "当前智能体操作者上下文", + data + )); + result.getNextActions().add("如果某个工具被拒绝,可以继续查询该工具详情,比对 requiredPermissions 与当前权限。"); + return result; + } + + private String summary(AgentOperator operator, List roles, List permissions) { + String name = operator == null ? "" : normalize(operator.name()); + if (name.isEmpty()) { + return "当前请求没有加载操作者名称,已返回空的智能体操作者上下文。"; + } + return "当前智能体操作者是 " + name + ",拥有 " + roles.size() + " 个角色、" + + permissions.size() + " 个智能体权限。"; + } + + private List sorted(List values) { + if (values == null || values.isEmpty()) { + return List.of(); + } + return values.stream() + .map(this::normalize) + .filter(value -> !value.isEmpty()) + .distinct() + .sorted(Comparator.naturalOrder()) + .toList(); + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream() + .map(keyword -> keyword.toLowerCase(Locale.ROOT)) + .anyMatch(value::contains); + } + + private String normalize(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentPlannerDiagnosticsAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentPlannerDiagnosticsAgentTool.java new file mode 100644 index 000000000..6bc8819ff --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentPlannerDiagnosticsAgentTool.java @@ -0,0 +1,143 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.ai.prompt.admin.AdminAgentPromptSpecs; +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * 查询智能体 planner 契约与命中规则的通用只读工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentPlannerDiagnosticsAgentTool implements AgentTool { + + private static final double MINIMUM_CONFIDENCE = 0.6D; + + private final AgentToolCatalogService catalogService; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.planner.diagnostics"); + definition.setTitle("查询 planner 诊断信息"); + definition.setDescription("查询管理员智能体 planner 的结构化输出契约、候选过滤规则和当前工具目录摘要。"); + definition.setIntent("system.agent.planner.diagnostics"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of()); + definition.setRequiredInputKeys(List.of()); + definition.setRequiredPermissions(List.of("agent:runtime:planner:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message).toLowerCase(Locale.ROOT); + boolean mentionsPlanner = containsAny(normalized, List.of("planner", "规划器", "计划解析", "plan resolver", "工具候选", "候选工具")); + boolean asksDiagnostics = containsAny(normalized, List.of("诊断", "契约", "规则", "为什么没命中", "怎么解析", "结构化输出")); + if (!mentionsPlanner || !asksDiagnostics) { + return Optional.empty(); + } + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("查询智能体 planner 诊断信息"); + call.setInput(new LinkedHashMap<>()); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("查询 planner 诊断信息是只读动作,不需要写入预览。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + List toolNames = catalogService.definitions().stream() + .map(AgentToolDefinition::getName) + .map(this::normalize) + .filter(name -> !name.isEmpty()) + .sorted() + .toList(); + + Map data = new LinkedHashMap<>(); + data.put("registeredToolCount", toolNames.size()); + data.put("registeredToolNames", toolNames); + data.put("minimumConfidence", MINIMUM_CONFIDENCE); + data.put("prompt", promptDescriptor()); + data.put("structuredOutput", structuredOutputDescriptor()); + data.put("resolverPipeline", List.of( + "DeterministicAgentPlanResolver 先尝试后端工具自身 resolve(message)", + "LlmAgentPlanResolver 仅生成候选工具调用", + "AgentToolRegistry 校验工具必须已注册", + "AgentPolicyEngine 校验 schema、权限、角色、租户和确认策略", + "SysAgentAuditService 只在写入确认链路中保存可信 payload" + )); + data.put("guardrails", List.of( + "未知工具名会被忽略", + "低于最低置信度的候选会被忽略", + "input 会按工具 inputSchema 过滤未知字段", + "missingFields 或缺少 requiredInputKeys 会触发澄清", + "planner 没有执行权,写入动作必须经过 policy 和 audit" + )); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("当前 planner 诊断可用,后端已注册 " + toolNames.size() + " 个候选工具。"); + result.getArtifacts().add(new AgentChatArtifact( + "agentPlannerDiagnostics", + "智能体 planner 诊断信息", + data + )); + result.getNextActions().add("如果某个请求没有命中工具,可以查看工具详情并补充 planner fixture。"); + return result; + } + + private Map promptDescriptor() { + Map prompt = new LinkedHashMap<>(); + prompt.put("key", AdminAgentPromptSpecs.PLAN.key()); + prompt.put("version", AdminAgentPromptSpecs.PLAN.version()); + prompt.put("variables", AdminAgentPromptSpecs.PLAN.templateVariables().stream().sorted().toList()); + return prompt; + } + + private Map structuredOutputDescriptor() { + Map output = new LinkedHashMap<>(); + output.put("type", "object"); + output.put("requiredFields", List.of("toolName", "input", "confidence", "missingFields")); + output.put("toolName", "string"); + output.put("input", "object"); + output.put("confidence", "number[0,1]"); + output.put("missingFields", "string[]"); + return output; + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream() + .map(keyword -> keyword.toLowerCase(Locale.ROOT)) + .anyMatch(value::contains); + } + + private String normalize(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentPlannerDryRunAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentPlannerDryRunAgentTool.java new file mode 100644 index 000000000..77ea8ee68 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentPlannerDryRunAgentTool.java @@ -0,0 +1,241 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentChatErrorCode; +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentPlanResolution; +import com.xiaou.system.agent.AgentPlanResolver; +import com.xiaou.system.agent.AgentResolvedToolCall; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * 对自然语言请求执行 planner 预演的通用只读工具。 + * + * @author xiaou + */ +@Component +public class AgentPlannerDryRunAgentTool implements AgentTool { + + private final ObjectProvider planResolverProvider; + + public AgentPlannerDryRunAgentTool(ObjectProvider planResolverProvider) { + this.planResolverProvider = planResolverProvider; + } + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.planner.dry_run"); + definition.setTitle("智能体 planner 预演"); + definition.setDescription("对一段管理员自然语言请求执行 planner 只读预演,解释会命中哪个后端工具或需要补充哪些字段。"); + definition.setIntent("system.agent.planner.dry_run"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of( + "message", Map.of("type", "string", "minLength", 1, "maxLength", 1000) + )); + definition.setRequiredInputKeys(List.of("message")); + definition.setRequiredPermissions(List.of("agent:runtime:planner:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (!asksPlannerDryRun(normalized)) { + return Optional.empty(); + } + + Map input = new LinkedHashMap<>(); + input.put("message", extractTargetMessage(normalized)); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("对管理员请求执行 planner 只读预演"); + call.setInput(input); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("planner 预演是只读动作,不执行命中的目标工具,也不创建写入审计记录。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + String message = normalize(call == null || call.getInput() == null ? null : call.getInput().get("message")); + if (!StringUtils.hasText(message)) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(false); + result.setSummary("planner 预演缺少要解析的管理员请求。"); + result.setErrorMessage("message 不能为空"); + result.getNextActions().add("请提供 message,例如:planner 预演:帮我查最近3条操作日志。"); + return result; + } + + AgentPlanResolver resolver = planResolverProvider == null ? null : planResolverProvider.getIfAvailable(); + if (resolver == null) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(false); + result.setSummary("planner 不可用,无法执行预演。"); + result.setErrorMessage("AgentPlanResolver is not available"); + return result; + } + + AgentPlanResolution resolution; + try { + resolution = context == null + ? resolver.resolvePlan(message) + : resolver.resolvePlan(new AgentExecutionContext( + context.sessionId(), + message, + context.operator(), + context.trace(), + context.sessionSnapshot() + )); + } catch (Exception e) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(false); + result.setSummary("planner 预演失败:" + exceptionMessage(e)); + result.setErrorMessage(exceptionMessage(e)); + result.getNextActions().add("可以先查询 planner 诊断信息,确认结构化输出契约和工具目录是否正常。"); + return result; + } + + return buildResult(message, resolution); + } + + private AgentToolResult buildResult(String message, AgentPlanResolution resolution) { + AgentPlanResolution safeResolution = resolution == null ? AgentPlanResolution.empty() : resolution; + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + + Map data = new LinkedHashMap<>(); + data.put("requestMessage", message); + data.put("dryRunOnly", true); + data.put("targetExecuted", false); + + if (safeResolution.isResolved()) { + AgentResolvedToolCall resolved = safeResolution.getResolvedCall(); + AgentToolDefinition definition = resolved == null || resolved.tool() == null ? null : resolved.tool().definition(); + AgentToolCall call = resolved == null ? null : resolved.call(); + String toolName = definition == null ? normalize(call == null ? null : call.getToolName()) : definition.getName(); + data.put("status", "RESOLVED"); + data.put("toolName", toolName); + data.put("toolTitle", definition == null ? "" : normalize(definition.getTitle())); + data.put("callSummary", call == null ? "" : normalize(call.getSummary())); + data.put("input", call == null || call.getInput() == null ? Map.of() : call.getInput()); + data.put("riskLevel", definition == null ? "" : normalize(definition.getRiskLevel())); + data.put("riskCategory", definition == null ? "" : normalize(definition.getRiskCategory())); + data.put("confirmationRequired", definition != null && definition.isConfirmationRequired()); + data.put("requiredInputKeys", definition == null ? List.of() : definition.getRequiredInputKeys()); + data.put("requiredPermissions", definition == null ? List.of() : definition.getRequiredPermissions()); + result.setSummary("planner 预演命中工具 " + toolName + ",但没有执行目标工具。"); + result.getNextActions().add("如需继续检查 schema、权限、租户和确认策略,可以对该工具执行 system.agent.policy.dry_run。"); + } else if (safeResolution.getErrorCode() != null) { + data.put("status", "CLARIFICATION"); + data.put("errorCode", errorCode(safeResolution.getErrorCode())); + data.put("message", safeResolution.getMessage()); + data.put("nextActions", safeResolution.getNextActions()); + result.setSummary("planner 预演需要补充信息:" + normalize(safeResolution.getMessage())); + result.getNextActions().addAll(safeResolution.getNextActions()); + } else { + data.put("status", "EMPTY"); + data.put("reason", "没有命中已注册的后端工具候选。"); + result.setSummary("planner 预演未命中可安全执行的后端工具。"); + result.getNextActions().add("可以先问:你能做什么,查看当前已注册工具目录。"); + } + + result.getArtifacts().add(new AgentChatArtifact( + "agentPlannerDryRun", + "智能体 planner 预演结果", + data + )); + return result; + } + + private boolean asksPlannerDryRun(String message) { + String normalized = message.toLowerCase(Locale.ROOT); + if (containsAny(normalized, List.of("planner 结构化输出", "planner 契约", "规划器诊断"))) { + return false; + } + return containsAny(normalized, List.of( + "planner dry run", + "plan resolver dry run", + "planner 预演", + "planner 试跑", + "规划预演", + "规划试跑", + "计划解析预演", + "模拟规划", + "预演 planner", + "试跑 planner", + "解析这句话", + "会命中哪个工具", + "会调用哪个工具", + "会选哪个工具" + )); + } + + private String extractTargetMessage(String message) { + String normalized = normalize(message); + for (String separator : List.of(":", ":", ",", ",")) { + int index = normalized.indexOf(separator); + if (index >= 0 && index + separator.length() < normalized.length()) { + return stripQuotes(normalized.substring(index + separator.length())); + } + } + return stripQuotes(normalized); + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream() + .map(keyword -> keyword.toLowerCase(Locale.ROOT)) + .anyMatch(value::contains); + } + + private String stripQuotes(String value) { + String text = normalize(value); + while (text.length() >= 2 && isWrappedByQuotes(text)) { + text = text.substring(1, text.length() - 1).trim(); + } + return text; + } + + private boolean isWrappedByQuotes(String value) { + char first = value.charAt(0); + char last = value.charAt(value.length() - 1); + return (first == '"' && last == '"') + || (first == '\'' && last == '\'') + || (first == '“' && last == '”') + || (first == '‘' && last == '’'); + } + + private String errorCode(AgentChatErrorCode errorCode) { + return errorCode == null ? "" : errorCode.name(); + } + + private String normalize(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String exceptionMessage(Exception e) { + return StringUtils.hasText(e.getMessage()) ? e.getMessage() : e.getClass().getSimpleName(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentPolicyDryRunAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentPolicyDryRunAgentTool.java new file mode 100644 index 000000000..f03a5ac03 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentPolicyDryRunAgentTool.java @@ -0,0 +1,190 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentChatErrorCode; +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentOperator; +import com.xiaou.system.agent.AgentPolicyDecision; +import com.xiaou.system.agent.AgentPolicyEngine; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * 对指定工具输入执行策略预检的通用只读工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentPolicyDryRunAgentTool implements AgentTool { + + private final AgentToolCatalogService catalogService; + private final AgentPolicyEngine policyEngine; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.policy.dry_run"); + definition.setTitle("智能体策略预检"); + definition.setDescription("对指定工具和输入执行 AgentPolicyEngine 只读预检,解释 schema、权限、角色、租户和确认策略判定。"); + definition.setIntent("system.agent.policy.dry_run"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of( + "toolName", Map.of("type", "string", "minLength", 1, "maxLength", 160), + "input", Map.of("type", "object") + )); + definition.setRequiredInputKeys(List.of("toolName")); + definition.setRequiredPermissions(List.of("agent:runtime:policy:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (!asksPolicyDryRun(normalized)) { + return Optional.empty(); + } + + return catalogService.definitions().stream() + .map(AgentToolDefinition::getName) + .filter(name -> !name.equals(definition().getName())) + .filter(name -> normalized.contains(name)) + .findFirst() + .map(toolName -> { + Map input = new LinkedHashMap<>(); + input.put("toolName", toolName); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("对后端智能体工具执行策略预检"); + call.setInput(input); + return call; + }); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("策略预检是只读动作,不执行目标工具,也不生成写入预览。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + String toolName = normalize(call.getInput().get("toolName")); + Optional matched = catalogService.definitions().stream() + .filter(definition -> toolName.equals(definition.getName())) + .findFirst(); + if (matched.isEmpty()) { + AgentToolResult missing = new AgentToolResult(); + missing.setSuccess(false); + missing.setSummary("没有找到对应的后端智能体工具。"); + missing.setErrorMessage("后端智能体工具不存在: " + toolName); + missing.getNextActions().add("可以先问:你能做什么,查看当前已注册的后端工具目录。"); + return missing; + } + + AgentToolDefinition target = matched.get(); + Map targetInput = targetInput(call.getInput().get("input")); + AgentOperator operator = context == null ? null : context.operator(); + AgentPolicyDecision decision = policyEngine.evaluate(target, targetInput, operator); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary(summary(target, decision)); + result.getArtifacts().add(new AgentChatArtifact( + "agentPolicyDryRun", + "智能体策略预检结果", + descriptor(target, targetInput, operator, decision) + )); + result.getNextActions().add("策略预检不会执行目标工具;真实请求仍必须重新经过 orchestrator、policy、audit 和确认链路。"); + return result; + } + + private Map descriptor(AgentToolDefinition target, + Map input, + AgentOperator operator, + AgentPolicyDecision decision) { + Map data = new LinkedHashMap<>(); + data.put("toolName", target.getName()); + data.put("toolTitle", target.getTitle()); + data.put("input", input); + data.put("operatorId", operator == null ? null : operator.id()); + data.put("operatorName", operator == null ? "" : normalize(operator.name())); + data.put("operatorTenantId", operator == null ? "" : normalize(operator.tenantId())); + data.put("allowed", decision.isAllowed()); + data.put("confirmationRequired", decision.isConfirmationRequired()); + data.put("confirmationText", normalize(decision.getConfirmationText())); + data.put("errorCode", errorCode(decision.getErrorCode())); + data.put("rejectionReason", normalize(decision.getRejectionReason())); + data.put("nextActions", decision.getNextActions()); + data.put("riskLevel", target.getRiskLevel()); + data.put("riskCategory", target.getRiskCategory()); + data.put("destructive", target.isDestructive()); + data.put("requiredInputKeys", target.getRequiredInputKeys()); + data.put("requiredPermissions", target.getRequiredPermissions()); + data.put("requiredRoles", target.getRequiredRoles()); + data.put("tenantScope", target.getTenantScope()); + data.put("engine", "AgentPolicyEngine.evaluate"); + data.put("dryRunOnly", true); + return data; + } + + private String summary(AgentToolDefinition target, AgentPolicyDecision decision) { + if (!decision.isAllowed()) { + return "策略预检拒绝工具 " + target.getName() + ":" + normalize(decision.getRejectionReason()); + } + if (decision.isConfirmationRequired()) { + return "策略预检通过工具 " + target.getName() + ",但真实执行需要强确认。"; + } + return "策略预检通过工具 " + target.getName() + ",真实执行仍会重新校验策略。"; + } + + private Map targetInput(Object value) { + if (!(value instanceof Map raw)) { + return new LinkedHashMap<>(); + } + Map input = new LinkedHashMap<>(); + for (Map.Entry entry : raw.entrySet()) { + if (entry.getKey() != null) { + input.put(String.valueOf(entry.getKey()), entry.getValue()); + } + } + return input; + } + + private boolean asksPolicyDryRun(String message) { + String normalized = message.toLowerCase(Locale.ROOT); + return containsAny(normalized, List.of("策略预检", "策略试跑", "policy dry run", "policy 预检", "policy 试跑", "dry run")) + || (containsAny(normalized, List.of("如果调用", "如果执行")) && normalized.contains("policy")); + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream() + .map(keyword -> keyword.toLowerCase(Locale.ROOT)) + .anyMatch(value::contains); + } + + private String errorCode(AgentChatErrorCode errorCode) { + return errorCode == null ? "" : errorCode.name(); + } + + private String normalize(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRecoveryDryRunAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRecoveryDryRunAgentTool.java new file mode 100644 index 000000000..167fb7498 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRecoveryDryRunAgentTool.java @@ -0,0 +1,251 @@ +package com.xiaou.system.agent.tools; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.service.SysAgentAuditService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * 对失败审计恢复路径执行通用只读安全预演。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentRecoveryDryRunAgentTool implements AgentTool { + + private final SysAgentAuditService auditService; + private final ObjectMapper objectMapper; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.recovery.dry_run"); + definition.setTitle("失败恢复安全预演"); + definition.setDescription("按 auditId 判断失败审计是否允许重试或补偿,只输出安全闸门,不执行目标工具、不写审计。"); + definition.setIntent("system.agent.recovery.dry_run"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of( + "auditId", Map.of("type", "string", "minLength", 1, "maxLength", 120) + )); + definition.setRequiredInputKeys(List.of("auditId")); + definition.setRequiredPermissions(List.of("agent:runtime:audit:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = AgentFailureRecoverySupport.normalize(message); + if (!asksRecoveryDryRun(normalized)) { + return Optional.empty(); + } + + String auditId = AgentFailureRecoverySupport.extractAuditId(normalized); + if (!StringUtils.hasText(auditId)) { + return Optional.empty(); + } + + Map input = new LinkedHashMap<>(); + input.put("auditId", auditId); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("预演失败审计恢复安全闸门"); + call.setInput(input); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("失败恢复安全预演是只读动作,不重试旧审计、不执行补偿、不写审计。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + String auditId = AgentFailureRecoverySupport.normalize( + call == null || call.getInput() == null ? null : call.getInput().get("auditId") + ); + if (!StringUtils.hasText(auditId)) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(false); + result.setSummary("失败恢复安全预演缺少 auditId。"); + result.setErrorMessage("auditId 不能为空"); + result.getNextActions().add("请提供失败审计 auditId,例如:预演一下 agent-audit-1 能不能重试。"); + return result; + } + + AgentAuditResponse audit = auditService.getByAuditId(auditId); + if (audit == null) { + AgentToolResult missing = new AgentToolResult(); + missing.setSuccess(false); + missing.setSummary("没有找到对应的智能体审计记录。"); + missing.setErrorMessage("智能体审计记录不存在: " + auditId); + missing.getNextActions().add("请先查询最近的智能体审计记录,确认 auditId 是否正确。"); + return missing; + } + + AgentChatArtifact recovery = AgentFailureRecoverySupport.resolveRecoveryArtifact(audit, objectMapper); + Map data = buildDryRun(audit, recovery); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary(summary(data)); + result.getArtifacts().add(new AgentChatArtifact( + "failureRecoveryDryRun", + "失败恢复安全预演", + data + )); + for (String action : AgentFailureRecoverySupport.recommendedActions(recovery)) { + AgentFailureRecoverySupport.addIfMissing(result.getNextActions(), action); + } + AgentFailureRecoverySupport.addIfMissing(result.getNextActions(), "不要复用旧失败审计直接重试;需要重新发起自然语言请求生成新的预览。"); + AgentFailureRecoverySupport.addIfMissing(result.getNextActions(), "如果未来需要补偿动作,必须先新增专门的后端 AgentTool 并重新经过 policy、audit 和强确认。"); + return result; + } + + private Map buildDryRun(AgentAuditResponse audit, AgentChatArtifact recovery) { + Map recoveryData = recovery == null || recovery.getData() == null + ? Map.of() + : recovery.getData(); + String retryPolicy = firstNonBlank( + AgentFailureRecoverySupport.stringValue(recoveryData, "retryPolicy"), + AgentFailureRecoverySupport.RETRY_POLICY_NEW_PREVIEW_REQUIRED + ); + boolean sameAuditRetryAllowed = AgentFailureRecoverySupport.booleanValue(recoveryData, "sameAuditRetryAllowed", false); + boolean requiresNewPreview = AgentFailureRecoverySupport.booleanValue(recoveryData, "requiresNewPreview", true); + String status = firstNonBlank( + AgentFailureRecoverySupport.stringValue(recoveryData, "status"), + audit.getStatus() + ); + + List blockers = blockers(status, sameAuditRetryAllowed, requiresNewPreview, retryPolicy); + List recommendedActions = AgentFailureRecoverySupport.recommendedActions(recovery); + + Map data = new LinkedHashMap<>(); + data.put("auditId", firstNonBlank(AgentFailureRecoverySupport.stringValue(recoveryData, "auditId"), audit.getAuditId())); + data.put("idempotencyKey", firstNonBlank(AgentFailureRecoverySupport.stringValue(recoveryData, "idempotencyKey"), audit.getIdempotencyKey())); + data.put("toolName", firstNonBlank(AgentFailureRecoverySupport.stringValue(recoveryData, "toolName"), audit.getActionId())); + data.put("riskLevel", firstNonBlank(AgentFailureRecoverySupport.stringValue(recoveryData, "riskLevel"), audit.getRiskLevel())); + data.put("riskCategory", firstNonBlank(AgentFailureRecoverySupport.stringValue(recoveryData, "riskCategory"), audit.getRiskCategory())); + data.put("status", status); + data.put("errorMessage", firstNonBlank(AgentFailureRecoverySupport.stringValue(recoveryData, "errorMessage"), audit.getErrorMessage())); + data.put("retryPolicy", retryPolicy); + data.put("sameAuditRetryAllowed", sameAuditRetryAllowed); + data.put("requiresNewPreview", requiresNewPreview); + data.put("safeToExecuteNow", false); + data.put("compensationAllowed", false); + data.put("dryRunOnly", true); + data.put("targetExecuted", false); + data.put("auditWritten", false); + data.put("sameAuditRetryDecision", decision( + sameAuditRetryAllowed, + sameAuditRetryAllowed + ? "恢复上下文声明允许同审计重试,但本工具仍只做预演,不会执行。" + : "旧失败审计不可作为重试入口,避免重复写入或绕过新的预览确认。" + )); + data.put("newPreviewDecision", decision( + true, + requiresNewPreview + ? "必须重新发起原始自然语言请求,生成新的 preview/audit/confirm 链路。" + : "当前恢复上下文未强制新预览,但统一运行时仍建议重新进入 preview/audit/confirm 链路。" + )); + data.put("compensationDecision", decision( + false, + "当前版本未开放自动补偿执行;补偿必须先建模为新的后端 AgentTool,再经过 policy、audit 和强确认。" + )); + data.put("blockers", blockers); + data.put("recommendedActions", recommendedActions); + data.put("failureRecovery", recoveryData); + return data; + } + + private List blockers(String status, + boolean sameAuditRetryAllowed, + boolean requiresNewPreview, + String retryPolicy) { + List blockers = new ArrayList<>(); + if (!"FAILED".equalsIgnoreCase(status)) { + blockers.add("当前审计不是 FAILED 状态,失败恢复不适用。"); + } + if (!sameAuditRetryAllowed) { + blockers.add("旧失败审计不可重试。"); + } + if (requiresNewPreview || AgentFailureRecoverySupport.RETRY_POLICY_NEW_PREVIEW_REQUIRED.equalsIgnoreCase(retryPolicy)) { + blockers.add("必须重新生成预览并走新的强确认链路。"); + } + blockers.add("当前工具是只读 dry-run,不执行目标工具、不写审计。"); + return blockers; + } + + private Map decision(boolean allowed, String reason) { + Map decision = new LinkedHashMap<>(); + decision.put("allowed", allowed); + decision.put("reason", reason); + return decision; + } + + private String summary(Map data) { + boolean sameAuditRetryAllowed = Boolean.TRUE.equals(data.get("sameAuditRetryAllowed")); + if (!sameAuditRetryAllowed) { + return "失败恢复 dry-run 完成:旧失败审计不可重试,必须重新生成预览;本次只读,未执行目标工具、未写审计。"; + } + return "失败恢复 dry-run 完成:恢复上下文允许同审计重试判断,但本工具只读,未执行目标工具、未写审计。"; + } + + private boolean asksRecoveryDryRun(String message) { + String normalized = AgentFailureRecoverySupport.normalize(message).toLowerCase(Locale.ROOT); + if (containsAny(normalized, List.of("解释", "恢复上下文")) && !containsAny(normalized, List.of("预演", "试跑", "dry run"))) { + return false; + } + return containsAny(normalized, List.of( + "recovery dry run", + "compensation dry run", + "恢复预演", + "恢复试跑", + "恢复预检", + "重试预演", + "重试试跑", + "重试预检", + "补偿预演", + "补偿试跑", + "补偿预检", + "能不能重试", + "能否重试", + "可以重试", + "能不能补偿", + "能否补偿", + "可以补偿", + "安全闸门" + )); + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream() + .map(keyword -> keyword.toLowerCase(Locale.ROOT)) + .anyMatch(value::contains); + } + + private String firstNonBlank(String first, String fallback) { + return StringUtils.hasText(first) ? first : AgentFailureRecoverySupport.normalize(fallback); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRecoveryExplainAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRecoveryExplainAgentTool.java new file mode 100644 index 000000000..9545bf717 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRecoveryExplainAgentTool.java @@ -0,0 +1,103 @@ +package com.xiaou.system.agent.tools; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.service.SysAgentAuditService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 解释失败审计恢复上下文的通用只读工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentRecoveryExplainAgentTool implements AgentTool { + + private final SysAgentAuditService auditService; + private final ObjectMapper objectMapper; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.recovery.explain"); + definition.setTitle("解释失败恢复上下文"); + definition.setDescription("按 auditId 查询失败审计的 failureRecovery 上下文,只解释恢复路径,不执行补偿动作。"); + definition.setIntent("system.agent.recovery.explain"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of( + "auditId", Map.of("type", "string", "minLength", 1, "maxLength", 120) + )); + definition.setRequiredInputKeys(List.of("auditId")); + definition.setRequiredPermissions(List.of("agent:runtime:audit:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = AgentFailureRecoverySupport.normalize(message); + if (!AgentFailureRecoverySupport.containsAny(normalized, List.of("失败恢复", "恢复上下文", "补偿建议", "怎么处理", "failureRecovery"))) { + return Optional.empty(); + } + + String auditId = AgentFailureRecoverySupport.extractAuditId(normalized); + if (!StringUtils.hasText(auditId)) { + return Optional.empty(); + } + + Map input = new LinkedHashMap<>(); + input.put("auditId", auditId); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("解释失败恢复上下文"); + call.setInput(input); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("解释失败恢复上下文是只读动作,不需要写入预览。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + String auditId = AgentFailureRecoverySupport.normalize(call == null || call.getInput() == null ? null : call.getInput().get("auditId")); + AgentAuditResponse audit = auditService.getByAuditId(auditId); + if (audit == null) { + AgentToolResult missing = new AgentToolResult(); + missing.setSuccess(false); + missing.setSummary("没有找到对应的智能体审计记录。"); + missing.setErrorMessage("智能体审计记录不存在: " + auditId); + missing.getNextActions().add("请先查询最近的智能体审计记录,确认 auditId 是否正确。"); + return missing; + } + + AgentChatArtifact recovery = AgentFailureRecoverySupport.resolveRecoveryArtifact(audit, objectMapper); + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("已解释失败审计 " + audit.getAuditId() + " 的恢复上下文;该工具只读,不会自动重试或补偿。"); + result.getArtifacts().add(recovery); + AgentFailureRecoverySupport.appendRecommendedActions(result, recovery); + result.getNextActions().add("如果要重新处理,请重新发起自然语言请求生成新的预览,不要复用旧失败审计。"); + return result; + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRequestDryRunAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRequestDryRunAgentTool.java new file mode 100644 index 000000000..13218bb42 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRequestDryRunAgentTool.java @@ -0,0 +1,348 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentChatErrorCode; +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentPlanResolution; +import com.xiaou.system.agent.AgentPlanResolver; +import com.xiaou.system.agent.AgentPolicyDecision; +import com.xiaou.system.agent.AgentPolicyEngine; +import com.xiaou.system.agent.AgentResolvedToolCall; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * 对管理员自然语言请求执行统一运行时预演的通用只读工具。 + * + * @author xiaou + */ +@Component +public class AgentRequestDryRunAgentTool implements AgentTool { + + private final ObjectProvider planResolverProvider; + private final AgentPolicyEngine policyEngine; + + public AgentRequestDryRunAgentTool(ObjectProvider planResolverProvider, + AgentPolicyEngine policyEngine) { + this.planResolverProvider = planResolverProvider; + this.policyEngine = policyEngine; + } + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.request.dry_run"); + definition.setTitle("智能体请求预演"); + definition.setDescription("对一段管理员自然语言请求执行 planner、policy 和写入预览的只读沙盘预演,不执行目标工具、不创建审计记录。"); + definition.setIntent("system.agent.request.dry_run"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of( + "message", Map.of("type", "string", "minLength", 1, "maxLength", 1000) + )); + definition.setRequiredInputKeys(List.of("message")); + definition.setRequiredPermissions(List.of("agent:runtime:planner:read", "agent:runtime:policy:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (!asksRequestDryRun(normalized)) { + return Optional.empty(); + } + + Map input = new LinkedHashMap<>(); + input.put("message", extractTargetMessage(normalized)); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("对管理员请求执行统一运行时只读预演"); + call.setInput(input); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("请求预演是只读动作,不执行命中的目标工具,也不创建写入审计记录。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + String message = normalize(call == null || call.getInput() == null ? null : call.getInput().get("message")); + if (!StringUtils.hasText(message)) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(false); + result.setSummary("请求预演缺少要解析的管理员请求。"); + result.setErrorMessage("message 不能为空"); + result.getNextActions().add("请提供 message,例如:运行时预演:帮我查最近3条操作日志。"); + return result; + } + + AgentPlanResolver resolver = planResolverProvider == null ? null : planResolverProvider.getIfAvailable(); + if (resolver == null) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(false); + result.setSummary("planner 不可用,无法执行请求预演。"); + result.setErrorMessage("AgentPlanResolver is not available"); + return result; + } + + AgentExecutionContext dryRunContext = dryRunContext(message, context); + AgentPlanResolution resolution; + try { + resolution = context == null ? resolver.resolvePlan(message) : resolver.resolvePlan(dryRunContext); + } catch (Exception e) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(false); + result.setSummary("请求预演失败:" + exceptionMessage(e)); + result.setErrorMessage(exceptionMessage(e)); + result.getNextActions().add("可以先查询 planner 诊断信息和工具目录,确认结构化输出契约是否正常。"); + return result; + } + + return buildResult(message, resolution, dryRunContext); + } + + private AgentToolResult buildResult(String message, AgentPlanResolution resolution, AgentExecutionContext context) { + AgentPlanResolution safeResolution = resolution == null ? AgentPlanResolution.empty() : resolution; + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + + Map data = baseDescriptor(message); + if (!safeResolution.isResolved()) { + return unresolvedResult(result, data, safeResolution); + } + + AgentResolvedToolCall resolved = safeResolution.getResolvedCall(); + AgentTool tool = resolved == null ? null : resolved.tool(); + AgentToolCall call = resolved == null ? null : resolved.call(); + AgentToolDefinition definition = tool == null ? null : tool.definition(); + if (definition == null) { + data.put("status", "TOOL_NOT_FOUND"); + data.put("policyAllowed", false); + result.setSummary("请求预演命中了空工具定义,已拒绝继续预演。"); + result.getArtifacts().add(artifact(data)); + return result; + } + + Map input = call == null || call.getInput() == null ? Map.of() : call.getInput(); + AgentPolicyDecision decision = policyEngine.evaluate(definition, input, context == null ? null : context.operator()); + data.put("status", decision.isAllowed() + ? (decision.isConfirmationRequired() ? "CONFIRM_REQUIRED" : "WOULD_EXECUTE_READONLY") + : "POLICY_REJECTED"); + data.put("toolName", definition.getName()); + data.put("toolTitle", normalize(definition.getTitle())); + data.put("input", input); + data.put("riskLevel", normalize(definition.getRiskLevel())); + data.put("riskCategory", normalize(definition.getRiskCategory())); + data.put("policyAllowed", decision.isAllowed()); + data.put("confirmationRequired", decision.isConfirmationRequired()); + data.put("confirmationText", normalize(decision.getConfirmationText())); + data.put("errorCode", errorCode(decision.getErrorCode())); + data.put("rejectionReason", normalize(decision.getRejectionReason())); + data.put("policyNextActions", decision.getNextActions()); + data.put("requiredInputKeys", definition.getRequiredInputKeys()); + data.put("requiredPermissions", definition.getRequiredPermissions()); + + if (!decision.isAllowed()) { + result.setSummary("请求预演被策略拒绝:" + normalize(decision.getRejectionReason())); + result.getNextActions().addAll(decision.getNextActions()); + result.getArtifacts().add(artifact(data)); + return result; + } + + if (!decision.isConfirmationRequired()) { + result.setSummary("请求预演通过:真实执行会调用只读工具 " + definition.getName() + ",本次没有执行目标工具。"); + result.getNextActions().add("如需真实查询,请直接发起原始管理员请求。"); + result.getArtifacts().add(artifact(data)); + return result; + } + + return previewWriteTool(result, data, tool, call, context, definition); + } + + private AgentToolResult previewWriteTool(AgentToolResult result, + Map data, + AgentTool tool, + AgentToolCall call, + AgentExecutionContext context, + AgentToolDefinition definition) { + try { + AgentToolPreview preview = tool.preview(call, context); + data.put("targetPreviewed", true); + data.put("previewExecutable", preview.isExecutable()); + data.put("previewSummary", normalize(preview.getSummary())); + data.put("previewBlockedReason", normalize(preview.getBlockedReason())); + data.put("previewPlan", preview.getPlan()); + data.put("previewDiff", preview.getDiff()); + data.put("previewArtifacts", preview.getArtifacts()); + result.setSummary(previewSummary(definition, preview)); + result.setDiff(preview.getDiff()); + result.getArtifacts().addAll(preview.getArtifacts()); + result.getArtifacts().add(artifact(data)); + result.getNextActions().add("真实执行仍会重新经过 orchestrator、policy、audit 和强确认链路。"); + return result; + } catch (Exception e) { + data.put("status", "PREVIEW_ERROR"); + data.put("targetPreviewed", false); + data.put("previewError", exceptionMessage(e)); + result.setSuccess(false); + result.setSummary("请求预演的目标工具预览失败:" + exceptionMessage(e)); + result.setErrorMessage(exceptionMessage(e)); + result.getArtifacts().add(artifact(data)); + result.getNextActions().add("请先查询目标工具详情,确认输入是否完整。"); + return result; + } + } + + private AgentToolResult unresolvedResult(AgentToolResult result, + Map data, + AgentPlanResolution resolution) { + if (resolution.getErrorCode() != null) { + data.put("status", "CLARIFICATION"); + data.put("errorCode", errorCode(resolution.getErrorCode())); + data.put("message", normalize(resolution.getMessage())); + data.put("nextActions", resolution.getNextActions()); + result.setSummary("请求预演需要补充信息:" + normalize(resolution.getMessage())); + result.getNextActions().addAll(resolution.getNextActions()); + } else { + data.put("status", "EMPTY"); + data.put("reason", "没有命中已注册的后端工具候选。"); + result.setSummary("请求预演未命中可安全执行的后端工具。"); + result.getNextActions().add("可以先问:你能做什么,查看当前已注册工具目录。"); + } + result.getArtifacts().add(artifact(data)); + return result; + } + + private Map baseDescriptor(String message) { + Map data = new LinkedHashMap<>(); + data.put("requestMessage", message); + data.put("dryRunOnly", true); + data.put("targetPreviewed", false); + data.put("targetExecuted", false); + data.put("auditCreated", false); + data.put("pipeline", List.of("planner", "policy", "preview-if-confirmation-required")); + return data; + } + + private AgentChatArtifact artifact(Map data) { + return new AgentChatArtifact( + "agentRequestDryRun", + "智能体请求预演结果", + data + ); + } + + private AgentExecutionContext dryRunContext(String message, AgentExecutionContext context) { + if (context == null) { + return null; + } + return new AgentExecutionContext( + context.sessionId(), + message, + context.operator(), + context.trace(), + context.sessionSnapshot() + ); + } + + private String previewSummary(AgentToolDefinition definition, AgentToolPreview preview) { + if (preview != null && !preview.isExecutable()) { + return "请求预演通过策略,但目标工具预览阻断:" + normalize(preview.getBlockedReason()); + } + return "请求预演通过工具 " + definition.getName() + ",真实执行需要强确认;本次只生成预览,没有创建审计记录。"; + } + + private boolean asksRequestDryRun(String message) { + String normalized = message.toLowerCase(Locale.ROOT); + if (containsAny(normalized, List.of( + "planner dry run", + "planner 预演", + "planner 试跑", + "策略预检", + "策略试跑", + "policy dry run", + "policy 预检", + "policy 试跑" + ))) { + return false; + } + return containsAny(normalized, List.of( + "request dry run", + "runtime dry run", + "运行时预演", + "运行时试跑", + "请求预演", + "请求试跑", + "沙盘预演", + "沙盘试跑", + "完整预演", + "如果我说", + "这句话如果执行", + "这个请求如果执行" + )); + } + + private String extractTargetMessage(String message) { + String normalized = normalize(message); + for (String separator : List.of(":", ":", ",", ",")) { + int index = normalized.indexOf(separator); + if (index >= 0 && index + separator.length() < normalized.length()) { + return stripQuotes(normalized.substring(index + separator.length())); + } + } + return stripQuotes(normalized); + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream() + .map(keyword -> keyword.toLowerCase(Locale.ROOT)) + .anyMatch(value::contains); + } + + private String stripQuotes(String value) { + String text = normalize(value); + while (text.length() >= 2 && isWrappedByQuotes(text)) { + text = text.substring(1, text.length() - 1).trim(); + } + return text; + } + + private boolean isWrappedByQuotes(String value) { + char first = value.charAt(0); + char last = value.charAt(value.length() - 1); + return (first == '"' && last == '"') + || (first == '\'' && last == '\'') + || (first == '“' && last == '”') + || (first == '‘' && last == '’'); + } + + private String errorCode(AgentChatErrorCode errorCode) { + return errorCode == null ? "" : errorCode.name(); + } + + private String normalize(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String exceptionMessage(Exception e) { + return StringUtils.hasText(e.getMessage()) ? e.getMessage() : e.getClass().getSimpleName(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRuntimeObservabilityAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRuntimeObservabilityAgentTool.java new file mode 100644 index 000000000..ac91066c6 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRuntimeObservabilityAgentTool.java @@ -0,0 +1,210 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentSessionProperties; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 聚合管理员智能体后端统一运行时观测快照的通用只读工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentRuntimeObservabilityAgentTool implements AgentTool { + + private final AgentToolCatalogService catalogService; + private final AgentSessionProperties sessionProperties; + private final MeterRegistry meterRegistry; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.runtime.observability"); + definition.setTitle("查询智能体运行时观测快照"); + definition.setDescription("聚合管理员智能体后端工具目录、会话配置、指标健康度和告警事件,提供 dashboard 前的统一后端观测快照。"); + definition.setIntent("system.agent.runtime.observability"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of()); + definition.setRequiredInputKeys(List.of()); + definition.setRequiredPermissions(List.of("agent:runtime:status:read", "agent:runtime:metrics:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message).toLowerCase(Locale.ROOT); + boolean mentionsRuntime = containsAny(normalized, List.of("智能体", "agent", "运行时", "runtime")); + boolean asksObservability = containsAny(normalized, List.of( + "观测", + "可观测", + "observability", + "snapshot", + "快照", + "总览", + "dashboard", + "仪表盘", + "告警总览", + "运行总览" + )); + if (!mentionsRuntime || !asksObservability) { + return Optional.empty(); + } + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("查询管理员智能体运行时观测快照"); + call.setInput(new LinkedHashMap<>()); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("查询运行时观测快照是只读动作,不执行目标工具、不创建审计记录。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + List definitions = safeDefinitions(); + AgentToolMetricsSnapshotSupport.MetricsSnapshot metricsSnapshot = new AgentToolMetricsSnapshotSupport(meterRegistry).snapshot(); + Map tools = tools(definitions); + Map metrics = metricsSnapshot.healthData(); + Map alerts = metricsSnapshot.alertsData(); + String status = status(definitions, metrics, alerts); + + Map data = new LinkedHashMap<>(); + data.put("status", status); + data.put("tools", tools); + data.put("session", session()); + data.put("metrics", metrics); + data.put("alerts", alerts); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("智能体运行时观测快照完成:" + status + + ",工具 " + definitions.size() + + " 个,调用 " + formatCount(metricsSnapshot.invocationCount()) + + " 次,活跃告警 " + alerts.get("alertCount") + " 个。"); + result.getArtifacts().add(new AgentChatArtifact( + "agentRuntimeObservability", + "智能体运行时观测快照", + data + )); + appendNextActions(result, status, definitions, metricsSnapshot); + return result; + } + + private List safeDefinitions() { + List definitions = catalogService == null ? List.of() : catalogService.definitions(); + return definitions == null ? List.of() : definitions; + } + + private Map tools(List definitions) { + Map tools = new LinkedHashMap<>(); + int readonlyToolCount = (int) definitions.stream().filter(this::isReadonly).count(); + int destructiveToolCount = (int) definitions.stream().filter(this::isDestructive).count(); + tools.put("toolCount", definitions.size()); + tools.put("readonlyToolCount", readonlyToolCount); + tools.put("writeRiskToolCount", definitions.size() - readonlyToolCount); + tools.put("destructiveToolCount", destructiveToolCount); + tools.put("riskCategoryCounts", riskCategoryCounts(definitions)); + return tools; + } + + private Map riskCategoryCounts(List definitions) { + return definitions.stream() + .collect(Collectors.groupingBy( + definition -> normalize(definition.getRiskCategory()).toUpperCase(Locale.ROOT), + LinkedHashMap::new, + Collectors.counting() + )); + } + + private Map session() { + Map session = new LinkedHashMap<>(); + session.put("repository", sessionProperties.getRepository()); + session.put("maxRecentTurns", sessionProperties.getMaxRecentTurns()); + session.put("ttlSeconds", sessionProperties.getTtlSeconds()); + session.put("redisKeyPrefix", sessionProperties.getRedisKeyPrefix()); + return session; + } + + private String status(List definitions, Map metrics, Map alerts) { + if ("ALERT".equals(alerts.get("status")) || "ALERT".equals(metrics.get("status"))) { + return "ALERT"; + } + if (definitions.isEmpty() || "WARN".equals(alerts.get("status")) || "WARN".equals(metrics.get("status"))) { + return "WARN"; + } + return "OK"; + } + + private void appendNextActions(AgentToolResult result, + String status, + List definitions, + AgentToolMetricsSnapshotSupport.MetricsSnapshot metricsSnapshot) { + if (definitions.isEmpty()) { + result.getNextActions().add("先检查 AgentTool Bean 注册,当前未发现任何后端工具。"); + } + if (metricsSnapshot.invocationCount() <= 0D) { + result.getNextActions().add("先通过统一聊天接口执行一次只读工具请求,确认指标采集链路能产生样本。"); + } + if (metricsSnapshot.errorCount() > 0D) { + result.getNextActions().add("优先查询失败工具对应的智能体审计记录,定位错误输入、权限或外部依赖。"); + } + if (metricsSnapshot.blockedCount() > 0D) { + result.getNextActions().add("结合审计记录确认 blocked 是否为预期策略拦截,避免误报。"); + } + if ("OK".equals(status)) { + result.getNextActions().add("当前观测快照正常,可以继续接入 dashboard 或外部告警适配层。"); + } + } + + private boolean isReadonly(AgentToolDefinition definition) { + return "readonly".equalsIgnoreCase(normalize(definition.getRiskLevel())) + && "READONLY".equalsIgnoreCase(normalize(definition.getRiskCategory())) + && !definition.isDestructive(); + } + + private boolean isDestructive(AgentToolDefinition definition) { + return definition.isDestructive() + || "DESTRUCTIVE".equalsIgnoreCase(normalize(definition.getRiskCategory())); + } + + private String formatCount(double value) { + if (value == Math.rint(value)) { + return String.valueOf((long) value); + } + return String.valueOf(value); + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream() + .map(keyword -> keyword.toLowerCase(Locale.ROOT)) + .anyMatch(value::contains); + } + + private String normalize(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRuntimeReadinessAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRuntimeReadinessAgentTool.java new file mode 100644 index 000000000..cfd00eb5b --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRuntimeReadinessAgentTool.java @@ -0,0 +1,337 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentSessionProperties; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 管理员智能体后端统一运行时上线自检工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentRuntimeReadinessAgentTool implements AgentTool { + + private static final Set SESSION_REPOSITORIES = Set.of("memory", "redis", "db"); + private static final List CORE_TOOLS = List.of( + "system.agent.tools.list", + "system.agent.tools.detail", + "system.agent.tools.search", + "system.agent.tools.validate", + "system.agent.tools.access_check", + "system.agent.runtime.status", + "system.agent.runtime.readiness", + "system.agent.runtime.observability", + "system.agent.session.context", + "system.agent.operator.self", + "system.agent.planner.diagnostics", + "system.agent.planner.dry_run", + "system.agent.request.dry_run", + "system.agent.policy.dry_run", + "system.agent.metrics.summary", + "system.agent.metrics.health", + "system.agent.metrics.alerts", + "system.agent.audit.list", + "system.agent.audit.detail", + "system.agent.recovery.explain", + "system.agent.recovery.dry_run" + ); + + private final AgentToolCatalogService catalogService; + private final AgentSessionProperties sessionProperties; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.runtime.readiness"); + definition.setTitle("智能体运行时上线自检"); + definition.setDescription("检查后端统一智能体运行时的核心工具、工具定义、权限声明、写入确认和会话配置是否具备上线条件。"); + definition.setIntent("system.agent.runtime.readiness"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of()); + definition.setRequiredInputKeys(List.of()); + definition.setRequiredPermissions(List.of("agent:runtime:readiness:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message).toLowerCase(Locale.ROOT); + if (!asksReadiness(normalized)) { + return Optional.empty(); + } + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("执行管理员智能体运行时上线自检"); + call.setInput(new LinkedHashMap<>()); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("智能体运行时上线自检是只读动作,不执行目标业务工具、不创建审计记录。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + List definitions = safeDefinitions(); + List> checks = new ArrayList<>(); + + checkToolCatalog(definitions, checks); + checkCoreTools(definitions, checks); + checkDefinitionNames(definitions, checks); + checkPermissions(definitions, checks); + checkWriteConfirmation(definitions, checks); + checkSessionConfiguration(checks); + + int blockedCount = countChecks(checks, "BLOCKED"); + int warnCount = countChecks(checks, "WARN"); + String status = blockedCount > 0 ? "BLOCKED" : (warnCount > 0 ? "WARN" : "READY"); + + Map data = new LinkedHashMap<>(); + data.put("status", status); + data.put("toolCount", definitions.size()); + data.put("coreToolCount", CORE_TOOLS.size()); + data.put("blockedCount", blockedCount); + data.put("warnCount", warnCount); + data.put("checks", checks); + data.put("session", sessionDescriptor()); + data.put("coreTools", CORE_TOOLS); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("智能体运行时 readiness 自检完成:" + status + ",工具数 " + definitions.size() + + ",BLOCKED=" + blockedCount + ",WARN=" + warnCount + "。"); + result.getArtifacts().add(new AgentChatArtifact( + "agentRuntimeReadiness", + "智能体运行时上线自检", + data + )); + appendNextActions(result, status); + return result; + } + + private void checkToolCatalog(List definitions, List> checks) { + addCheck( + checks, + "toolCatalog.present", + definitions.isEmpty() ? "BLOCKED" : "READY", + definitions.isEmpty() ? "未发现任何后端 AgentTool,统一运行时无法承载请求。" : "已发现后端 AgentTool 目录。", + Map.of("toolCount", definitions.size()) + ); + } + + private void checkCoreTools(List definitions, List> checks) { + Set names = definitions.stream() + .map(AgentToolDefinition::getName) + .map(this::normalize) + .filter(StringUtils::hasText) + .collect(Collectors.toSet()); + List missing = CORE_TOOLS.stream() + .filter(toolName -> !names.contains(toolName)) + .toList(); + addCheck( + checks, + "coreTools.present", + missing.isEmpty() ? "READY" : "BLOCKED", + missing.isEmpty() ? "核心运行时工具已齐备。" : "核心运行时工具缺失,统一运行时自描述和安全门禁不完整。", + Map.of("missingTools", missing) + ); + } + + private void checkDefinitionNames(List definitions, List> checks) { + Map counts = definitions.stream() + .map(AgentToolDefinition::getName) + .map(this::normalize) + .filter(StringUtils::hasText) + .collect(Collectors.groupingBy(Function.identity(), LinkedHashMap::new, Collectors.counting())); + List duplicateNames = counts.entrySet().stream() + .filter(entry -> entry.getValue() > 1) + .map(Map.Entry::getKey) + .toList(); + long blankCount = definitions.stream() + .filter(definition -> !StringUtils.hasText(normalize(definition.getName()))) + .count(); + boolean ok = duplicateNames.isEmpty() && blankCount == 0; + addCheck( + checks, + "toolDefinitions.names", + ok ? "READY" : "BLOCKED", + ok ? "工具名非空且无重复。" : "工具名存在空值或重复,注册表无法安全路由。", + Map.of("duplicateNames", duplicateNames, "blankNameCount", blankCount) + ); + } + + private void checkPermissions(List definitions, List> checks) { + List invalidTools = definitions.stream() + .filter(this::hasInvalidPermissions) + .map(AgentToolDefinition::getName) + .map(this::normalize) + .toList(); + addCheck( + checks, + "toolDefinitions.permissions", + invalidTools.isEmpty() ? "READY" : "BLOCKED", + invalidTools.isEmpty() ? "所有工具都声明了 agent:* 权限。" : "部分工具缺少 agent:* 权限声明。", + Map.of("invalidTools", invalidTools) + ); + } + + private void checkWriteConfirmation(List definitions, List> checks) { + List invalidWriteTools = definitions.stream() + .filter(definition -> !isReadonly(definition)) + .filter(definition -> !definition.isConfirmationRequired() || !StringUtils.hasText(definition.getConfirmationText())) + .map(AgentToolDefinition::getName) + .map(this::normalize) + .toList(); + addCheck( + checks, + "toolDefinitions.writeConfirmation", + invalidWriteTools.isEmpty() ? "READY" : "BLOCKED", + invalidWriteTools.isEmpty() ? "写入/破坏性工具均声明强确认。" : "写入/破坏性工具缺少强确认,不能上线。", + Map.of("invalidTools", invalidWriteTools) + ); + } + + private void checkSessionConfiguration(List> checks) { + String repository = normalize(sessionProperties.getRepository()).toLowerCase(Locale.ROOT); + boolean repositoryOk = SESSION_REPOSITORIES.contains(repository); + addCheck( + checks, + "session.repository", + repositoryOk ? "READY" : "WARN", + repositoryOk ? "会话上下文存储类型有效。" : "会话上下文存储类型不是 memory/redis/db,将使用非预期配置。", + Map.of("repository", repository) + ); + + boolean maxTurnsOk = sessionProperties.getMaxRecentTurns() > 0; + addCheck( + checks, + "session.maxRecentTurns", + maxTurnsOk ? "READY" : "WARN", + maxTurnsOk ? "会话上下文保留轮数有效。" : "会话上下文保留轮数应大于 0。", + Map.of("maxRecentTurns", sessionProperties.getMaxRecentTurns()) + ); + + boolean redisPrefixOk = !"redis".equals(repository) || StringUtils.hasText(sessionProperties.getRedisKeyPrefix()); + addCheck( + checks, + "session.redisKeyPrefix", + redisPrefixOk ? "READY" : "WARN", + redisPrefixOk ? "Redis 会话 key 前缀有效。" : "Redis 会话存储需要配置 key 前缀。", + Map.of("redisKeyPrefix", normalize(sessionProperties.getRedisKeyPrefix())) + ); + } + + private void appendNextActions(AgentToolResult result, String status) { + if ("BLOCKED".equals(status)) { + result.getNextActions().add("先补齐 BLOCKED 检查项,再继续扩展业务工具。"); + result.getNextActions().add("修复后重新运行 system.agent.runtime.readiness。"); + return; + } + if ("WARN".equals(status)) { + result.getNextActions().add("修复 WARN 检查项后再做上线验收。"); + result.getNextActions().add("如需继续扩展,仍按 AgentTool + planner fixture + 权限声明接入。"); + return; + } + result.getNextActions().add("运行时底座已就绪,可以继续按 AgentTool 模式接入新的后台能力。"); + } + + private List safeDefinitions() { + List definitions = catalogService == null ? List.of() : catalogService.definitions(); + return definitions == null ? List.of() : definitions; + } + + private boolean hasInvalidPermissions(AgentToolDefinition definition) { + List permissions = definition.getRequiredPermissions() == null ? List.of() : definition.getRequiredPermissions(); + return permissions.isEmpty() + || permissions.stream() + .map(this::normalize) + .anyMatch(permission -> !permission.startsWith("agent:")); + } + + private boolean isReadonly(AgentToolDefinition definition) { + return "readonly".equalsIgnoreCase(normalize(definition.getRiskLevel())) + && "READONLY".equalsIgnoreCase(normalize(definition.getRiskCategory())) + && !definition.isDestructive(); + } + + private Map sessionDescriptor() { + Map session = new LinkedHashMap<>(); + session.put("repository", sessionProperties.getRepository()); + session.put("maxRecentTurns", sessionProperties.getMaxRecentTurns()); + session.put("ttlSeconds", sessionProperties.getTtlSeconds()); + session.put("redisKeyPrefix", sessionProperties.getRedisKeyPrefix()); + return session; + } + + private void addCheck(List> checks, + String id, + String status, + String message, + Map details) { + Map check = new LinkedHashMap<>(); + check.put("id", id); + check.put("status", status); + check.put("message", message); + check.put("details", details == null ? Map.of() : details); + checks.add(check); + } + + private int countChecks(List> checks, String status) { + return (int) checks.stream() + .filter(check -> status.equals(check.get("status"))) + .count(); + } + + private boolean asksReadiness(String message) { + boolean mentionsRuntime = containsAny(message, List.of("智能体", "agent", "运行时", "runtime")); + boolean asksReadiness = containsAny(message, List.of( + "readiness", + "ready", + "就绪", + "上线", + "验收", + "体检", + "自检", + "上线检查", + "上线自检" + )); + return mentionsRuntime && asksReadiness; + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream() + .map(keyword -> keyword.toLowerCase(Locale.ROOT)) + .anyMatch(value::contains); + } + + private String normalize(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRuntimeStatusAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRuntimeStatusAgentTool.java new file mode 100644 index 000000000..74e3fe007 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentRuntimeStatusAgentTool.java @@ -0,0 +1,181 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentSessionProperties; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 查询管理员智能体后端运行时状态的通用只读工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentRuntimeStatusAgentTool implements AgentTool { + + private final AgentToolCatalogService catalogService; + private final AgentSessionProperties sessionProperties; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.runtime.status"); + definition.setTitle("查询智能体运行时状态"); + definition.setDescription("查询管理员智能体后端统一运行时的工具注册、风险分布和会话配置状态。"); + definition.setIntent("system.agent.runtime.status"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of()); + definition.setRequiredInputKeys(List.of()); + definition.setRequiredPermissions(List.of("agent:runtime:status:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (mentionsMoreSpecificRuntimeCheck(normalized)) { + return Optional.empty(); + } + boolean mentionsRuntime = containsAny(normalized, List.of("智能体", "agent", "运行时", "runtime")); + boolean asksStatus = containsAny(normalized, List.of("状态", "运行情况", "运行状态")); + if (!mentionsRuntime || !asksStatus) { + return Optional.empty(); + } + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("查询管理员智能体运行时状态"); + call.setInput(new LinkedHashMap<>()); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("查询智能体运行时状态是只读动作,不需要写入预览。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + List definitions = catalogService.definitions(); + int toolCount = definitions.size(); + int readonlyToolCount = (int) definitions.stream().filter(this::isReadonly).count(); + int destructiveToolCount = (int) definitions.stream().filter(this::isDestructive).count(); + int writeRiskToolCount = toolCount - readonlyToolCount; + + Map status = new LinkedHashMap<>(); + status.put("status", "UP"); + status.put("toolCount", toolCount); + status.put("readonlyToolCount", readonlyToolCount); + status.put("writeRiskToolCount", writeRiskToolCount); + status.put("destructiveToolCount", destructiveToolCount); + status.put("riskCategoryCounts", riskCategoryCounts(definitions)); + status.put("session", sessionDescriptor()); + status.put("registeredTools", definitions.stream().map(this::toolDescriptor).toList()); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("管理员智能体运行时正常,已注册 " + toolCount + " 个后端工具,会话存储:" + + sessionProperties.getRepository() + "。"); + result.getArtifacts().add(new AgentChatArtifact( + "agentRuntimeStatus", + "智能体运行时状态", + status + )); + result.getNextActions().add("可以继续问:你能做什么,或查询最近的智能体审计记录。"); + return result; + } + + private Map riskCategoryCounts(List definitions) { + return definitions.stream() + .collect(Collectors.groupingBy( + definition -> normalize(definition.getRiskCategory()).toUpperCase(Locale.ROOT), + LinkedHashMap::new, + Collectors.counting() + )); + } + + private Map sessionDescriptor() { + Map session = new LinkedHashMap<>(); + session.put("repository", sessionProperties.getRepository()); + session.put("maxRecentTurns", sessionProperties.getMaxRecentTurns()); + session.put("ttlSeconds", sessionProperties.getTtlSeconds()); + session.put("redisKeyPrefix", sessionProperties.getRedisKeyPrefix()); + return session; + } + + private Map toolDescriptor(AgentToolDefinition definition) { + Map item = new LinkedHashMap<>(); + item.put("name", definition.getName()); + item.put("title", definition.getTitle()); + item.put("riskLevel", definition.getRiskLevel()); + item.put("riskCategory", definition.getRiskCategory()); + item.put("destructive", definition.isDestructive()); + item.put("requiredPermissions", definition.getRequiredPermissions()); + return item; + } + + private boolean isReadonly(AgentToolDefinition definition) { + return "readonly".equalsIgnoreCase(normalize(definition.getRiskLevel())) + && "READONLY".equalsIgnoreCase(normalize(definition.getRiskCategory())) + && !definition.isDestructive(); + } + + private boolean isDestructive(AgentToolDefinition definition) { + return definition.isDestructive() + || "DESTRUCTIVE".equalsIgnoreCase(normalize(definition.getRiskCategory())); + } + + private boolean containsAny(String value, List keywords) { + String normalized = value.toLowerCase(Locale.ROOT); + return keywords.stream() + .map(keyword -> keyword.toLowerCase(Locale.ROOT)) + .anyMatch(normalized::contains); + } + + private boolean mentionsMoreSpecificRuntimeCheck(String value) { + return containsAny(value, List.of( + "工具调用健康", + "工具指标", + "工具调用指标", + "指标健康", + "metrics health", + "metric health", + "metrics", + "告警", + "alert", + "错误率", + "慢调用", + "异常", + "阻断", + "blocked", + "就绪", + "readiness", + "上线", + "自检", + "验收" + )); + } + + private String normalize(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentSessionContextAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentSessionContextAgentTool.java new file mode 100644 index 000000000..86e0ba619 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentSessionContextAgentTool.java @@ -0,0 +1,130 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentSessionProperties; +import com.xiaou.system.agent.AgentSessionSnapshot; +import com.xiaou.system.agent.AgentSessionTurn; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 查询当前管理员智能体会话上下文的通用只读工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentSessionContextAgentTool implements AgentTool { + + private static final int TEXT_LIMIT = 500; + + private final AgentSessionProperties sessionProperties; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.session.context"); + definition.setTitle("查询当前会话上下文"); + definition.setDescription("查询当前管理员智能体会话已加载的最近对话摘要,用于解释 planner 上下文来源。"); + definition.setIntent("system.agent.session.context"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of()); + definition.setRequiredInputKeys(List.of()); + definition.setRequiredPermissions(List.of("agent:runtime:session:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (!containsAny(normalized, List.of("你记住了什么", "记住了什么", "会话上下文", "当前上下文", "上下文是什么"))) { + return Optional.empty(); + } + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("查询当前管理员智能体会话上下文"); + call.setInput(new LinkedHashMap<>()); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("查询当前会话上下文是只读动作,不需要写入预览。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + AgentSessionSnapshot snapshot = snapshot(context); + List turns = snapshot.getRecentTurns() == null ? List.of() : snapshot.getRecentTurns(); + + Map data = new LinkedHashMap<>(); + data.put("sessionId", normalize(snapshot.getSessionId())); + data.put("recentTurnCount", turns.size()); + data.put("maxRecentTurns", Math.max(sessionProperties.getMaxRecentTurns(), 0)); + data.put("recentTurns", turns.stream().map(this::turnDescriptor).toList()); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("当前会话已保留 " + turns.size() + " 轮智能体上下文。"); + result.getArtifacts().add(new AgentChatArtifact( + "agentSessionContext", + "当前会话上下文", + data + )); + result.getNextActions().add("如果上下文不符合预期,可以开启新的聊天会话重新描述任务。"); + return result; + } + + private AgentSessionSnapshot snapshot(AgentExecutionContext context) { + if (context == null || context.sessionSnapshot() == null) { + return new AgentSessionSnapshot(); + } + return context.sessionSnapshot(); + } + + private Map turnDescriptor(AgentSessionTurn turn) { + Map item = new LinkedHashMap<>(); + if (turn == null) { + return item; + } + item.put("message", limit(turn.getMessage())); + item.put("status", normalize(turn.getStatus())); + item.put("answer", limit(turn.getAnswer())); + item.put("toolName", normalize(turn.getToolName())); + item.put("auditId", normalize(turn.getAuditId())); + item.put("traceId", normalize(turn.getTraceId())); + return item; + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream().anyMatch(value::contains); + } + + private String limit(String value) { + String text = normalize(value); + if (text.length() <= TEXT_LIMIT) { + return text; + } + return text.substring(0, TEXT_LIMIT); + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolAccessCheckAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolAccessCheckAgentTool.java new file mode 100644 index 000000000..c6badfd78 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolAccessCheckAgentTool.java @@ -0,0 +1,193 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentOperator; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * 查询当前操作者对指定智能体工具的访问条件。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentToolAccessCheckAgentTool implements AgentTool { + + private final AgentToolCatalogService catalogService; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.tools.access_check"); + definition.setTitle("检查当前操作者工具访问"); + definition.setDescription("检查当前请求操作者是否满足某个后端智能体工具声明的权限、角色和租户策略。"); + definition.setIntent("system.agent.tools.access_check"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of( + "toolName", Map.of("type", "string", "minLength", 1, "maxLength", 160) + )); + definition.setRequiredInputKeys(List.of("toolName")); + definition.setRequiredPermissions(List.of("agent:runtime:tool-access:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (!asksAccessCheck(normalized)) { + return Optional.empty(); + } + + return catalogService.definitions().stream() + .map(AgentToolDefinition::getName) + .filter(name -> !name.equals(definition().getName())) + .filter(name -> normalized.contains(name)) + .findFirst() + .map(toolName -> { + Map input = new LinkedHashMap<>(); + input.put("toolName", toolName); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("检查当前操作者对后端智能体工具的访问条件"); + call.setInput(input); + return call; + }); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("检查工具访问条件是只读动作,不需要写入预览。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + String toolName = normalize(call.getInput().get("toolName")); + Optional matched = catalogService.definitions().stream() + .filter(definition -> toolName.equals(definition.getName())) + .findFirst(); + if (matched.isEmpty()) { + AgentToolResult missing = new AgentToolResult(); + missing.setSuccess(false); + missing.setSummary("没有找到对应的后端智能体工具。"); + missing.setErrorMessage("后端智能体工具不存在: " + toolName); + missing.getNextActions().add("可以先问:你能做什么,查看当前已注册的后端工具目录。"); + return missing; + } + + AgentToolDefinition target = matched.get(); + AgentOperator operator = context == null ? null : context.operator(); + List requiredPermissions = normalizedList(target.getRequiredPermissions()); + List requiredRoles = normalizedList(target.getRequiredRoles()); + List missingPermissions = requiredPermissions.stream() + .filter(permission -> operator == null || !operator.hasPermission(permission)) + .toList(); + List missingRoles = missingRoles(operator, requiredRoles); + boolean tenantRuntimeCheckRequired = "SAME_TENANT".equalsIgnoreCase(normalize(target.getTenantScope())); + boolean accessAllowed = missingPermissions.isEmpty() && missingRoles.isEmpty(); + + Map data = new LinkedHashMap<>(); + data.put("toolName", target.getName()); + data.put("toolTitle", target.getTitle()); + data.put("accessAllowed", accessAllowed); + data.put("operatorId", operator == null ? null : operator.id()); + data.put("operatorName", operator == null ? "" : normalize(operator.name())); + data.put("operatorTenantId", operator == null ? "" : normalize(operator.tenantId())); + data.put("requiredPermissions", requiredPermissions); + data.put("missingPermissions", missingPermissions); + data.put("requiredRoles", requiredRoles); + data.put("missingRoles", missingRoles); + data.put("tenantScope", normalize(target.getTenantScope())); + data.put("tenantRuntimeCheckRequired", tenantRuntimeCheckRequired); + data.put("riskLevel", target.getRiskLevel()); + data.put("riskCategory", target.getRiskCategory()); + data.put("destructive", target.isDestructive()); + data.put("requiresConfirmation", requiresConfirmation(target)); + data.put("confirmationText", target.getConfirmationText()); + data.put("requiredInputKeys", target.getRequiredInputKeys()); + data.put("notes", notes(tenantRuntimeCheckRequired)); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary(accessAllowed + ? "当前操作者可以访问工具 " + target.getName() + ";真实执行时仍会校验输入 schema、租户和确认策略。" + : "当前操作者暂时不能访问工具 " + target.getName() + ",请查看缺失权限或角色。"); + result.getArtifacts().add(new AgentChatArtifact( + "agentToolAccessCheck", + "当前操作者工具访问检查", + data + )); + result.getNextActions().add("如果访问被拒绝,可以先补齐权限种子或角色授权,再重新通过统一聊天入口发起请求。"); + return result; + } + + private boolean asksAccessCheck(String message) { + String normalized = message.toLowerCase(Locale.ROOT); + return containsAny(normalized, List.of("我能不能用", "我能否使用", "我可以用", "有没有权限用", "是否可以使用", "访问检查", "权限检查")); + } + + private List missingRoles(AgentOperator operator, List requiredRoles) { + if (requiredRoles.isEmpty()) { + return List.of(); + } + boolean matched = requiredRoles.stream().anyMatch(role -> operator != null && operator.hasRole(role)); + return matched ? List.of() : requiredRoles; + } + + private List notes(boolean tenantRuntimeCheckRequired) { + if (!tenantRuntimeCheckRequired) { + return List.of("这是静态访问自检;真实执行仍由 AgentPolicyEngine 重新校验。"); + } + return List.of( + "这是静态访问自检;真实执行仍由 AgentPolicyEngine 重新校验。", + "该工具要求 SAME_TENANT,真实执行时还会校验输入 tenantId 与当前操作者租户一致。" + ); + } + + private boolean requiresConfirmation(AgentToolDefinition definition) { + return definition.isConfirmationRequired() + || definition.isDestructive() + || !"readonly".equalsIgnoreCase(normalize(definition.getRiskLevel())) + || !"READONLY".equalsIgnoreCase(normalize(definition.getRiskCategory())); + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream() + .map(keyword -> keyword.toLowerCase(Locale.ROOT)) + .anyMatch(value::contains); + } + + private List normalizedList(List values) { + if (values == null || values.isEmpty()) { + return List.of(); + } + return values.stream() + .map(this::normalize) + .filter(StringUtils::hasText) + .distinct() + .toList(); + } + + private String normalize(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolCatalogAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolCatalogAgentTool.java new file mode 100644 index 000000000..bcb6dfb9c --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolCatalogAgentTool.java @@ -0,0 +1,108 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 查询后端智能体工具目录的通用只读工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentToolCatalogAgentTool implements AgentTool { + + private final AgentToolCatalogService catalogService; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.tools.list"); + definition.setTitle("查询智能体工具目录"); + definition.setDescription("列出当前后端统一智能体已注册工具及其输入、风险和访问声明。"); + definition.setIntent("system.agent.tools.list"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of()); + definition.setRequiredInputKeys(List.of()); + definition.setRequiredPermissions(List.of("agent:runtime:tool-catalog:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (!containsAny(normalized, List.of("你能做什么", "能做什么", "有哪些工具", "工具目录", "能力列表", "支持什么", "怎么用智能体"))) { + return Optional.empty(); + } + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("查询当前后端智能体工具目录"); + call.setInput(new LinkedHashMap<>()); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("查询智能体工具目录是只读动作,不需要写入预览。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + List> tools = catalogService.definitions().stream() + .map(this::descriptor) + .toList(); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("当前后端统一智能体已注册 " + tools.size() + " 个工具。新增能力只需要新增后端 AgentTool Bean,并补齐权限和 planner fixture。"); + result.getArtifacts().add(new AgentChatArtifact( + "agentToolCatalog", + "后端智能体工具目录", + Map.of("tools", tools) + )); + result.getNextActions().add("你可以直接描述要处理的后台任务;写入和破坏性动作会先生成预览并要求强确认。"); + return result; + } + + private Map descriptor(AgentToolDefinition definition) { + Map item = new LinkedHashMap<>(); + item.put("name", definition.getName()); + item.put("title", definition.getTitle()); + item.put("description", definition.getDescription()); + item.put("riskLevel", definition.getRiskLevel()); + item.put("riskCategory", definition.getRiskCategory()); + item.put("route", definition.getRoute()); + item.put("inputSchema", definition.getInputSchema()); + item.put("requiredInputKeys", definition.getRequiredInputKeys()); + item.put("requiredPermissions", definition.getRequiredPermissions()); + item.put("requiredRoles", definition.getRequiredRoles()); + item.put("tenantScope", definition.getTenantScope()); + return item; + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream().anyMatch(value::contains); + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolDefinitionValidateAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolDefinitionValidateAgentTool.java new file mode 100644 index 000000000..4f5943f95 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolDefinitionValidateAgentTool.java @@ -0,0 +1,288 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 校验后端智能体工具定义契约的通用只读工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentToolDefinitionValidateAgentTool implements AgentTool { + + private static final Set TENANT_SCOPES = Set.of("ANY", "SAME_TENANT"); + + private final AgentToolCatalogService catalogService; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.tools.validate"); + definition.setTitle("校验智能体工具定义"); + definition.setDescription("校验当前后端统一智能体工具目录中的 definition 契约、schema、权限和确认策略。"); + definition.setIntent("system.agent.tools.validate"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of( + "toolName", Map.of("type", "string", "minLength", 1, "maxLength", 160) + )); + definition.setRequiredInputKeys(List.of()); + definition.setRequiredPermissions(List.of("agent:runtime:tool-catalog:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message).toLowerCase(Locale.ROOT); + if (!asksValidation(normalized)) { + return Optional.empty(); + } + + Map input = new LinkedHashMap<>(); + catalogService.definitions().stream() + .map(AgentToolDefinition::getName) + .filter(StringUtils::hasText) + .filter(normalized::contains) + .findFirst() + .ifPresent(toolName -> input.put("toolName", toolName)); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("校验后端智能体工具定义契约"); + call.setInput(input); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("校验智能体工具定义是只读动作,不需要写入预览。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + String toolName = normalize(call == null || call.getInput() == null ? null : call.getInput().get("toolName")); + List definitions = catalogService.definitions(); + List selected = selectedDefinitions(definitions, toolName); + if (StringUtils.hasText(toolName) && selected.isEmpty()) { + AgentToolResult missing = new AgentToolResult(); + missing.setSuccess(false); + missing.setSummary("没有找到对应的后端智能体工具。"); + missing.setErrorMessage("后端智能体工具不存在: " + toolName); + missing.getNextActions().add("可以先问:你能做什么,查看当前已注册的后端工具目录。"); + return missing; + } + + Map nameCounts = definitions.stream() + .map(AgentToolDefinition::getName) + .map(this::normalize) + .filter(StringUtils::hasText) + .collect(Collectors.groupingBy(Function.identity(), LinkedHashMap::new, Collectors.counting())); + List> issues = new ArrayList<>(); + for (AgentToolDefinition definition : selected) { + validateDefinition(definition, nameCounts, issues); + } + + Map data = new LinkedHashMap<>(); + data.put("status", issues.isEmpty() ? "PASS" : "FAILED"); + data.put("toolName", toolName); + data.put("totalCount", selected.size()); + data.put("issueCount", issues.size()); + data.put("validCount", selected.size() - invalidToolCount(issues)); + data.put("issues", issues); + data.put("checkedRules", List.of( + "required metadata text", + "route starts with /", + "tenantScope is ANY or SAME_TENANT", + "requiredInputKeys exist in inputSchema", + "inputSchema fields declare type", + "requiredPermissions use agent:* namespace", + "write/destructive tools require confirmation text", + "readonly tools do not require confirmation", + "tool names are unique" + )); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary(issues.isEmpty() + ? "智能体工具定义契约全部通过,共校验 " + selected.size() + " 个工具。" + : "智能体工具定义契约发现 " + issues.size() + " 个问题,请先修复后再继续扩展。"); + result.getArtifacts().add(new AgentChatArtifact( + "agentToolDefinitionValidation", + "智能体工具定义契约校验结果", + data + )); + result.getNextActions().add(issues.isEmpty() + ? "新增工具时继续遵守 AgentToolDefinition 契约,并补 planner fixture。" + : "优先修复 schema、权限和确认策略问题,再运行后端智能体回归。"); + return result; + } + + private List selectedDefinitions(List definitions, String toolName) { + List safeDefinitions = definitions == null ? List.of() : definitions; + if (!StringUtils.hasText(toolName)) { + return safeDefinitions; + } + return safeDefinitions.stream() + .filter(definition -> toolName.equals(normalize(definition.getName()))) + .toList(); + } + + private void validateDefinition(AgentToolDefinition definition, + Map nameCounts, + List> issues) { + String name = normalize(definition == null ? null : definition.getName()); + if (!StringUtils.hasText(name)) { + addIssue(issues, name, "name", "ERROR", "definition.name must not be blank"); + return; + } + if (nameCounts.getOrDefault(name, 0L) > 1) { + addIssue(issues, name, "name", "ERROR", "duplicate tool name: " + name); + } + + requireText(issues, name, "title", definition.getTitle()); + requireText(issues, name, "description", definition.getDescription()); + requireText(issues, name, "intent", definition.getIntent()); + requireText(issues, name, "riskLevel", definition.getRiskLevel()); + requireText(issues, name, "riskCategory", definition.getRiskCategory()); + + String route = normalize(definition.getRoute()); + if (!StringUtils.hasText(route) || !route.startsWith("/")) { + addIssue(issues, name, "route", "ERROR", "route must start with /"); + } + + if (!TENANT_SCOPES.contains(normalize(definition.getTenantScope()))) { + addIssue(issues, name, "tenantScope", "ERROR", "tenantScope must be ANY or SAME_TENANT"); + } + + validateSchema(issues, name, definition); + validateAccess(issues, name, definition); + validateConfirmation(issues, name, definition); + } + + private void validateSchema(List> issues, String name, AgentToolDefinition definition) { + Map schema = definition.getInputSchema() == null ? Map.of() : definition.getInputSchema(); + for (String key : safeList(definition.getRequiredInputKeys())) { + if (!schema.containsKey(key)) { + addIssue(issues, name, "requiredInputKeys", "ERROR", + "required input key is missing from inputSchema: " + key); + } + } + for (Map.Entry entry : schema.entrySet()) { + String fieldName = normalize(entry.getKey()); + if (!StringUtils.hasText(fieldName)) { + addIssue(issues, name, "inputSchema", "ERROR", "inputSchema field name must not be blank"); + continue; + } + if (!(entry.getValue() instanceof Map fieldSchema)) { + addIssue(issues, name, "inputSchema." + fieldName, "ERROR", "inputSchema field must be an object"); + continue; + } + Object type = fieldSchema.get("type"); + if (!(type instanceof String typeText) || !StringUtils.hasText(typeText)) { + addIssue(issues, name, "inputSchema." + fieldName, "ERROR", "inputSchema field must declare type"); + } + } + } + + private void validateAccess(List> issues, String name, AgentToolDefinition definition) { + List permissions = safeList(definition.getRequiredPermissions()); + if (permissions.isEmpty()) { + addIssue(issues, name, "requiredPermissions", "ERROR", "requiredPermissions must not be empty"); + return; + } + for (String permission : permissions) { + if (!permission.startsWith("agent:")) { + addIssue(issues, name, "requiredPermissions", "ERROR", + "permission must use agent:* namespace: " + permission); + } + } + } + + private void validateConfirmation(List> issues, String name, AgentToolDefinition definition) { + boolean readonly = "READONLY".equals(normalize(definition.getRiskCategory())) + && "readonly".equalsIgnoreCase(normalize(definition.getRiskLevel())) + && !definition.isDestructive(); + if (readonly) { + if (definition.isConfirmationRequired()) { + addIssue(issues, name, "confirmation", "ERROR", "readonly tool should not require confirmation"); + } + return; + } + + if (!definition.isConfirmationRequired()) { + addIssue(issues, name, "confirmation", "ERROR", "write/destructive tool must require confirmation"); + } + if (!StringUtils.hasText(definition.getConfirmationText())) { + addIssue(issues, name, "confirmationText", "ERROR", "write/destructive tool must declare confirmationText"); + } + } + + private int invalidToolCount(List> issues) { + return (int) issues.stream() + .map(issue -> normalize(issue.get("toolName"))) + .filter(StringUtils::hasText) + .distinct() + .count(); + } + + private void requireText(List> issues, String toolName, String field, String value) { + if (!StringUtils.hasText(value)) { + addIssue(issues, toolName, field, "ERROR", field + " must not be blank"); + } + } + + private void addIssue(List> issues, String toolName, String field, String severity, String message) { + Map issue = new LinkedHashMap<>(); + issue.put("toolName", normalize(toolName)); + issue.put("field", field); + issue.put("severity", severity); + issue.put("message", message); + issues.add(issue); + } + + private boolean asksValidation(String message) { + return containsAny(message, List.of("工具定义自检", "工具契约", "工具校验", "工具定义是否合规", "definition validate", "contract check")) + || (message.contains("工具") && containsAny(message, List.of("自检", "校验", "合规", "检查"))); + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream() + .map(keyword -> keyword.toLowerCase(Locale.ROOT)) + .anyMatch(value::contains); + } + + private List safeList(List values) { + return values == null ? List.of() : values.stream() + .map(this::normalize) + .filter(StringUtils::hasText) + .toList(); + } + + private String normalize(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolDetailAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolDetailAgentTool.java new file mode 100644 index 000000000..268ddefae --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolDetailAgentTool.java @@ -0,0 +1,143 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * 查询单个后端智能体工具定义的通用只读工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentToolDetailAgentTool implements AgentTool { + + private final AgentToolCatalogService catalogService; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.tools.detail"); + definition.setTitle("查询智能体工具详情"); + definition.setDescription("按工具名查询后端统一智能体工具的输入 schema、风险、权限和确认策略。"); + definition.setIntent("system.agent.tools.detail"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of( + "toolName", Map.of("type", "string", "minLength", 1, "maxLength", 160) + )); + definition.setRequiredInputKeys(List.of("toolName")); + definition.setRequiredPermissions(List.of("agent:runtime:tool-catalog:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (!containsAny(normalized, List.of("工具详情", "工具定义", "需要什么权限", "输入schema", "输入 schema", "风险等级"))) { + return Optional.empty(); + } + + return catalogService.definitions().stream() + .map(AgentToolDefinition::getName) + .filter(name -> !name.equals(definition().getName())) + .filter(name -> normalized.contains(name)) + .findFirst() + .map(toolName -> { + Map input = new LinkedHashMap<>(); + input.put("toolName", toolName); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("查询后端智能体工具详情"); + call.setInput(input); + return call; + }); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("查询智能体工具详情是只读动作,不需要写入预览。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + String toolName = normalize(call.getInput().get("toolName")); + Optional matched = catalogService.definitions().stream() + .filter(definition -> toolName.equals(definition.getName())) + .findFirst(); + if (matched.isEmpty()) { + AgentToolResult missing = new AgentToolResult(); + missing.setSuccess(false); + missing.setSummary("没有找到对应的后端智能体工具。"); + missing.setErrorMessage("后端智能体工具不存在: " + toolName); + missing.getNextActions().add("可以先问:你能做什么,查看当前已注册的后端工具目录。"); + return missing; + } + + AgentToolDefinition definition = matched.get(); + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("已查询到后端智能体工具 " + definition.getName() + " 的定义详情。"); + result.getArtifacts().add(new AgentChatArtifact( + "agentToolDetail", + "智能体工具详情", + descriptor(definition) + )); + result.getNextActions().add("新增或修改工具时,请同步维护权限声明和 planner fixture。"); + return result; + } + + private Map descriptor(AgentToolDefinition definition) { + Map item = new LinkedHashMap<>(); + item.put("name", definition.getName()); + item.put("title", definition.getTitle()); + item.put("description", definition.getDescription()); + item.put("intent", definition.getIntent()); + item.put("route", definition.getRoute()); + item.put("riskLevel", definition.getRiskLevel()); + item.put("riskCategory", definition.getRiskCategory()); + item.put("destructive", definition.isDestructive()); + item.put("confirmationRequired", definition.isConfirmationRequired()); + item.put("requiresConfirmation", requiresConfirmation(definition)); + item.put("confirmationText", definition.getConfirmationText()); + item.put("inputSchema", definition.getInputSchema()); + item.put("requiredInputKeys", definition.getRequiredInputKeys()); + item.put("requiredPermissions", definition.getRequiredPermissions()); + item.put("requiredRoles", definition.getRequiredRoles()); + item.put("tenantScope", definition.getTenantScope()); + return item; + } + + private boolean requiresConfirmation(AgentToolDefinition definition) { + return definition.isConfirmationRequired() + || definition.isDestructive() + || !"readonly".equalsIgnoreCase(normalize(definition.getRiskLevel())) + || !"READONLY".equalsIgnoreCase(normalize(definition.getRiskCategory()).toUpperCase(Locale.ROOT)); + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream().anyMatch(value::contains); + } + + private String normalize(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsAlertsAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsAlertsAgentTool.java new file mode 100644 index 000000000..9b7baa926 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsAlertsAgentTool.java @@ -0,0 +1,166 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * 评估管理员智能体工具调用指标告警事件的通用只读工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentToolMetricsAlertsAgentTool implements AgentTool { + + private final MeterRegistry meterRegistry; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.metrics.alerts"); + definition.setTitle("评估智能体工具指标告警"); + definition.setDescription("根据管理员智能体工具调用指标评估标准化告警事件,用于 dashboard 和外部告警接入。"); + definition.setIntent("system.agent.metrics.alerts"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of( + "slowThresholdMs", Map.of( + "type", "number", + "minimum", 1, + "maximum", 600000, + "description", "慢调用阈值,单位毫秒。" + ), + "errorThreshold", Map.of( + "type", "number", + "minimum", 1, + "maximum", 100000, + "description", "触发错误告警的错误调用次数阈值。" + ), + "blockedThreshold", Map.of( + "type", "number", + "minimum", 1, + "maximum", 100000, + "description", "触发阻断告警的 blocked 调用次数阈值。" + ) + )); + definition.setRequiredInputKeys(List.of()); + definition.setRequiredPermissions(List.of("agent:runtime:metrics:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message).toLowerCase(Locale.ROOT); + boolean mentionsAgentOrTool = containsAny(normalized, List.of("智能体", "agent", "工具指标", "工具调用指标", "工具调用", "工具")); + boolean asksAlertEvaluation = asksAlertEvaluation(normalized); + if (!mentionsAgentOrTool || !asksAlertEvaluation) { + return Optional.empty(); + } + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("评估管理员智能体工具指标告警"); + call.setInput(new LinkedHashMap<>()); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("评估工具指标告警是只读动作,不执行目标工具、不创建审计记录。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + double slowThresholdMs = threshold(call, "slowThresholdMs", AgentToolMetricsSnapshotSupport.DEFAULT_SLOW_THRESHOLD_MS); + double errorThreshold = threshold(call, "errorThreshold", AgentToolMetricsSnapshotSupport.DEFAULT_ERROR_THRESHOLD); + double blockedThreshold = threshold(call, "blockedThreshold", AgentToolMetricsSnapshotSupport.DEFAULT_BLOCKED_THRESHOLD); + AgentToolMetricsSnapshotSupport.MetricsSnapshot snapshot = new AgentToolMetricsSnapshotSupport(meterRegistry) + .snapshot(slowThresholdMs, errorThreshold, blockedThreshold); + Map data = snapshot.alertsData(); + String status = String.valueOf(data.get("status")); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("智能体工具指标告警评估完成:" + status + ",活跃告警 " + data.get("alertCount") + + " 个,调用 " + formatCount(snapshot.invocationCount()) + " 次。"); + result.getArtifacts().add(new AgentChatArtifact( + "agentToolMetricsAlerts", + "智能体工具指标告警评估", + data + )); + appendNextActions(result, status, snapshot.invocationCount(), snapshot.errorCount(), snapshot.blockedCount(), snapshot.slowSeriesCount()); + return result; + } + + private void appendNextActions(AgentToolResult result, + String status, + double invocationCount, + double errorCount, + double blockedCount, + int slowSeriesCount) { + if (invocationCount <= 0D) { + result.getNextActions().add("先通过统一聊天接口执行一次只读工具请求,确认指标采集链路能产生样本。"); + } + if (errorCount > 0D) { + result.getNextActions().add("优先查询失败工具对应的智能体审计记录,定位错误输入、权限或外部依赖。"); + } + if (blockedCount > 0D) { + result.getNextActions().add("结合审计记录确认 blocked 是否为预期策略拦截,避免误报。"); + } + if (slowSeriesCount > 0) { + result.getNextActions().add("针对慢调用工具补充服务层耗时定位,必要时拆分查询或增加索引。"); + } + if ("OK".equals(status)) { + result.getNextActions().add("当前没有活跃告警,可以把该契约接入 dashboard 或外部告警系统。"); + } + } + + private double threshold(AgentToolCall call, String key, double defaultValue) { + Map input = call == null || call.getInput() == null ? Map.of() : call.getInput(); + Object value = input.get(key); + if (value instanceof Number number && number.doubleValue() > 0D) { + return number.doubleValue(); + } + return defaultValue; + } + + private String formatCount(double value) { + if (value == Math.rint(value)) { + return String.valueOf((long) value); + } + return String.valueOf(value); + } + + private boolean asksAlertEvaluation(String value) { + boolean asksAlert = containsAny(value, List.of("告警", "alert", "alerts")); + boolean asksEvaluation = containsAny(value, List.of("规则", "评估", "事件", "信号", "列表", "有哪些", "rule", "rules", "evaluate")); + return asksAlert && asksEvaluation; + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream() + .map(keyword -> keyword.toLowerCase(Locale.ROOT)) + .anyMatch(value::contains); + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsHealthAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsHealthAgentTool.java new file mode 100644 index 000000000..290664853 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsHealthAgentTool.java @@ -0,0 +1,143 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Locale; + +/** + * 检查管理员智能体工具调用指标健康度的通用只读工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentToolMetricsHealthAgentTool implements AgentTool { + + private final MeterRegistry meterRegistry; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.metrics.health"); + definition.setTitle("检查智能体工具指标健康度"); + definition.setDescription("检查管理员智能体后端工具调用指标是否存在错误、阻断或慢调用信号,用于告警和上线验收。"); + definition.setIntent("system.agent.metrics.health"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of()); + definition.setRequiredInputKeys(List.of()); + definition.setRequiredPermissions(List.of("agent:runtime:metrics:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message).toLowerCase(Locale.ROOT); + if (asksAlertEvaluation(normalized)) { + return Optional.empty(); + } + boolean mentionsAgentOrTool = containsAny(normalized, List.of("智能体", "agent", "工具调用", "工具")); + boolean asksHealth = containsAny(normalized, List.of("健康", "告警", "异常", "错误率", "慢调用", "health", "alert")); + boolean asksMetrics = containsAny(normalized, List.of("指标", "metrics", "调用")); + if (!mentionsAgentOrTool || (!asksHealth && !asksMetrics)) { + return Optional.empty(); + } + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("检查管理员智能体工具调用指标健康度"); + call.setInput(new LinkedHashMap<>()); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("检查工具调用指标健康度是只读动作,不执行目标工具、不创建审计记录。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + AgentToolMetricsSnapshotSupport.MetricsSnapshot snapshot = new AgentToolMetricsSnapshotSupport(meterRegistry).snapshot(); + Map data = snapshot.healthData(); + String status = String.valueOf(data.get("status")); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary(summary(status, snapshot.invocationCount(), snapshot.errorCount(), snapshot.blockedCount(), snapshot.slowSeriesCount())); + result.getArtifacts().add(new AgentChatArtifact( + "agentToolMetricsHealth", + "智能体工具指标健康度", + data + )); + appendNextActions(result, status, snapshot.invocationCount(), snapshot.errorCount(), snapshot.slowSeriesCount()); + return result; + } + + private String summary(String status, + double invocationCount, + double errorCount, + double blockedCount, + int slowSeriesCount) { + return "智能体工具指标健康检查完成:" + status + + ",调用 " + formatCount(invocationCount) + + " 次,错误 " + formatCount(errorCount) + + " 次,阻断 " + formatCount(blockedCount) + + " 次,慢调用序列 " + slowSeriesCount + " 个。"; + } + + private void appendNextActions(AgentToolResult result, + String status, + double invocationCount, + double errorCount, + int slowSeriesCount) { + if (invocationCount <= 0D) { + result.getNextActions().add("先通过统一聊天接口执行一次只读工具请求,确认指标链路能采集到调用样本。"); + } + if (errorCount > 0D) { + result.getNextActions().add("优先查询失败工具对应的智能体审计记录,定位错误输入、权限或外部依赖。"); + } + if (slowSeriesCount > 0) { + result.getNextActions().add("针对慢调用工具补充服务层耗时定位,必要时拆分查询或增加索引。"); + } + if ("HEALTHY".equals(status)) { + result.getNextActions().add("当前指标健康,可以继续接入 dashboard 或外部告警系统。"); + } + } + + private String formatCount(double value) { + if (value == Math.rint(value)) { + return String.valueOf((long) value); + } + return String.valueOf(value); + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream().anyMatch(value::contains); + } + + private boolean asksAlertEvaluation(String value) { + boolean asksAlert = containsAny(value, List.of("告警", "alert", "alerts")); + boolean asksEvaluation = containsAny(value, List.of("规则", "评估", "事件", "信号", "列表", "有哪些", "rule", "rules", "evaluate")); + return asksAlert && asksEvaluation; + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsSnapshotSupport.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsSnapshotSupport.java new file mode 100644 index 000000000..7a3e5e4cf --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsSnapshotSupport.java @@ -0,0 +1,351 @@ +package com.xiaou.system.agent.tools; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.RequiredArgsConstructor; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 智能体工具调用指标的统一只读视图。 + * + * @author xiaou + */ +@RequiredArgsConstructor +final class AgentToolMetricsSnapshotSupport { + + static final double DEFAULT_SLOW_THRESHOLD_MS = 1000D; + static final double DEFAULT_ERROR_THRESHOLD = 1D; + static final double DEFAULT_BLOCKED_THRESHOLD = 1D; + + private static final String INVOCATIONS_METER = "xiaou.agent.tool.invocations"; + private static final String DURATION_METER = "xiaou.agent.tool.duration"; + + private final MeterRegistry meterRegistry; + + MetricsSnapshot snapshot() { + return snapshot(DEFAULT_SLOW_THRESHOLD_MS, DEFAULT_ERROR_THRESHOLD, DEFAULT_BLOCKED_THRESHOLD); + } + + MetricsSnapshot snapshot(double slowThresholdMs, double errorThreshold, double blockedThreshold) { + List> series = metricSeries(); + double invocationCount = sumCount(series); + double errorCount = sumOutcome(series, "error"); + double blockedCount = sumOutcome(series, "blocked"); + List> slowSeries = series.stream() + .filter(item -> ((Double) item.get("maxDurationMs")) >= slowThresholdMs) + .toList(); + return new MetricsSnapshot( + series, + invocationCount, + errorCount, + blockedCount, + slowSeries, + slowThresholdMs, + errorThreshold, + blockedThreshold + ); + } + + private List> metricSeries() { + return meterRegistry.getMeters().stream() + .filter(meter -> INVOCATIONS_METER.equals(meter.getId().getName())) + .filter(Counter.class::isInstance) + .map(Counter.class::cast) + .map(this::series) + .sorted(Comparator + ., String>comparing(item -> String.valueOf(item.get("outcome"))) + .thenComparing(item -> String.valueOf(item.get("tool"))) + .thenComparing(item -> String.valueOf(item.get("phase")))) + .toList(); + } + + private Map series(Counter counter) { + Meter.Id id = counter.getId(); + Timer timer = meterRegistry.find(DURATION_METER) + .tag("tool", tag(id, "tool")) + .tag("phase", tag(id, "phase")) + .tag("outcome", tag(id, "outcome")) + .tag("risk_level", tag(id, "risk_level")) + .tag("risk_category", tag(id, "risk_category")) + .timer(); + double count = counter.count(); + double totalDurationMs = timer == null ? 0D : timer.totalTime(TimeUnit.MILLISECONDS); + double maxDurationMs = timer == null ? 0D : timer.max(TimeUnit.MILLISECONDS); + double meanDurationMs = timer == null || timer.count() <= 0 ? 0D : timer.mean(TimeUnit.MILLISECONDS); + + Map item = new LinkedHashMap<>(); + item.put("tool", tag(id, "tool")); + item.put("phase", tag(id, "phase")); + item.put("outcome", tag(id, "outcome")); + item.put("riskLevel", tag(id, "risk_level")); + item.put("riskCategory", tag(id, "risk_category")); + item.put("count", count); + item.put("durationCount", timer == null ? 0D : (double) timer.count()); + item.put("totalDurationMs", totalDurationMs); + item.put("maxDurationMs", maxDurationMs); + item.put("meanDurationMs", meanDurationMs); + return item; + } + + private String tag(Meter.Id id, String key) { + String value = id.getTag(key); + return value == null ? "unknown" : value; + } + + private double sumCount(List> series) { + return series.stream() + .mapToDouble(item -> (Double) item.get("count")) + .sum(); + } + + private double sumOutcome(List> series, String outcome) { + return series.stream() + .filter(item -> outcome.equalsIgnoreCase(String.valueOf(item.get("outcome")))) + .mapToDouble(item -> (Double) item.get("count")) + .sum(); + } + + static final class MetricsSnapshot { + + private final List> series; + private final double invocationCount; + private final double errorCount; + private final double blockedCount; + private final List> slowSeries; + private final double slowThresholdMs; + private final double errorThreshold; + private final double blockedThreshold; + + private MetricsSnapshot(List> series, + double invocationCount, + double errorCount, + double blockedCount, + List> slowSeries, + double slowThresholdMs, + double errorThreshold, + double blockedThreshold) { + this.series = series; + this.invocationCount = invocationCount; + this.errorCount = errorCount; + this.blockedCount = blockedCount; + this.slowSeries = slowSeries; + this.slowThresholdMs = slowThresholdMs; + this.errorThreshold = errorThreshold; + this.blockedThreshold = blockedThreshold; + } + + double invocationCount() { + return invocationCount; + } + + double errorCount() { + return errorCount; + } + + double blockedCount() { + return blockedCount; + } + + int slowSeriesCount() { + return slowSeries.size(); + } + + Map summaryData() { + Map data = new LinkedHashMap<>(); + data.put("invocationCount", invocationCount); + data.put("seriesCount", series.size()); + data.put("hasMetrics", invocationCount > 0D); + data.put("series", seriesByCountDesc()); + return data; + } + + Map healthData() { + List> checks = healthChecks(); + Map data = new LinkedHashMap<>(); + data.put("status", healthStatus(checks)); + data.put("invocationCount", invocationCount); + data.put("seriesCount", series.size()); + data.put("errorCount", errorCount); + data.put("blockedCount", blockedCount); + data.put("slowSeriesCount", slowSeries.size()); + data.put("slowSeriesThresholdMs", slowThresholdMs); + data.put("checks", checks); + data.put("series", series); + data.put("slowSeries", slowSeries); + return data; + } + + Map alertsData() { + List> alerts = alerts(); + Map data = new LinkedHashMap<>(); + data.put("status", alertsStatus(alerts)); + data.put("invocationCount", invocationCount); + data.put("seriesCount", series.size()); + data.put("errorCount", errorCount); + data.put("blockedCount", blockedCount); + data.put("slowSeriesCount", slowSeries.size()); + data.put("slowThresholdMs", slowThresholdMs); + data.put("errorThreshold", errorThreshold); + data.put("blockedThreshold", blockedThreshold); + data.put("alertCount", alerts.size()); + data.put("alertCountBySeverity", alertCountBySeverity(alerts)); + data.put("rules", rules()); + data.put("alerts", alerts); + data.put("series", series); + data.put("slowSeries", slowSeries); + return data; + } + + private List> seriesByCountDesc() { + return series.stream() + .sorted(Comparator + ., Double>comparing(item -> (Double) item.get("count")) + .reversed() + .thenComparing(item -> String.valueOf(item.get("tool")))) + .toList(); + } + + private List> healthChecks() { + List> checks = new ArrayList<>(); + addCheck(checks, + "metrics.present", + invocationCount > 0D ? "HEALTHY" : "WARN", + invocationCount > 0D ? "已采集到智能体工具调用指标。" : "暂未采集到智能体工具调用指标,无法判断运行质量。", + Map.of("invocationCount", invocationCount)); + addCheck(checks, + "metrics.errors", + errorCount > 0D ? "ALERT" : "HEALTHY", + errorCount > 0D ? "存在工具执行或预览错误,需要优先排查。" : "未发现工具错误指标。", + Map.of("errorCount", errorCount)); + addCheck(checks, + "metrics.blocked", + blockedCount > 0D ? "WARN" : "HEALTHY", + blockedCount > 0D ? "存在预览阻断,建议结合审计确认是否为预期拦截。" : "未发现预览阻断指标。", + Map.of("blockedCount", blockedCount)); + addCheck(checks, + "metrics.slowSeries", + slowSeries.isEmpty() ? "HEALTHY" : "WARN", + slowSeries.isEmpty() ? "未发现超过阈值的慢调用指标。" : "存在超过阈值的慢调用指标。", + Map.of("slowSeriesCount", slowSeries.size(), "thresholdMs", slowThresholdMs)); + return checks; + } + + private String healthStatus(List> checks) { + if (checks.stream().anyMatch(check -> "ALERT".equals(check.get("status")))) { + return "ALERT"; + } + if (checks.stream().anyMatch(check -> "WARN".equals(check.get("status")))) { + return "WARN"; + } + return "HEALTHY"; + } + + private void addCheck(List> checks, + String id, + String status, + String message, + Map details) { + Map check = new LinkedHashMap<>(); + check.put("id", id); + check.put("status", status); + check.put("message", message); + check.put("details", details); + checks.add(check); + } + + private List> rules() { + List> rules = new ArrayList<>(); + rules.add(rule("agent.tool.metrics.missing", "WARN", "invocationCount == 0", 1D, + "没有采集到工具调用指标时触发,表示观测链路缺少样本。")); + rules.add(rule("agent.tool.errors", "ALERT", "errorCount >= errorThreshold", errorThreshold, + "错误调用次数达到阈值时触发,表示目标工具或外部依赖可能异常。")); + rules.add(rule("agent.tool.blocked", "WARN", "blockedCount >= blockedThreshold", blockedThreshold, + "预览或策略阻断达到阈值时触发,表示需要结合审计确认是否为预期拦截。")); + rules.add(rule("agent.tool.slow", "WARN", "maxDurationMs >= slowThresholdMs", slowThresholdMs, + "任一指标序列最大耗时达到慢调用阈值时触发。")); + return rules; + } + + private Map rule(String id, String severity, String condition, double threshold, String description) { + Map rule = new LinkedHashMap<>(); + rule.put("id", id); + rule.put("severity", severity); + rule.put("condition", condition); + rule.put("threshold", threshold); + rule.put("description", description); + return rule; + } + + private List> alerts() { + List> alerts = new ArrayList<>(); + if (invocationCount <= 0D) { + addAlert(alerts, + "agent.tool.metrics.missing", + "WARN", + "暂未采集到智能体工具调用指标,dashboard 或告警系统无法判断真实运行质量。", + Map.of("invocationCount", invocationCount, "expectedMinimum", 1D)); + } + if (errorCount >= errorThreshold) { + addAlert(alerts, + "agent.tool.errors", + "ALERT", + "智能体工具错误调用次数达到告警阈值。", + Map.of("errorCount", errorCount, "threshold", errorThreshold)); + } + if (blockedCount >= blockedThreshold) { + addAlert(alerts, + "agent.tool.blocked", + "WARN", + "智能体工具 blocked 调用次数达到告警阈值。", + Map.of("blockedCount", blockedCount, "threshold", blockedThreshold)); + } + if (!slowSeries.isEmpty()) { + addAlert(alerts, + "agent.tool.slow", + "WARN", + "存在超过慢调用阈值的智能体工具指标序列。", + Map.of("slowSeriesCount", slowSeries.size(), "thresholdMs", slowThresholdMs)); + } + return alerts; + } + + private void addAlert(List> alerts, + String id, + String severity, + String message, + Map details) { + Map alert = new LinkedHashMap<>(); + alert.put("id", id); + alert.put("severity", severity); + alert.put("status", "FIRING"); + alert.put("message", message); + alert.put("details", details); + alerts.add(alert); + } + + private Map alertCountBySeverity(List> alerts) { + Map counts = new LinkedHashMap<>(); + counts.put("ALERT", alerts.stream().filter(alert -> "ALERT".equals(alert.get("severity"))).count()); + counts.put("WARN", alerts.stream().filter(alert -> "WARN".equals(alert.get("severity"))).count()); + return counts; + } + + private String alertsStatus(List> alerts) { + if (alerts.stream().anyMatch(alert -> "ALERT".equals(alert.get("severity")))) { + return "ALERT"; + } + if (alerts.stream().anyMatch(alert -> "WARN".equals(alert.get("severity")))) { + return "WARN"; + } + return "OK"; + } + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsSummaryAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsSummaryAgentTool.java new file mode 100644 index 000000000..b9c2e1c0c --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolMetricsSummaryAgentTool.java @@ -0,0 +1,115 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 查询管理员智能体后端工具调用指标的通用只读工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentToolMetricsSummaryAgentTool implements AgentTool { + + private final MeterRegistry meterRegistry; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.metrics.summary"); + definition.setTitle("查询智能体工具调用指标"); + definition.setDescription("汇总管理员智能体后端工具调用次数、耗时和结果分布,用于统一运行时排障。"); + definition.setIntent("system.agent.metrics.summary"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of()); + definition.setRequiredInputKeys(List.of()); + definition.setRequiredPermissions(List.of("agent:runtime:metrics:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (asksAlertEvaluation(normalized)) { + return Optional.empty(); + } + boolean mentionsAgentOrTool = containsAny(normalized, List.of("智能体", "agent", "工具调用", "工具")); + boolean asksMetrics = containsAny(normalized, List.of("工具调用指标", "调用指标", "调用情况", "耗时", "metrics", "指标")); + if (!mentionsAgentOrTool || !asksMetrics) { + return Optional.empty(); + } + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("查询管理员智能体工具调用指标"); + call.setInput(new LinkedHashMap<>()); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("查询工具调用指标是只读动作,不需要写入预览。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + AgentToolMetricsSnapshotSupport.MetricsSnapshot snapshot = new AgentToolMetricsSnapshotSupport(meterRegistry).snapshot(); + double invocationCount = snapshot.invocationCount(); + Map data = snapshot.summaryData(); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary(invocationCount <= 0D + ? "还没有记录智能体工具调用指标。" + : "已汇总智能体工具调用指标,共 " + formatCount(invocationCount) + " 次调用。"); + result.getArtifacts().add(new AgentChatArtifact( + "agentToolMetricsSummary", + "智能体工具调用指标", + data + )); + result.getNextActions().add("如果某个工具错误率升高,可以继续查询智能体审计记录定位失败请求。"); + return result; + } + + private String formatCount(double value) { + if (value == Math.rint(value)) { + return String.valueOf((long) value); + } + return String.valueOf(value); + } + + private boolean containsAny(String value, List keywords) { + String normalized = value.toLowerCase(java.util.Locale.ROOT); + return keywords.stream() + .map(keyword -> keyword.toLowerCase(java.util.Locale.ROOT)) + .anyMatch(normalized::contains); + } + + private boolean asksAlertEvaluation(String value) { + boolean asksAlert = containsAny(value, List.of("告警", "alert", "alerts")); + boolean asksEvaluation = containsAny(value, List.of("规则", "评估", "事件", "信号", "列表", "有哪些", "rule", "rules", "evaluate")); + return asksAlert && asksEvaluation; + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolSearchAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolSearchAgentTool.java new file mode 100644 index 000000000..4cde590e0 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/AgentToolSearchAgentTool.java @@ -0,0 +1,193 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * 搜索后端智能体工具目录的通用只读工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class AgentToolSearchAgentTool implements AgentTool { + + private static final int DEFAULT_LIMIT = 10; + private static final int MAX_LIMIT = 20; + + private final AgentToolCatalogService catalogService; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.agent.tools.search"); + definition.setTitle("搜索智能体工具目录"); + definition.setDescription("按关键词搜索后端统一智能体已注册工具,用于在工具数量增加后快速定位能力。"); + definition.setIntent("system.agent.tools.search"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of( + "query", Map.of("type", "string", "minLength", 1, "maxLength", 120), + "limit", Map.of("type", "integer", "minimum", 1, "maximum", MAX_LIMIT) + )); + definition.setRequiredInputKeys(List.of("query")); + definition.setRequiredPermissions(List.of("agent:runtime:tool-catalog:read")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (!asksToolSearch(normalized)) { + return Optional.empty(); + } + + String query = extractQuery(normalized); + if (!StringUtils.hasText(query)) { + return Optional.empty(); + } + + Map input = new LinkedHashMap<>(); + input.put("query", query); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("搜索后端智能体工具目录"); + call.setInput(input); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("搜索智能体工具目录是只读动作,不需要写入预览。"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + String query = normalize(call.getInput().get("query")); + int limit = limit(call.getInput().get("limit")); + List> matched = catalogService.definitions().stream() + .filter(definition -> matches(definition, query)) + .map(this::descriptor) + .toList(); + List> results = matched.stream() + .limit(limit) + .toList(); + + Map data = new LinkedHashMap<>(); + data.put("query", query); + data.put("limit", limit); + data.put("totalMatchedCount", matched.size()); + data.put("resultCount", results.size()); + data.put("results", results); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary(results.isEmpty() + ? "没有找到匹配关键词 " + query + " 的后端智能体工具。" + : "已找到 " + matched.size() + " 个匹配关键词 " + query + " 的后端智能体工具。"); + result.getArtifacts().add(new AgentChatArtifact( + "agentToolSearch", + "智能体工具搜索结果", + data + )); + result.getNextActions().add("可以继续查询某个工具详情,或检查当前操作者是否具备该工具访问条件。"); + return result; + } + + private Map descriptor(AgentToolDefinition definition) { + Map item = new LinkedHashMap<>(); + item.put("name", definition.getName()); + item.put("title", definition.getTitle()); + item.put("description", definition.getDescription()); + item.put("riskLevel", definition.getRiskLevel()); + item.put("riskCategory", definition.getRiskCategory()); + item.put("requiredInputKeys", definition.getRequiredInputKeys()); + item.put("requiredPermissions", definition.getRequiredPermissions()); + item.put("requiredRoles", definition.getRequiredRoles()); + item.put("tenantScope", definition.getTenantScope()); + return item; + } + + private boolean matches(AgentToolDefinition definition, String query) { + String normalizedQuery = normalize(query).toLowerCase(Locale.ROOT); + if (!StringUtils.hasText(normalizedQuery)) { + return false; + } + String haystack = String.join(" ", + normalize(definition.getName()), + normalize(definition.getTitle()), + normalize(definition.getDescription()), + normalize(definition.getIntent()), + normalize(definition.getRoute()), + normalize(definition.getRiskLevel()), + normalize(definition.getRiskCategory()), + String.join(" ", safeList(definition.getRequiredInputKeys())), + String.join(" ", safeList(definition.getRequiredPermissions())), + String.join(" ", safeList(definition.getRequiredRoles())) + ).toLowerCase(Locale.ROOT); + if (haystack.contains(normalizedQuery)) { + return true; + } + List tokens = List.of(normalizedQuery.split("\\s+")).stream() + .filter(StringUtils::hasText) + .toList(); + return !tokens.isEmpty() && tokens.stream().allMatch(haystack::contains); + } + + private boolean asksToolSearch(String message) { + String normalized = message.toLowerCase(Locale.ROOT); + return containsAny(normalized, List.of("搜索", "查找", "找一下", "找工具", "工具搜索")) + && normalized.contains("工具"); + } + + private String extractQuery(String message) { + String query = normalize(message); + for (String keyword : List.of("帮我", "请", "搜索", "查找", "找一下", "找工具", "工具搜索", "相关", "工具", "智能体", "后端", "一下", "有哪些", "有啥")) { + query = query.replace(keyword, ""); + } + return query.replaceAll("[::,,。.!!??]", "").trim(); + } + + private int limit(Object value) { + if (value instanceof Number number) { + return Math.max(1, Math.min(MAX_LIMIT, number.intValue())); + } + return DEFAULT_LIMIT; + } + + private List safeList(List values) { + return values == null ? List.of() : values.stream() + .map(this::normalize) + .filter(StringUtils::hasText) + .toList(); + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream() + .map(keyword -> keyword.toLowerCase(Locale.ROOT)) + .anyMatch(value::contains); + } + + private String normalize(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/ChatUserBanStatusAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/ChatUserBanStatusAgentTool.java new file mode 100644 index 000000000..79aeb376c --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/ChatUserBanStatusAgentTool.java @@ -0,0 +1,108 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.chat.domain.ChatUserBan; +import com.xiaou.chat.service.ChatUserBanService; +import com.xiaou.system.agent.AbstractReadonlyAgentTool; +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinitionBuilder; +import com.xiaou.system.agent.AgentToolResult; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 查询聊天室用户禁言状态工具。 + * + * @author xiaou + */ +@Component +public class ChatUserBanStatusAgentTool extends AbstractReadonlyAgentTool { + + private static final Pattern USER_ID_PATTERN = Pattern.compile("(?:用户|uid|userId)?\\s*(\\d+)"); + + private final ChatUserBanService chatUserBanService; + + public ChatUserBanStatusAgentTool(ChatUserBanService chatUserBanService) { + super(AgentToolDefinitionBuilder.readonly("chat.userBan.active", "查询聊天室用户禁言状态") + .description("查询指定用户在官方聊天室当前是否存在生效禁言。") + .route("/admin/chat/users/ban/active") + .input("userId", Map.of("type", "integer", "minimum", 1, "description", "用户ID"), true) + .permission("agent:chat:user-ban:read") + .build()); + this.chatUserBanService = chatUserBanService; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (!normalized.contains("禁言") || containsAny(normalized, List.of("解除", "解禁", "取消禁言"))) { + return Optional.empty(); + } + if (!containsAny(normalized, List.of("查", "看", "状态", "是否", "当前", "有没有", "被禁言"))) { + return Optional.empty(); + } + + Long userId = extractUserId(normalized); + if (userId == null) { + return Optional.empty(); + } + + return Optional.of(call("查询用户 " + userId + " 的聊天室禁言状态", Map.of("userId", userId))); + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + Long userId = longValue(call.getInput().get("userId")); + ChatUserBan ban = chatUserBanService.getActiveBan(userId); + + if (ban == null) { + return success( + "用户 " + userId + " 当前未被禁言。", + artifact("chatUserBanStatus", "聊天室用户禁言状态", Map.of("userId", userId, "active", false)), + List.of("如果需要禁言该用户,请说明禁言时长和原因。") + ); + } + + return success( + "用户 " + userId + " 当前处于禁言中。", + artifact("chatUserBanStatus", "聊天室用户禁言状态", banData(ban, true)), + List.of("如果确认需要解除禁言,可以继续说:解除用户" + userId + "禁言。") + ); + } + + private Map banData(ChatUserBan ban, boolean active) { + Map data = new LinkedHashMap<>(); + data.put("active", active); + data.put("id", ban.getId()); + data.put("userId", ban.getUserId()); + data.put("roomId", ban.getRoomId()); + data.put("banReason", ban.getBanReason()); + data.put("banStartTime", ban.getBanStartTime()); + data.put("banEndTime", ban.getBanEndTime()); + data.put("operatorId", ban.getOperatorId()); + data.put("status", ban.getStatus()); + return data; + } + + private Long extractUserId(String message) { + Matcher matcher = USER_ID_PATTERN.matcher(message); + if (!matcher.find()) { + return null; + } + return Long.parseLong(matcher.group(1)); + } + + private Long longValue(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + return Long.parseLong(String.valueOf(value)); + } + +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/ChatUserUnbanAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/ChatUserUnbanAgentTool.java new file mode 100644 index 000000000..4f9ad0cda --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/ChatUserUnbanAgentTool.java @@ -0,0 +1,173 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.chat.domain.ChatUserBan; +import com.xiaou.chat.service.ChatUserBanService; +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.dto.AgentChatDiffItem; +import com.xiaou.system.dto.AgentChatPlanStep; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 解除聊天室用户禁言工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class ChatUserUnbanAgentTool implements AgentTool { + + private static final Pattern USER_ID_PATTERN = Pattern.compile("(?:用户|uid|userId)?\\s*(\\d+)"); + + private final ChatUserBanService chatUserBanService; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("chat.userBan.unban"); + definition.setTitle("解除聊天室用户禁言"); + definition.setDescription("解除指定用户在官方聊天室当前生效的禁言。"); + definition.setIntent("chat.userBan.unban"); + definition.setRoute("/admin/chat/users/unban"); + definition.setRiskLevel("medium"); + definition.setRiskCategory("WRITE"); + definition.setConfirmationRequired(true); + definition.setConfirmationText("确认解除禁言"); + definition.setInputSchema(Map.of( + "userId", Map.of("type", "integer", "minimum", 1, "description", "用户ID") + )); + definition.setRequiredInputKeys(List.of("userId")); + definition.setRequiredPermissions(List.of("agent:chat:user-ban:write")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (!normalized.contains("禁言") || !containsAny(normalized, List.of("解除", "解禁", "取消禁言"))) { + return Optional.empty(); + } + + Long userId = extractUserId(normalized); + if (userId == null) { + return Optional.empty(); + } + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("解除用户 " + userId + " 的聊天室禁言"); + call.setInput(new LinkedHashMap<>(Map.of("userId", userId))); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + Long userId = longValue(call.getInput().get("userId")); + ChatUserBan ban = chatUserBanService.getActiveBan(userId); + + AgentToolPreview preview = new AgentToolPreview(); + if (ban == null) { + String message = "用户 " + userId + " 当前未被禁言,无需解除。"; + preview.setExecutable(false); + preview.setSummary(message); + preview.setBlockedReason(message); + preview.getPlan().add(new AgentChatPlanStep("查询当前状态", "done", "未发现生效禁言记录。")); + preview.getArtifacts().add(new AgentChatArtifact( + "chatUserBanUnbanPreview", + "聊天室解除禁言预览", + Map.of("userId", userId, "active", false) + )); + return preview; + } + + preview.setSummary("将解除用户 " + userId + " 的聊天室禁言。确认前不会修改禁言状态。"); + preview.getPlan().add(new AgentChatPlanStep("查询当前状态", "done", "已找到生效禁言记录:" + ban.getId())); + preview.getPlan().add(new AgentChatPlanStep("生成预览", "done", "解除用户 " + userId + " 的当前禁言。")); + preview.getPlan().add(new AgentChatPlanStep("等待确认", "blocked", "需要输入强确认文本后才会执行。")); + preview.getDiff().add(new AgentChatDiffItem( + "chatUserBan.status", + "生效中", + "已解除", + "用户 " + userId + " 的聊天室禁言状态将被解除" + )); + preview.getArtifacts().add(new AgentChatArtifact( + "chatUserBanUnbanPreview", + "聊天室解除禁言预览", + banData(ban, true) + )); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + Long userId = longValue(call.getInput().get("userId")); + chatUserBanService.unbanUser(userId); + + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("已解除用户 " + userId + " 的聊天室禁言。"); + result.getDiff().add(new AgentChatDiffItem( + "chatUserBan.status", + "生效中", + "已解除", + "用户 " + userId + " 的聊天室禁言状态已更新" + )); + result.getArtifacts().add(new AgentChatArtifact( + "chatUserBanUnbanResult", + "聊天室解除禁言结果", + Map.of("userId", userId, "success", true) + )); + result.getNextActions().add("可以继续查询用户" + userId + "禁言状态,确认当前已解除。"); + return result; + } + + private Map banData(ChatUserBan ban, boolean active) { + Map data = new LinkedHashMap<>(); + data.put("active", active); + data.put("id", ban.getId()); + data.put("userId", ban.getUserId()); + data.put("roomId", ban.getRoomId()); + data.put("banReason", ban.getBanReason()); + data.put("banStartTime", ban.getBanStartTime()); + data.put("banEndTime", ban.getBanEndTime()); + data.put("operatorId", ban.getOperatorId()); + data.put("status", ban.getStatus()); + return data; + } + + private Long extractUserId(String message) { + Matcher matcher = USER_ID_PATTERN.matcher(message); + if (!matcher.find()) { + return null; + } + return Long.parseLong(matcher.group(1)); + } + + private Long longValue(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + return Long.parseLong(String.valueOf(value)); + } + + private boolean containsAny(String value, List keywords) { + return keywords.stream().anyMatch(value::contains); + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/LotteryRealtimeMonitorAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/LotteryRealtimeMonitorAgentTool.java new file mode 100644 index 000000000..663cc916e --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/LotteryRealtimeMonitorAgentTool.java @@ -0,0 +1,152 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.points.dto.lottery.admin.RealtimeMonitorResponse; +import com.xiaou.points.service.LotteryAdminService; +import com.xiaou.system.agent.AbstractReadonlyAgentTool; +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinitionBuilder; +import com.xiaou.system.agent.AgentToolResult; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * 查询抽奖实时监控工具。 + * + * @author xiaou + */ +@Component +public class LotteryRealtimeMonitorAgentTool extends AbstractReadonlyAgentTool { + + private final LotteryAdminService lotteryAdminService; + + public LotteryRealtimeMonitorAgentTool(LotteryAdminService lotteryAdminService) { + super(AgentToolDefinitionBuilder.readonly("points.lottery.monitor.realtime", "查询抽奖实时监控") + .description("通过抽奖管理服务读取实时监控、今日概览、奖品状态和策略信息。") + .route("/admin/lottery/monitor/realtime") + .permission("agent:points:lottery:monitor:read") + .build()); + this.lotteryAdminService = lotteryAdminService; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (!containsAny(normalized, List.of("抽奖", "奖品", "积分抽奖"))) { + return Optional.empty(); + } + if (!containsAny(normalized, List.of("实时", "监控", "概览", "状态", "看", "查"))) { + return Optional.empty(); + } + + return Optional.of(call("查询抽奖实时监控", new LinkedHashMap<>())); + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + RealtimeMonitorResponse monitor = lotteryAdminService.getRealtimeMonitor(); + Map data = toArtifactData(monitor); + + return success( + summary(data, monitor == null), + artifact("pointsLotteryRealtimeMonitor", "抽奖实时监控", data), + List.of("如需继续排查,可以查询抽奖预警、单个奖品监控或抽奖记录。") + ); + } + + private Map toArtifactData(RealtimeMonitorResponse monitor) { + Map data = new LinkedHashMap<>(); + if (monitor == null) { + data.put("systemStatus", "UNKNOWN"); + data.put("totalDrawCount", 0); + data.put("totalCostPoints", 0L); + data.put("totalRewardPoints", 0L); + data.put("actualReturnRate", BigDecimal.ZERO); + data.put("profitPoints", 0L); + data.put("profitRate", BigDecimal.ZERO); + data.put("uniqueUserCount", 0); + data.put("prizeCount", 0); + data.put("alertPrizeCount", 0); + data.put("monitor", Map.of()); + return data; + } + + RealtimeMonitorResponse.TodayOverview overview = monitor.getTodayOverview(); + List prizes = safePrizes(monitor); + data.put("systemStatus", systemStatus(monitor)); + data.put("totalDrawCount", overview == null || overview.getTotalDrawCount() == null ? 0 : overview.getTotalDrawCount()); + data.put("totalCostPoints", overview == null || overview.getTotalCostPoints() == null ? 0L : overview.getTotalCostPoints()); + data.put("totalRewardPoints", overview == null || overview.getTotalRewardPoints() == null ? 0L : overview.getTotalRewardPoints()); + data.put("actualReturnRate", overview == null || overview.getActualReturnRate() == null ? BigDecimal.ZERO : overview.getActualReturnRate()); + data.put("profitPoints", overview == null || overview.getProfitPoints() == null ? 0L : overview.getProfitPoints()); + data.put("profitRate", overview == null || overview.getProfitRate() == null ? BigDecimal.ZERO : overview.getProfitRate()); + data.put("uniqueUserCount", overview == null || overview.getUniqueUserCount() == null ? 0 : overview.getUniqueUserCount()); + data.put("prizeCount", prizes.size()); + data.put("alertPrizeCount", countAlertPrizes(prizes)); + data.put("prizes", prizes.stream().filter(Objects::nonNull).map(this::toPrizeSummary).toList()); + data.put("strategyInfo", monitor.getStrategyInfo() == null ? Map.of() : monitor.getStrategyInfo()); + data.put("monitor", monitor); + return data; + } + + private String summary(Map data, boolean empty) { + if (empty) { + return "暂无抽奖实时监控数据。"; + } + return "抽奖实时监控已读取:今日抽奖 " + data.get("totalDrawCount") + + " 次,参与用户 " + data.get("uniqueUserCount") + + " 人,奖品 " + data.get("prizeCount") + + " 个,预警奖品 " + data.get("alertPrizeCount") + " 个。"; + } + + private List safePrizes(RealtimeMonitorResponse monitor) { + if (monitor.getPrizeStatusList() == null) { + return List.of(); + } + return monitor.getPrizeStatusList(); + } + + private String systemStatus(RealtimeMonitorResponse monitor) { + RealtimeMonitorResponse.SystemStatus status = monitor.getSystemStatus(); + if (status == null || !hasText(status.getStatus())) { + return "UNKNOWN"; + } + return status.getStatus(); + } + + private long countAlertPrizes(List prizes) { + return prizes.stream().filter(this::hasAlert).count(); + } + + private boolean hasAlert(RealtimeMonitorResponse.PrizeStatus prize) { + return prize != null + && (hasText(prize.getAlertMessage()) + || (hasText(prize.getAlertLevel()) && !"无".equals(prize.getAlertLevel()))); + } + + private Map toPrizeSummary(RealtimeMonitorResponse.PrizeStatus prize) { + Map item = new LinkedHashMap<>(); + item.put("prizeId", prize.getPrizeId()); + item.put("prizeName", prize.getPrizeName()); + item.put("prizeLevel", prize.getPrizeLevel()); + item.put("status", prize.getStatus()); + item.put("todayDrawCount", prize.getTodayDrawCount()); + item.put("todayWinCount", prize.getTodayWinCount()); + item.put("currentStock", prize.getCurrentStock()); + item.put("totalStock", prize.getTotalStock()); + item.put("actualReturnRate", prize.getActualReturnRate()); + item.put("alertLevel", prize.getAlertLevel()); + item.put("alertMessage", prize.getAlertMessage()); + return item; + } + + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/OperationLogCleanAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/OperationLogCleanAgentTool.java new file mode 100644 index 000000000..09c04ad8c --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/OperationLogCleanAgentTool.java @@ -0,0 +1,145 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.dto.AgentChatDiffItem; +import com.xiaou.system.dto.AgentChatPlanStep; +import com.xiaou.system.service.SysOperationLogService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 清理过期操作日志工具。 + * + * @author xiaou + */ +@Component +@RequiredArgsConstructor +public class OperationLogCleanAgentTool implements AgentTool { + + private static final Pattern DAYS_PATTERN = Pattern.compile("(\\d+)\\s*天"); + + private final SysOperationLogService operationLogService; + + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.operationLog.cleanExpired"); + definition.setTitle("清理过期操作日志"); + definition.setDescription("清理指定保留天数之前的操作日志。"); + definition.setIntent("system.operationLog.cleanExpired"); + definition.setRoute("/logs/operation"); + definition.setRiskLevel("high"); + definition.setRiskCategory("DESTRUCTIVE_WRITE"); + definition.setDestructive(true); + definition.setConfirmationRequired(true); + definition.setConfirmationText("确认清理操作日志"); + definition.setInputSchema(Map.of( + "days", Map.of("type", "integer", "minimum", 1, "description", "保留天数") + )); + definition.setRequiredInputKeys(List.of("days")); + definition.setRequiredPermissions(List.of("agent:system:operation-log:clean")); + return definition; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (!normalized.contains("操作日志")) { + return Optional.empty(); + } + if (!(normalized.contains("清理") || normalized.contains("删除") || normalized.contains("清除"))) { + return Optional.empty(); + } + + int days = extractDays(normalized); + if (days <= 0) { + return Optional.empty(); + } + + AgentToolCall call = new AgentToolCall(); + call.setToolName(definition().getName()); + call.setSummary("清理 " + days + " 天前的操作日志"); + call.setInput(new LinkedHashMap<>(Map.of("days", days))); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + int days = intValue(call.getInput().get("days"), 0); + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("将清理 " + days + " 天前的操作日志。确认前不会执行删除。"); + preview.getPlan().add(new AgentChatPlanStep("识别动作", "done", "命中工具:" + definition().getName())); + preview.getPlan().add(new AgentChatPlanStep("生成预览", "done", "清理范围:操作日志,保留最近 " + days + " 天。")); + preview.getPlan().add(new AgentChatPlanStep("等待确认", "blocked", "需要输入强确认文本后才会执行。")); + preview.getDiff().add(new AgentChatDiffItem( + "operationLogsBeforeDays", + "保留", + "删除", + "所有 " + days + " 天前的操作日志将被清理" + )); + preview.getArtifacts().add(new AgentChatArtifact( + "operationLogCleanupPreview", + "操作日志清理预览", + Map.of("days", days, "destructive", true) + )); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + int days = intValue(call.getInput().get("days"), 0); + AgentToolResult result = new AgentToolResult(); + if (days <= 0) { + result.setSuccess(false); + result.setSummary("清理失败:保留天数必须大于 0。"); + result.setErrorMessage("days must be greater than 0"); + return result; + } + + boolean success = operationLogService.cleanOperationLogByDays(days); + result.setSuccess(success); + result.setSummary(success ? "已清理 " + days + " 天前的操作日志。" : "操作日志清理失败。"); + if (!success) { + result.setErrorMessage("operation log cleanup returned false"); + } + result.getArtifacts().add(new AgentChatArtifact( + "operationLogCleanupResult", + "操作日志清理结果", + Map.of("days", days, "success", success) + )); + result.getNextActions().add("你可以继续查询最近操作日志确认后台状态。"); + return result; + } + + private int extractDays(String message) { + Matcher matcher = DAYS_PATTERN.matcher(message); + if (!matcher.find()) { + return 0; + } + return Integer.parseInt(matcher.group(1)); + } + + private int intValue(Object value, int fallback) { + if (value instanceof Number number) { + return number.intValue(); + } + return fallback; + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/agent/tools/OperationLogListAgentTool.java b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/OperationLogListAgentTool.java new file mode 100644 index 000000000..9c0adeb23 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/agent/tools/OperationLogListAgentTool.java @@ -0,0 +1,113 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.common.core.domain.PageResult; +import com.xiaou.system.agent.AbstractReadonlyAgentTool; +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinitionBuilder; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.OperationLogQueryRequest; +import com.xiaou.system.dto.OperationLogResponse; +import com.xiaou.system.service.SysOperationLogService; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 查询操作日志工具。 + * + * @author xiaou + */ +@Component +public class OperationLogListAgentTool extends AbstractReadonlyAgentTool { + + private static final Pattern NUMBER_PATTERN = Pattern.compile("(\\d+)"); + + private final SysOperationLogService operationLogService; + + public OperationLogListAgentTool(SysOperationLogService operationLogService) { + super(AgentToolDefinitionBuilder.readonly("system.operationLog.list", "查询操作日志") + .description("分页查询最近操作日志,返回结构化摘要。") + .route("/logs/operation") + .input("pageNum", Map.of("type", "integer", "default", 1), true) + .input("pageSize", Map.of("type", "integer", "minimum", 1, "maximum", 20), true) + .permission("agent:system:operation-log:read") + .build()); + this.operationLogService = operationLogService; + } + + @Override + public Optional resolve(String message) { + String normalized = normalize(message); + if (!normalized.contains("操作日志")) { + return Optional.empty(); + } + if (!(normalized.contains("查") || normalized.contains("看") || normalized.contains("最近") || normalized.contains("列表"))) { + return Optional.empty(); + } + + int limit = clamp(firstNumber(normalized, 5), 1, 20); + return Optional.of(call("查询最近 " + limit + " 条操作日志", new LinkedHashMap<>(Map.of( + "pageNum", 1, + "pageSize", limit + )))); + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + int pageSize = intValue(call.getInput().get("pageSize"), 5); + OperationLogQueryRequest query = new OperationLogQueryRequest(); + query.setPageNum(1); + query.setPageSize(clamp(pageSize, 1, 20)); + PageResult page = operationLogService.getOperationLogPage(query); + List> records = page.getRecords() == null ? List.of() : page.getRecords().stream() + .map(this::toSummary) + .toList(); + + return success(records.isEmpty() + ? "没有查询到操作日志。" + : "已查询到最近 " + records.size() + " 条操作日志。", + artifact("operationLogList", "最近操作日志", Map.of( + "total", page.getTotal() == null ? records.size() : page.getTotal(), + "records", records + )), + List.of("如果需要清理旧日志,请先说明保留天数,例如:清理30天前操作日志。")); + } + + private Map toSummary(OperationLogResponse log) { + Map item = new LinkedHashMap<>(); + item.put("id", log.getId()); + item.put("module", log.getModule()); + item.put("operationType", log.getOperationType()); + item.put("description", log.getDescription()); + item.put("operatorName", log.getOperatorName()); + item.put("statusText", log.getStatusText()); + item.put("operationTime", log.getOperationTime()); + return item; + } + + private int firstNumber(String message, int fallback) { + Matcher matcher = NUMBER_PATTERN.matcher(message); + if (!matcher.find()) { + return fallback; + } + return Integer.parseInt(matcher.group(1)); + } + + private int intValue(Object value, int fallback) { + if (value instanceof Number number) { + return number.intValue(); + } + return fallback; + } + + private int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } + +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/controller/AgentChatController.java b/xiaou-system/src/main/java/com/xiaou/system/controller/AgentChatController.java new file mode 100644 index 000000000..1ba29caab --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/controller/AgentChatController.java @@ -0,0 +1,92 @@ +package com.xiaou.system.controller; + +import com.xiaou.common.annotation.Log; +import com.xiaou.common.annotation.RequireAdmin; +import com.xiaou.common.core.domain.Result; +import com.xiaou.common.satoken.StpAdminUtil; +import com.xiaou.system.agent.AgentChatOrchestrator; +import com.xiaou.system.agent.AgentOperator; +import com.xiaou.system.domain.SysAdmin; +import com.xiaou.system.dto.AgentChatRequest; +import com.xiaou.system.dto.AgentChatResponse; +import com.xiaou.system.service.SysAdminService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 管理员智能体统一聊天入口。 + * + * @author xiaou + */ +@Slf4j +@Validated +@RestController +@RequestMapping("/admin/agent") +@RequiredArgsConstructor +@Tag(name = "管理员智能体聊天", description = "通过单一聊天接口完成问答、预览、确认、执行和审计") +public class AgentChatController { + + private final AgentChatOrchestrator agentChatOrchestrator; + private final SysAdminService adminService; + + @Operation(summary = "管理员智能体统一聊天") + @SecurityRequirement(name = "Bearer Token") + @RequireAdmin(message = "使用管理员智能体需要管理员权限") + @PostMapping("/chat") + @Log(module = "系统管理", type = Log.OperationType.OTHER, description = "管理员智能体聊天", + saveRequestData = false, saveResponseData = false) + public Result chat(@Valid @RequestBody AgentChatRequest request) { + Long adminId = StpAdminUtil.getLoginIdAsLong(); + AgentOperator operator = new AgentOperator( + adminId, + resolveOperatorName(adminId), + "", + resolveOperatorRoles(adminId), + resolveOperatorPermissions(adminId) + ); + return Result.success("智能体响应完成", agentChatOrchestrator.chat(request, operator)); + } + + private String resolveOperatorName(Long adminId) { + try { + SysAdmin admin = adminService.getById(adminId); + if (admin != null && admin.getUsername() != null) { + return admin.getUsername(); + } + } catch (Exception e) { + log.warn("获取智能体操作人失败,adminId={}", adminId); + } + return String.valueOf(adminId); + } + + private List resolveOperatorRoles(Long adminId) { + try { + List roles = adminService.getAdminRoles(adminId); + return roles == null ? List.of() : roles; + } catch (Exception e) { + log.warn("获取智能体操作人角色失败,adminId={}", adminId); + return List.of(); + } + } + + private List resolveOperatorPermissions(Long adminId) { + try { + List permissions = adminService.getAdminPermissions(adminId); + return permissions == null ? List.of() : permissions; + } catch (Exception e) { + log.warn("获取智能体操作人权限失败,adminId={}", adminId); + return List.of(); + } + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/domain/SysAgentAudit.java b/xiaou-system/src/main/java/com/xiaou/system/domain/SysAgentAudit.java new file mode 100644 index 000000000..6e9a7df5d --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/domain/SysAgentAudit.java @@ -0,0 +1,75 @@ +package com.xiaou.system.domain; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 管理员智能体审计记录 + * + * @author xiaou + */ +@Data +@EqualsAndHashCode(callSuper = false) +public class SysAgentAudit { + + private Long id; + + private String auditId; + + private String confirmationId; + + private String idempotencyKey; + + private String userMessage; + + private String intent; + + private String actionId; + + private String route; + + private String riskLevel; + + private String riskCategory; + + /** + * PREVIEW / CONFIRMED / CANCELLED / EXECUTED / FAILED + */ + private String status; + + /** + * 乐观状态迁移条件,不映射到数据库字段。 + */ + private String expectedStatus; + + private String summary; + + private String payloadJson; + + private String diffJson; + + private String planJson; + + private String resultJson; + + private String errorMessage; + + private Long operatorId; + + private String operatorName; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime confirmedTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime executedTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime createdTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime updatedTime; +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/domain/SysAgentSessionContext.java b/xiaou-system/src/main/java/com/xiaou/system/domain/SysAgentSessionContext.java new file mode 100644 index 000000000..fd24a220c --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/domain/SysAgentSessionContext.java @@ -0,0 +1,26 @@ +package com.xiaou.system.domain; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 管理员智能体会话上下文持久化记录。 + * + * @author xiaou + */ +@Data +@EqualsAndHashCode(callSuper = false) +public class SysAgentSessionContext { + + private Long id; + + private String sessionId; + + private String turnsJson; + + private LocalDateTime createdTime; + + private LocalDateTime updatedTime; +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditPreviewRequest.java b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditPreviewRequest.java new file mode 100644 index 000000000..e109c56a1 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditPreviewRequest.java @@ -0,0 +1,52 @@ +package com.xiaou.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 智能体预览审计请求 + * + * @author xiaou + */ +@Data +@Schema(description = "智能体预览审计请求") +public class AgentAuditPreviewRequest { + + @NotBlank(message = "确认ID不能为空") + @Size(max = 120, message = "确认ID不能超过120个字符") + private String confirmationId; + + @Size(max = 160, message = "幂等键不能超过160个字符") + private String idempotencyKey; + + @Size(max = 1000, message = "用户指令不能超过1000个字符") + private String userMessage; + + @NotBlank(message = "意图不能为空") + @Size(max = 120, message = "意图不能超过120个字符") + private String intent; + + @NotBlank(message = "动作ID不能为空") + @Size(max = 160, message = "动作ID不能超过160个字符") + private String actionId; + + @Size(max = 200, message = "路由不能超过200个字符") + private String route; + + @Size(max = 40, message = "风险等级不能超过40个字符") + private String riskLevel; + + @Size(max = 80, message = "风险分类不能超过80个字符") + private String riskCategory; + + @Size(max = 500, message = "摘要不能超过500个字符") + private String summary; + + private String payloadJson; + + private String diffJson; + + private String planJson; +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditQueryRequest.java b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditQueryRequest.java new file mode 100644 index 000000000..95176dcb5 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditQueryRequest.java @@ -0,0 +1,50 @@ +package com.xiaou.system.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.xiaou.common.core.domain.PageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 智能体审计查询请求 + * + * @author xiaou + */ +@Data +@Schema(description = "智能体审计查询请求") +public class AgentAuditQueryRequest implements PageRequest { + + private String intent; + + private String actionId; + + private String riskCategory; + + private String status; + + private String operatorName; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime startTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime endTime; + + private Integer pageNum = 1; + + private Integer pageSize = 10; + + @Override + public AgentAuditQueryRequest setPageNum(Integer pageNum) { + this.pageNum = pageNum; + return this; + } + + @Override + public AgentAuditQueryRequest setPageSize(Integer pageSize) { + this.pageSize = pageSize; + return this; + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditResponse.java b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditResponse.java new file mode 100644 index 000000000..403b1bb8e --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditResponse.java @@ -0,0 +1,81 @@ +package com.xiaou.system.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 智能体审计响应 + * + * @author xiaou + */ +@Data +@Schema(description = "智能体审计响应") +public class AgentAuditResponse { + + private Long id; + + private String auditId; + + private String confirmationId; + + private String idempotencyKey; + + private String userMessage; + + private String intent; + + private String actionId; + + private String route; + + private String riskLevel; + + private String riskCategory; + + private String status; + + private String statusText; + + private String summary; + + private String payloadJson; + + private String diffJson; + + private String planJson; + + private String resultJson; + + private String errorMessage; + + private Long operatorId; + + private String operatorName; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime confirmedTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime executedTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime createdTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime updatedTime; + + public void setStatus(String status) { + this.status = status; + this.statusText = switch (status == null ? "" : status) { + case "PREVIEW" -> "待确认"; + case "CONFIRMED" -> "已确认"; + case "CANCELLED" -> "已取消"; + case "EXECUTED" -> "执行成功"; + case "FAILED" -> "执行失败"; + default -> "未知"; + }; + } +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditResultRequest.java b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditResultRequest.java new file mode 100644 index 000000000..349327636 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentAuditResultRequest.java @@ -0,0 +1,24 @@ +package com.xiaou.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 智能体执行结果审计请求 + * + * @author xiaou + */ +@Data +@Schema(description = "智能体执行结果审计请求") +public class AgentAuditResultRequest { + + @Schema(description = "执行是否成功") + private Boolean success; + + @Schema(description = "执行结果 JSON") + private String resultJson; + + @Size(max = 1000, message = "错误消息不能超过1000个字符") + private String errorMessage; +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatArtifact.java b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatArtifact.java new file mode 100644 index 000000000..a070aeb0e --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatArtifact.java @@ -0,0 +1,26 @@ +package com.xiaou.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * 智能体结构化产物。 + * + * @author xiaou + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "智能体结构化产物") +public class AgentChatArtifact { + + private String type; + + private String title; + + private Map data; +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatConfirmation.java b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatConfirmation.java new file mode 100644 index 000000000..cb9235e8a --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatConfirmation.java @@ -0,0 +1,22 @@ +package com.xiaou.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 智能体确认信息。 + * + * @author xiaou + */ +@Data +@Schema(description = "智能体确认信息") +public class AgentChatConfirmation { + + private String auditId; + + private String confirmationId; + + private String requiredText; + + private String prompt; +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatDiffItem.java b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatDiffItem.java new file mode 100644 index 000000000..9cf6d8445 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatDiffItem.java @@ -0,0 +1,26 @@ +package com.xiaou.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 智能体预览差异项。 + * + * @author xiaou + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "智能体预览差异项") +public class AgentChatDiffItem { + + private String field; + + private Object beforeValue; + + private Object afterValue; + + private String description; +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatPlanStep.java b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatPlanStep.java new file mode 100644 index 000000000..c35061718 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatPlanStep.java @@ -0,0 +1,24 @@ +package com.xiaou.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 智能体计划步骤。 + * + * @author xiaou + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "智能体计划步骤") +public class AgentChatPlanStep { + + private String title; + + private String status; + + private String detail; +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatRequest.java b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatRequest.java new file mode 100644 index 000000000..373ff8f2c --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatRequest.java @@ -0,0 +1,31 @@ +package com.xiaou.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 管理员智能体统一聊天请求。 + * + * @author xiaou + */ +@Data +@Schema(description = "管理员智能体统一聊天请求") +public class AgentChatRequest { + + @Schema(description = "会话ID,前端可选传入用于归并上下文") + @Size(max = 128, message = "会话ID不能超过128个字符") + private String sessionId; + + @Schema(description = "管理员自然语言消息") + @Size(max = 4000, message = "管理员自然语言消息不能超过4000个字符") + private String message; + + @Schema(description = "待确认审计ID。为空时表示新请求;不为空时表示确认/取消已有预览") + @Size(max = 80, message = "审计ID不能超过80个字符") + private String auditId; + + @Schema(description = "强确认文本") + @Size(max = 200, message = "强确认文本不能超过200个字符") + private String confirmationText; +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatResponse.java b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatResponse.java new file mode 100644 index 000000000..839085f66 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatResponse.java @@ -0,0 +1,68 @@ +package com.xiaou.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 管理员智能体统一聊天响应。 + * + * @author xiaou + */ +@Data +@Schema(description = "管理员智能体统一聊天响应") +public class AgentChatResponse { + + @Schema(description = "会话ID") + private String sessionId; + + @Schema(description = "后端运行追踪ID") + private String traceId; + + @Schema(description = "状态:answered / confirm_required / executed / cancelled / rejected / error") + private String status; + + @Schema(description = "给管理员展示的自然语言回复") + private String answer; + + @Schema(description = "审计ID") + private String auditId; + + @Schema(description = "写入动作幂等键") + private String idempotencyKey; + + @Schema(description = "命中的工具名称") + private String toolName; + + @Schema(description = "风险级别") + private String riskLevel; + + @Schema(description = "风险类别") + private String riskCategory; + + @Schema(description = "执行计划") + private List plan = new ArrayList<>(); + + @Schema(description = "预览差异") + private List diff = new ArrayList<>(); + + @Schema(description = "结构化产物") + private List artifacts = new ArrayList<>(); + + @Schema(description = "确认信息") + private AgentChatConfirmation confirmation; + + @Schema(description = "下一步建议") + private List nextActions = new ArrayList<>(); + + @Schema(description = "后端运行阶段追踪") + private List trace = new ArrayList<>(); + + @Schema(description = "错误码") + private String errorCode; + + @Schema(description = "错误说明") + private String errorMessage; +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatTraceStep.java b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatTraceStep.java new file mode 100644 index 000000000..d2bda0132 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/dto/AgentChatTraceStep.java @@ -0,0 +1,26 @@ +package com.xiaou.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 管理员智能体运行阶段追踪项。 + * + * @author xiaou + */ +@Data +@Schema(description = "管理员智能体运行阶段追踪项") +public class AgentChatTraceStep { + + @Schema(description = "阶段名称") + private String stage; + + @Schema(description = "阶段状态") + private String status; + + @Schema(description = "阶段说明") + private String detail; + + @Schema(description = "距离请求开始的耗时,毫秒") + private Long elapsedMs; +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/mapper/SysAgentAuditMapper.java b/xiaou-system/src/main/java/com/xiaou/system/mapper/SysAgentAuditMapper.java new file mode 100644 index 000000000..2557a15be --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/mapper/SysAgentAuditMapper.java @@ -0,0 +1,24 @@ +package com.xiaou.system.mapper; + +import com.xiaou.system.domain.SysAgentAudit; +import com.xiaou.system.dto.AgentAuditQueryRequest; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 智能体审计 Mapper + * + * @author xiaou + */ +@Mapper +public interface SysAgentAuditMapper { + + int insert(SysAgentAudit agentAudit); + + int updateByAuditId(SysAgentAudit agentAudit); + + SysAgentAudit selectByAuditId(String auditId); + + List selectList(AgentAuditQueryRequest query); +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/mapper/SysAgentSessionContextMapper.java b/xiaou-system/src/main/java/com/xiaou/system/mapper/SysAgentSessionContextMapper.java new file mode 100644 index 000000000..ebe0bd085 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/mapper/SysAgentSessionContextMapper.java @@ -0,0 +1,17 @@ +package com.xiaou.system.mapper; + +import com.xiaou.system.domain.SysAgentSessionContext; +import org.apache.ibatis.annotations.Mapper; + +/** + * 管理员智能体会话上下文 Mapper。 + * + * @author xiaou + */ +@Mapper +public interface SysAgentSessionContextMapper { + + SysAgentSessionContext selectBySessionId(String sessionId); + + int upsert(SysAgentSessionContext sessionContext); +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/service/SysAgentAuditService.java b/xiaou-system/src/main/java/com/xiaou/system/service/SysAgentAuditService.java new file mode 100644 index 000000000..41ef5d4f2 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/service/SysAgentAuditService.java @@ -0,0 +1,27 @@ +package com.xiaou.system.service; + +import com.xiaou.common.core.domain.PageResult; +import com.xiaou.system.dto.AgentAuditPreviewRequest; +import com.xiaou.system.dto.AgentAuditQueryRequest; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentAuditResultRequest; + +/** + * 智能体审计服务 + * + * @author xiaou + */ +public interface SysAgentAuditService { + + AgentAuditResponse createPreview(AgentAuditPreviewRequest request, Long operatorId, String operatorName); + + AgentAuditResponse confirm(String auditId); + + AgentAuditResponse cancel(String auditId, String reason); + + AgentAuditResponse recordResult(String auditId, AgentAuditResultRequest request); + + AgentAuditResponse getByAuditId(String auditId); + + PageResult getAuditPage(AgentAuditQueryRequest query); +} diff --git a/xiaou-system/src/main/java/com/xiaou/system/service/impl/SysAgentAuditServiceImpl.java b/xiaou-system/src/main/java/com/xiaou/system/service/impl/SysAgentAuditServiceImpl.java new file mode 100644 index 000000000..d9da49813 --- /dev/null +++ b/xiaou-system/src/main/java/com/xiaou/system/service/impl/SysAgentAuditServiceImpl.java @@ -0,0 +1,197 @@ +package com.xiaou.system.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import com.xiaou.common.core.domain.PageResult; +import com.xiaou.common.utils.PageHelper; +import com.xiaou.system.domain.SysAgentAudit; +import com.xiaou.system.dto.AgentAuditPreviewRequest; +import com.xiaou.system.dto.AgentAuditQueryRequest; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentAuditResultRequest; +import com.xiaou.system.mapper.SysAgentAuditMapper; +import com.xiaou.system.service.SysAgentAuditService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * 智能体审计服务实现 + * + * @author xiaou + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SysAgentAuditServiceImpl implements SysAgentAuditService { + + private static final String STATUS_PREVIEW = "PREVIEW"; + private static final String STATUS_CONFIRMED = "CONFIRMED"; + private static final String STATUS_CANCELLED = "CANCELLED"; + private static final String STATUS_EXECUTED = "EXECUTED"; + private static final String STATUS_FAILED = "FAILED"; + + private final SysAgentAuditMapper agentAuditMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public AgentAuditResponse createPreview(AgentAuditPreviewRequest request, Long operatorId, String operatorName) { + LocalDateTime now = LocalDateTime.now(); + SysAgentAudit audit = new SysAgentAudit(); + audit.setAuditId(createAuditId()); + audit.setConfirmationId(request.getConfirmationId()); + audit.setIdempotencyKey(resolveIdempotencyKey(request.getIdempotencyKey())); + audit.setUserMessage(limit(request.getUserMessage(), 1000)); + audit.setIntent(request.getIntent()); + audit.setActionId(request.getActionId()); + audit.setRoute(request.getRoute()); + audit.setRiskLevel(request.getRiskLevel()); + audit.setRiskCategory(request.getRiskCategory()); + audit.setStatus(STATUS_PREVIEW); + audit.setSummary(request.getSummary()); + audit.setPayloadJson(request.getPayloadJson()); + audit.setDiffJson(request.getDiffJson()); + audit.setPlanJson(request.getPlanJson()); + audit.setOperatorId(operatorId); + audit.setOperatorName(operatorName); + audit.setCreatedTime(now); + audit.setUpdatedTime(now); + + agentAuditMapper.insert(audit); + return convertToResponse(audit); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public AgentAuditResponse confirm(String auditId) { + SysAgentAudit existing = requireAudit(auditId); + ensureStatus(existing, STATUS_PREVIEW, "confirm"); + LocalDateTime now = LocalDateTime.now(); + SysAgentAudit update = new SysAgentAudit(); + update.setAuditId(existing.getAuditId()); + update.setStatus(STATUS_CONFIRMED); + update.setExpectedStatus(STATUS_PREVIEW); + update.setConfirmedTime(now); + update.setUpdatedTime(now); + + updateExpectingOneRow(update, "confirm"); + existing.setStatus(STATUS_CONFIRMED); + existing.setConfirmedTime(now); + existing.setUpdatedTime(now); + return convertToResponse(existing); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public AgentAuditResponse cancel(String auditId, String reason) { + SysAgentAudit existing = requireAudit(auditId); + ensureStatus(existing, STATUS_PREVIEW, "cancel"); + LocalDateTime now = LocalDateTime.now(); + SysAgentAudit update = new SysAgentAudit(); + update.setAuditId(existing.getAuditId()); + update.setStatus(STATUS_CANCELLED); + update.setExpectedStatus(STATUS_PREVIEW); + update.setErrorMessage(limit(reason, 1000)); + update.setUpdatedTime(now); + + updateExpectingOneRow(update, "cancel"); + existing.setStatus(STATUS_CANCELLED); + existing.setErrorMessage(update.getErrorMessage()); + existing.setUpdatedTime(now); + return convertToResponse(existing); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public AgentAuditResponse recordResult(String auditId, AgentAuditResultRequest request) { + SysAgentAudit existing = requireAudit(auditId); + ensureStatus(existing, STATUS_CONFIRMED, "record result"); + LocalDateTime now = LocalDateTime.now(); + boolean success = Boolean.TRUE.equals(request.getSuccess()); + SysAgentAudit update = new SysAgentAudit(); + update.setAuditId(existing.getAuditId()); + update.setStatus(success ? STATUS_EXECUTED : STATUS_FAILED); + update.setExpectedStatus(STATUS_CONFIRMED); + update.setResultJson(request.getResultJson()); + update.setErrorMessage(limit(request.getErrorMessage(), 1000)); + update.setExecutedTime(now); + update.setUpdatedTime(now); + + updateExpectingOneRow(update, "record result"); + existing.setStatus(update.getStatus()); + existing.setResultJson(update.getResultJson()); + existing.setErrorMessage(update.getErrorMessage()); + existing.setExecutedTime(now); + existing.setUpdatedTime(now); + return convertToResponse(existing); + } + + @Override + public AgentAuditResponse getByAuditId(String auditId) { + SysAgentAudit audit = agentAuditMapper.selectByAuditId(auditId); + return audit == null ? null : convertToResponse(audit); + } + + @Override + public PageResult getAuditPage(AgentAuditQueryRequest query) { + return PageHelper.doPage(query.getPageNum(), query.getPageSize(), () -> { + List audits = agentAuditMapper.selectList(query); + return audits.stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + }); + } + + private SysAgentAudit requireAudit(String auditId) { + SysAgentAudit audit = agentAuditMapper.selectByAuditId(auditId); + if (audit == null) { + throw new IllegalArgumentException("agent audit not found: " + auditId); + } + return audit; + } + + private void ensureStatus(SysAgentAudit audit, String expectedStatus, String action) { + if (!expectedStatus.equals(audit.getStatus())) { + throw new IllegalStateException("cannot " + action + " agent audit " + audit.getAuditId() + + " because status is " + audit.getStatus() + ", expected " + expectedStatus); + } + } + + private void updateExpectingOneRow(SysAgentAudit update, String action) { + int updatedRows = agentAuditMapper.updateByAuditId(update); + if (updatedRows != 1) { + throw new IllegalStateException("cannot " + action + " agent audit " + update.getAuditId() + + " because status changed from " + update.getExpectedStatus()); + } + } + + private AgentAuditResponse convertToResponse(SysAgentAudit audit) { + AgentAuditResponse response = new AgentAuditResponse(); + BeanUtil.copyProperties(audit, response); + response.setStatus(audit.getStatus()); + return response; + } + + private String createAuditId() { + return "agent-audit-" + UUID.randomUUID(); + } + + private String resolveIdempotencyKey(String idempotencyKey) { + if (idempotencyKey == null || idempotencyKey.trim().isEmpty()) { + return "agent-idempotency-" + UUID.randomUUID(); + } + return limit(idempotencyKey.trim(), 160); + } + + private String limit(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } +} diff --git a/xiaou-system/src/main/resources/mapper/SysAgentAuditMapper.xml b/xiaou-system/src/main/resources/mapper/SysAgentAuditMapper.xml new file mode 100644 index 000000000..809af7eaf --- /dev/null +++ b/xiaou-system/src/main/resources/mapper/SysAgentAuditMapper.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + id, audit_id, confirmation_id, idempotency_key, user_message, intent, action_id, route, + risk_level, risk_category, status, summary, payload_json, diff_json, + plan_json, result_json, error_message, operator_id, operator_name, + confirmed_time, executed_time, created_time, updated_time + + + + + + AND intent = #{intent} + + + AND action_id = #{actionId} + + + AND risk_category = #{riskCategory} + + + AND status = #{status} + + + AND operator_name LIKE CONCAT('%', #{operatorName}, '%') + + + AND created_time >= #{startTime} + + + AND created_time <= #{endTime} + + + + + + INSERT INTO sys_agent_audit ( + audit_id, confirmation_id, idempotency_key, user_message, intent, action_id, route, + risk_level, risk_category, status, summary, payload_json, diff_json, + plan_json, result_json, error_message, operator_id, operator_name, + confirmed_time, executed_time, created_time, updated_time + ) VALUES ( + #{auditId}, #{confirmationId}, #{idempotencyKey}, #{userMessage}, #{intent}, #{actionId}, #{route}, + #{riskLevel}, #{riskCategory}, #{status}, #{summary}, #{payloadJson}, #{diffJson}, + #{planJson}, #{resultJson}, #{errorMessage}, #{operatorId}, #{operatorName}, + #{confirmedTime}, #{executedTime}, #{createdTime}, #{updatedTime} + ) + + + + UPDATE sys_agent_audit + + status = #{status}, + result_json = #{resultJson}, + error_message = #{errorMessage}, + confirmed_time = #{confirmedTime}, + executed_time = #{executedTime}, + updated_time = #{updatedTime} + + WHERE audit_id = #{auditId} + + AND status = #{expectedStatus} + + + + + + + diff --git a/xiaou-system/src/main/resources/mapper/SysAgentSessionContextMapper.xml b/xiaou-system/src/main/resources/mapper/SysAgentSessionContextMapper.xml new file mode 100644 index 000000000..2a5a4a1a6 --- /dev/null +++ b/xiaou-system/src/main/resources/mapper/SysAgentSessionContextMapper.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + INSERT INTO sys_agent_session_context ( + session_id, turns_json, created_time, updated_time + ) VALUES ( + #{sessionId}, #{turnsJson}, #{createdTime}, #{updatedTime} + ) + ON DUPLICATE KEY UPDATE + turns_json = VALUES(turns_json), + updated_time = VALUES(updated_time) + + diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AbstractReadonlyAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AbstractReadonlyAgentToolTest.java new file mode 100644 index 000000000..832dabe7a --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AbstractReadonlyAgentToolTest.java @@ -0,0 +1,102 @@ +package com.xiaou.system.agent; + +import com.xiaou.system.dto.AgentChatArtifact; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AbstractReadonlyAgentToolTest { + + @Test + void shouldProvideSharedReadonlyDefinitionAndPreview() { + DemoReadonlyTool tool = new DemoReadonlyTool(); + + AgentToolDefinition definition = tool.definition(); + AgentToolPreview preview = tool.preview(new AgentToolCall(), context()); + + assertEquals("demo.readonly", definition.getName()); + assertEquals("readonly", definition.getRiskLevel()); + assertEquals("READONLY", definition.getRiskCategory()); + assertTrue(preview.isExecutable()); + assertEquals("查询演示数据是只读动作,不需要写入预览。", preview.getSummary()); + } + + @Test + void shouldProvideSharedCallArtifactAndSuccessResultHelpers() { + DemoReadonlyTool tool = new DemoReadonlyTool(); + + Optional call = tool.resolve("查演示"); + AgentToolResult result = tool.execute(call.orElseThrow(), context()); + + assertEquals("demo.readonly", call.orElseThrow().getToolName()); + assertEquals("查询演示数据", call.orElseThrow().getSummary()); + assertEquals(3, call.orElseThrow().getInput().get("limit")); + assertTrue(result.isSuccess()); + assertEquals("已读取演示数据。", result.getSummary()); + assertEquals("demoArtifact", result.getArtifacts().get(0).getType()); + assertEquals(Boolean.TRUE, result.getArtifacts().get(0).getData().get("ok")); + assertEquals(List.of("可以继续查看详情。"), result.getNextActions()); + } + + @Test + void shouldRejectNonReadonlyDefinitions() { + AgentToolDefinition writeDefinition = AgentToolDefinitionBuilder.write("demo.write", "写入演示") + .description("写入演示。") + .route("/admin/demo/write") + .permission("agent:demo:write") + .confirmationText("确认写入") + .build(); + + assertThrows(IllegalArgumentException.class, () -> new BadReadonlyTool(writeDefinition)); + } + + private AgentExecutionContext context() { + return new AgentExecutionContext("session-1", "", new AgentOperator(7L, "admin")); + } + + private static class DemoReadonlyTool extends AbstractReadonlyAgentTool { + + private DemoReadonlyTool() { + super(AgentToolDefinitionBuilder.readonly("demo.readonly", "查询演示数据") + .description("读取演示数据。") + .route("/admin/demo/readonly") + .permission("agent:demo:read") + .input("limit", Map.of("type", "integer", "minimum", 1), true) + .build()); + } + + @Override + public Optional resolve(String message) { + return Optional.of(call("查询演示数据", Map.of("limit", 3))); + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + AgentChatArtifact artifact = artifact("demoArtifact", "演示数据", Map.of("ok", true)); + return success("已读取演示数据。", artifact, List.of("可以继续查看详情。")); + } + } + + private static class BadReadonlyTool extends AbstractReadonlyAgentTool { + + private BadReadonlyTool(AgentToolDefinition definition) { + super(definition); + } + + @Override + public Optional resolve(String message) { + return Optional.empty(); + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + return success("ok", List.of(), List.of()); + } + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AdminAgentPlannerRegressionTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AdminAgentPlannerRegressionTest.java new file mode 100644 index 000000000..9f9b6ec13 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AdminAgentPlannerRegressionTest.java @@ -0,0 +1,192 @@ +package com.xiaou.system.agent; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.ai.prompt.admin.AdminAgentPromptSpecs; +import com.xiaou.ai.support.AiExecutionSupport; +import com.xiaou.chat.service.ChatUserBanService; +import com.xiaou.points.service.LotteryAdminService; +import com.xiaou.system.service.SysAgentAuditService; +import com.xiaou.system.service.SysOperationLogService; +import org.junit.jupiter.api.Test; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AdminAgentPlannerRegressionTest { + + private static final String FIXTURE_PATH = "/agent/admin-agent-planner-regression-cases.json"; + private static final Set EXPECTED_STATUSES = Set.of("RESOLVED", "CLARIFICATION", "EMPTY"); + private static final TypeReference> CASE_LIST_TYPE = new TypeReference<>() { + }; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final SysOperationLogService operationLogService = mock(SysOperationLogService.class); + private final ChatUserBanService chatUserBanService = mock(ChatUserBanService.class); + private final LotteryAdminService lotteryAdminService = mock(LotteryAdminService.class); + private final SysAgentAuditService auditService = mock(SysAgentAuditService.class); + private final AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + + @Test + void shouldKeepPlannerContractStableAcrossRegressionFixtures() throws IOException { + AgentToolRegistry registry = registry(); + assertFixtureContract(loadCases(), registry); + + for (PlannerRegressionCase testCase : loadCases()) { + AiExecutionSupport aiExecutionSupport = mock(AiExecutionSupport.class); + LlmAgentPlanResolver resolver = new LlmAgentPlanResolver( + new DeterministicAgentPlanResolver(registry), + registry, + aiExecutionSupport, + objectMapper + ); + stubAiResponse(aiExecutionSupport, testCase.aiResponse); + + AgentPlanResolution resolution = resolver.resolvePlan(testCase.message); + + assertCase(testCase, resolution); + } + } + + private AgentToolRegistry registry() { + return new AgentToolRegistry(AgentToolTestCatalog.builtInTools( + operationLogService, + chatUserBanService, + lotteryAdminService, + auditService, + catalogService + )); + } + + private List loadCases() throws IOException { + InputStream inputStream = getClass().getResourceAsStream(FIXTURE_PATH); + assertNotNull(inputStream, "Missing fixture: " + FIXTURE_PATH); + try (inputStream) { + return objectMapper.readValue(inputStream, CASE_LIST_TYPE); + } + } + + private void assertFixtureContract(List cases, AgentToolRegistry registry) { + Set ids = new HashSet<>(); + Set resolvedToolNames = new HashSet<>(); + for (PlannerRegressionCase testCase : cases) { + assertTrue(StringUtils.hasText(testCase.id), "fixture id must not be blank"); + assertTrue(ids.add(testCase.id), "duplicate fixture id: " + testCase.id); + assertTrue(StringUtils.hasText(testCase.message), testCase.id + " message must not be blank"); + assertTrue(StringUtils.hasText(testCase.aiResponse), testCase.id + " aiResponse must not be blank"); + assertTrue(EXPECTED_STATUSES.contains(testCase.expectedStatus), testCase.id + " expectedStatus is invalid"); + + if ("RESOLVED".equals(testCase.expectedStatus) || "CLARIFICATION".equals(testCase.expectedStatus)) { + AgentToolDefinition definition = registry.find(testCase.expectedToolName) + .orElseThrow(() -> new AssertionError(testCase.id + " references unregistered tool: " + testCase.expectedToolName)) + .definition(); + if ("RESOLVED".equals(testCase.expectedStatus)) { + resolvedToolNames.add(definition.getName()); + } + assertExpectedInputKeysExistInSchema(testCase, definition); + assertMissingFieldsExistInSchema(testCase, definition); + } + } + for (AgentToolDefinition definition : registry.definitions()) { + assertTrue(resolvedToolNames.contains(definition.getName()), + "missing RESOLVED planner regression fixture for tool: " + definition.getName()); + } + } + + private void assertExpectedInputKeysExistInSchema(PlannerRegressionCase testCase, AgentToolDefinition definition) { + Map schema = definition.getInputSchema() == null ? Map.of() : definition.getInputSchema(); + for (String key : testCase.expectedInput.keySet()) { + assertTrue(schema.containsKey(key), testCase.id + " expectedInput key is not declared by tool schema: " + key); + } + } + + private void assertMissingFieldsExistInSchema(PlannerRegressionCase testCase, AgentToolDefinition definition) { + Map schema = definition.getInputSchema() == null ? Map.of() : definition.getInputSchema(); + for (String key : testCase.expectedMissingFields) { + assertTrue(schema.containsKey(key), testCase.id + " missing field is not declared by tool schema: " + key); + } + } + + private void assertCase(PlannerRegressionCase testCase, AgentPlanResolution resolution) { + switch (testCase.expectedStatus) { + case "RESOLVED" -> { + assertTrue(resolution.isResolved(), testCase.id); + AgentResolvedToolCall resolved = resolution.getResolvedCall(); + assertEquals(testCase.expectedToolName, resolved.call().getToolName(), testCase.id); + assertEquals(normalizeValue(testCase.expectedInput), normalizeValue(resolved.call().getInput()), testCase.id); + } + case "CLARIFICATION" -> { + assertFalse(resolution.isResolved(), testCase.id); + assertEquals(AgentChatErrorCode.PLAN_CLARIFICATION_REQUIRED, resolution.getErrorCode(), testCase.id); + for (String missingField : testCase.expectedMissingFields) { + assertTrue(resolution.getMessage().contains(missingField), testCase.id); + } + } + case "EMPTY" -> { + assertFalse(resolution.isResolved(), testCase.id); + assertEquals(null, resolution.getErrorCode(), testCase.id); + } + default -> throw new AssertionError("Unknown expectedStatus: " + testCase.expectedStatus); + } + } + + private void stubAiResponse(AiExecutionSupport aiExecutionSupport, String content) { + when(aiExecutionSupport.chatWithFallback( + eq("admin.agent.plan"), + eq(AdminAgentPromptSpecs.PLAN), + org.mockito.ArgumentMatchers.>any(), + org.mockito.ArgumentMatchers.>any(), + org.mockito.ArgumentMatchers.>any() + )).thenAnswer(invocation -> { + Function parser = invocation.getArgument(3); + return parser.apply(content); + }); + } + + private Object normalizeValue(Object value) { + if (value instanceof Map map) { + Map normalized = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + normalized.put(String.valueOf(entry.getKey()), normalizeValue(entry.getValue())); + } + return normalized; + } + if (value instanceof List list) { + return list.stream().map(this::normalizeValue).toList(); + } + if (value instanceof Number number) { + double doubleValue = number.doubleValue(); + if (doubleValue == Math.rint(doubleValue)) { + return number.longValue(); + } + return doubleValue; + } + return value; + } + + private static class PlannerRegressionCase { + public String id; + public String message; + public String aiResponse; + public String expectedStatus; + public String expectedToolName; + public Map expectedInput = new LinkedHashMap<>(); + public List expectedMissingFields = List.of(); + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AgentChatOrchestratorTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentChatOrchestratorTest.java new file mode 100644 index 000000000..598e56d00 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentChatOrchestratorTest.java @@ -0,0 +1,1131 @@ +package com.xiaou.system.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.common.core.domain.PageResult; +import com.xiaou.points.dto.lottery.admin.RealtimeMonitorResponse; +import com.xiaou.points.service.LotteryAdminService; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentAuditResultRequest; +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.dto.AgentChatRequest; +import com.xiaou.system.dto.AgentChatResponse; +import com.xiaou.system.agent.tools.AgentAuditDetailAgentTool; +import com.xiaou.system.agent.tools.AgentAuditListAgentTool; +import com.xiaou.system.agent.tools.AgentRecoveryDryRunAgentTool; +import com.xiaou.system.agent.tools.AgentRecoveryExplainAgentTool; +import com.xiaou.system.agent.tools.AgentRuntimeObservabilityAgentTool; +import com.xiaou.system.agent.tools.AgentRuntimeReadinessAgentTool; +import com.xiaou.system.agent.tools.AgentRuntimeStatusAgentTool; +import com.xiaou.system.agent.tools.AgentSessionContextAgentTool; +import com.xiaou.system.agent.tools.AgentToolCatalogAgentTool; +import com.xiaou.system.agent.tools.AgentToolDetailAgentTool; +import com.xiaou.system.agent.tools.AgentToolMetricsAlertsAgentTool; +import com.xiaou.system.agent.tools.AgentToolMetricsHealthAgentTool; +import com.xiaou.system.agent.tools.AgentToolMetricsSummaryAgentTool; +import com.xiaou.system.agent.tools.LotteryRealtimeMonitorAgentTool; +import com.xiaou.system.service.SysAgentAuditService; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AgentChatOrchestratorTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void shouldExecuteReadonlyToolThroughUnifiedRuntime() { + AgentTool tool = readonlyTool("system.operationLog.list"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentSessionContextStore sessionStore = new AgentSessionContextStore(); + AgentChatOrchestrator orchestrator = orchestrator(List.of(tool), resolver(tool, Map.of("limit", 3)), auditService, sessionStore); + + AgentChatResponse response = orchestrator.chat(request("session-1", "查最近3条操作日志"), operator()); + + assertEquals("answered", response.getStatus()); + assertEquals("system.operationLog.list", response.getToolName()); + assertEquals("readonly", response.getRiskLevel()); + assertEquals("已执行 system.operationLog.list", response.getAnswer()); + assertNotNull(response.getTraceId()); + assertFalse(response.getTrace().isEmpty()); + assertEquals("system.operationLog.list", sessionStore.snapshot("session-1", 100L).getRecentTurns().get(0).getToolName()); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldExecuteLotteryRealtimeMonitorThroughUnifiedRuntime() { + LotteryAdminService lotteryAdminService = mock(LotteryAdminService.class); + when(lotteryAdminService.getRealtimeMonitor()).thenReturn(lotteryMonitor()); + AgentTool tool = new LotteryRealtimeMonitorAgentTool(lotteryAdminService); + AgentToolRegistry registry = new AgentToolRegistry(List.of(tool)); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentChatOrchestrator orchestrator = new AgentChatOrchestrator( + registry, + new DeterministicAgentPlanResolver(registry), + new AgentPolicyEngine(), + auditService, + objectMapper, + new AgentSessionContextStore() + ); + + AgentChatResponse response = orchestrator.chat(request("session-1", "看一下抽奖实时监控"), operator()); + + assertEquals("answered", response.getStatus()); + assertEquals("points.lottery.monitor.realtime", response.getToolName()); + assertEquals("readonly", response.getRiskLevel()); + assertTrue(response.getAnswer().contains("今日抽奖 9 次")); + assertTrue(response.getArtifacts().stream().anyMatch(artifact -> "pointsLotteryRealtimeMonitor".equals(artifact.getType()))); + verify(lotteryAdminService).getRealtimeMonitor(); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldRecordGenericToolMetricsForExecuteAndPreviewPhases() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder metricsRecorder = new AgentToolMetricsRecorder(meterRegistry); + AgentTool readonlyTool = readonlyTool("system.operationLog.list"); + AgentTool writeTool = writeTool("chat.userBan.unban"); + + SysAgentAuditService readonlyAuditService = mock(SysAgentAuditService.class); + AgentChatOrchestrator readonlyOrchestrator = orchestrator( + List.of(readonlyTool), + resolver(readonlyTool, Map.of("limit", 3)), + readonlyAuditService, + new AgentSessionContextStore(), + metricsRecorder + ); + readonlyOrchestrator.chat(request("session-1", "查最近3条操作日志"), operator()); + + SysAgentAuditService writeAuditService = mock(SysAgentAuditService.class); + when(writeAuditService.createPreview(any(), eq(100L), eq("admin"))) + .thenReturn(audit("audit-1", "chat.userBan.unban", "PREVIEW")); + AgentChatOrchestrator writeOrchestrator = orchestrator( + List.of(writeTool), + resolver(writeTool, Map.of("userId", 88)), + writeAuditService, + new AgentSessionContextStore(), + metricsRecorder + ); + writeOrchestrator.chat(request("session-2", "解除 88 禁言"), operator()); + + assertCounter(meterRegistry, "system.operationLog.list", "execute", "success", "readonly", "READONLY"); + assertCounter(meterRegistry, "chat.userBan.unban", "preview", "success", "medium", "WRITE"); + } + + @Test + void shouldAnswerToolCatalogThroughUnifiedRuntime() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + AgentTool tool = new AgentToolCatalogAgentTool(catalogService); + when(catalogService.definitions()).thenReturn(List.of(tool.definition())); + AgentToolRegistry registry = new AgentToolRegistry(List.of(tool)); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentChatOrchestrator orchestrator = new AgentChatOrchestrator( + registry, + new DeterministicAgentPlanResolver(registry), + new AgentPolicyEngine(), + auditService, + objectMapper, + new AgentSessionContextStore() + ); + + AgentChatResponse response = orchestrator.chat(request("session-1", "你能做什么"), operator()); + + assertEquals("answered", response.getStatus()); + assertEquals("system.agent.tools.list", response.getToolName()); + assertTrue(response.getAnswer().contains("已注册 1 个工具")); + assertFalse(response.getArtifacts().isEmpty()); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldAnswerToolDetailThroughUnifiedRuntime() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + AgentToolDetailAgentTool tool = new AgentToolDetailAgentTool(catalogService); + AgentToolDefinition targetDefinition = definition("chat.userBan.unban", "medium", "WRITE", true); + targetDefinition.setConfirmationRequired(true); + targetDefinition.setRequiredPermissions(List.of("agent:chat:user-ban:write")); + when(catalogService.definitions()).thenReturn(List.of(targetDefinition)); + AgentToolRegistry registry = new AgentToolRegistry(List.of(tool)); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentChatOrchestrator orchestrator = new AgentChatOrchestrator( + registry, + new DeterministicAgentPlanResolver(registry), + new AgentPolicyEngine(), + auditService, + objectMapper, + new AgentSessionContextStore() + ); + + AgentChatResponse response = orchestrator.chat(request("session-1", "chat.userBan.unban 这个工具需要什么权限"), operator()); + + assertEquals("answered", response.getStatus()); + assertEquals("system.agent.tools.detail", response.getToolName()); + assertTrue(response.getAnswer().contains("chat.userBan.unban")); + assertFalse(response.getArtifacts().isEmpty()); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldAnswerRuntimeStatusThroughUnifiedRuntime() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + AgentSessionProperties sessionProperties = new AgentSessionProperties(); + sessionProperties.setRepository("memory"); + AgentTool tool = new AgentRuntimeStatusAgentTool(catalogService, sessionProperties); + when(catalogService.definitions()).thenReturn(List.of(tool.definition())); + AgentToolRegistry registry = new AgentToolRegistry(List.of(tool)); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentChatOrchestrator orchestrator = new AgentChatOrchestrator( + registry, + new DeterministicAgentPlanResolver(registry), + new AgentPolicyEngine(), + auditService, + objectMapper, + new AgentSessionContextStore() + ); + + AgentChatResponse response = orchestrator.chat(request("session-1", "智能体现在状态怎么样"), operator()); + + assertEquals("answered", response.getStatus()); + assertEquals("system.agent.runtime.status", response.getToolName()); + assertTrue(response.getAnswer().contains("已注册 1 个后端工具")); + assertFalse(response.getArtifacts().isEmpty()); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldAnswerRuntimeReadinessThroughUnifiedRuntime() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + AgentSessionProperties sessionProperties = new AgentSessionProperties(); + AgentTool tool = new AgentRuntimeReadinessAgentTool(catalogService, sessionProperties); + AgentToolDefinition readiness = tool.definition(); + when(catalogService.definitions()).thenReturn(List.of( + readinessDefinition("system.agent.tools.list"), + readinessDefinition("system.agent.tools.detail"), + readinessDefinition("system.agent.tools.search"), + readinessDefinition("system.agent.tools.validate"), + readinessDefinition("system.agent.tools.access_check"), + readinessDefinition("system.agent.runtime.status"), + readiness, + readinessDefinition("system.agent.runtime.observability"), + readinessDefinition("system.agent.session.context"), + readinessDefinition("system.agent.operator.self"), + readinessDefinition("system.agent.planner.diagnostics"), + readinessDefinition("system.agent.planner.dry_run"), + readinessDefinition("system.agent.request.dry_run"), + readinessDefinition("system.agent.policy.dry_run"), + readinessDefinition("system.agent.metrics.summary"), + readinessDefinition("system.agent.metrics.health"), + readinessDefinition("system.agent.metrics.alerts"), + readinessDefinition("system.agent.audit.list"), + readinessDefinition("system.agent.audit.detail"), + readinessDefinition("system.agent.recovery.explain"), + readinessDefinition("system.agent.recovery.dry_run") + )); + AgentToolRegistry registry = new AgentToolRegistry(List.of(tool)); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentChatOrchestrator orchestrator = new AgentChatOrchestrator( + registry, + new DeterministicAgentPlanResolver(registry), + new AgentPolicyEngine(), + auditService, + objectMapper, + new AgentSessionContextStore() + ); + + AgentChatResponse response = orchestrator.chat(request("session-1", "智能体运行时 readiness 自检一下"), operator()); + + assertEquals("answered", response.getStatus()); + assertEquals("system.agent.runtime.readiness", response.getToolName()); + assertTrue(response.getAnswer().contains("READY")); + assertTrue(response.getArtifacts().stream().anyMatch(artifact -> "agentRuntimeReadiness".equals(artifact.getType()))); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldAnswerSessionContextThroughUnifiedRuntime() { + AgentTool tool = new AgentSessionContextAgentTool(new AgentSessionProperties()); + AgentToolRegistry registry = new AgentToolRegistry(List.of(tool)); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentSessionContextStore sessionStore = new AgentSessionContextStore(); + AgentChatRequest previousRequest = request("session-1", "查最近3条操作日志"); + AgentChatResponse previousResponse = new AgentChatResponse(); + previousResponse.setSessionId("session-1"); + previousResponse.setStatus("answered"); + previousResponse.setAnswer("已查询到 3 条操作日志"); + previousResponse.setToolName("system.operationLog.list"); + previousResponse.setTraceId("trace-1"); + sessionStore.record(previousRequest, previousResponse, 100L); + AgentChatOrchestrator orchestrator = new AgentChatOrchestrator( + registry, + new DeterministicAgentPlanResolver(registry), + new AgentPolicyEngine(), + auditService, + objectMapper, + sessionStore + ); + + AgentChatResponse response = orchestrator.chat(request("session-1", "你现在记住了什么"), operator()); + + assertEquals("answered", response.getStatus()); + assertEquals("system.agent.session.context", response.getToolName()); + assertTrue(response.getAnswer().contains("已保留 1 轮")); + assertFalse(response.getArtifacts().isEmpty()); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldAnswerToolMetricsThroughUnifiedRuntime() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder metricsRecorder = new AgentToolMetricsRecorder(meterRegistry); + AgentTool readonlyTool = readonlyTool("system.operationLog.list"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentChatOrchestrator firstOrchestrator = orchestrator( + List.of(readonlyTool), + resolver(readonlyTool, Map.of("limit", 3)), + auditService, + new AgentSessionContextStore(), + metricsRecorder + ); + firstOrchestrator.chat(request("session-1", "查最近3条操作日志"), operator()); + + AgentTool metricsTool = new AgentToolMetricsSummaryAgentTool(meterRegistry); + AgentToolRegistry registry = new AgentToolRegistry(List.of(metricsTool)); + AgentChatOrchestrator metricsOrchestrator = new AgentChatOrchestrator( + registry, + new DeterministicAgentPlanResolver(registry), + new AgentPolicyEngine(), + auditService, + objectMapper, + new AgentSessionContextStore() + ); + + AgentChatResponse response = metricsOrchestrator.chat(request("session-2", "智能体工具调用指标怎么样"), operator()); + + assertEquals("answered", response.getStatus()); + assertEquals("system.agent.metrics.summary", response.getToolName()); + assertTrue(response.getAnswer().contains("1 次")); + assertFalse(response.getArtifacts().isEmpty()); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldAnswerToolMetricsHealthThroughUnifiedRuntime() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder metricsRecorder = new AgentToolMetricsRecorder(meterRegistry); + AgentTool readonlyTool = readonlyTool("system.operationLog.list"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentChatOrchestrator firstOrchestrator = orchestrator( + List.of(readonlyTool), + resolver(readonlyTool, Map.of("limit", 3)), + auditService, + new AgentSessionContextStore(), + metricsRecorder + ); + firstOrchestrator.chat(request("session-1", "查最近3条操作日志"), operator()); + + AgentTool metricsTool = new AgentToolMetricsHealthAgentTool(meterRegistry); + AgentToolRegistry registry = new AgentToolRegistry(List.of(metricsTool)); + AgentChatOrchestrator metricsOrchestrator = new AgentChatOrchestrator( + registry, + new DeterministicAgentPlanResolver(registry), + new AgentPolicyEngine(), + auditService, + objectMapper, + new AgentSessionContextStore() + ); + + AgentChatResponse response = metricsOrchestrator.chat(request("session-2", "智能体工具调用健康和告警情况怎么样"), operator()); + + assertEquals("answered", response.getStatus()); + assertEquals("system.agent.metrics.health", response.getToolName()); + assertTrue(response.getAnswer().contains("HEALTHY")); + assertTrue(response.getArtifacts().stream().anyMatch(artifact -> "agentToolMetricsHealth".equals(artifact.getType()))); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldAnswerToolMetricsAlertsThroughUnifiedRuntime() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder metricsRecorder = new AgentToolMetricsRecorder(meterRegistry); + AgentTool failingTool = failingReadonlyTool("system.operationLog.list"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentChatOrchestrator firstOrchestrator = orchestrator( + List.of(failingTool), + resolver(failingTool, Map.of("limit", 3)), + auditService, + new AgentSessionContextStore(), + metricsRecorder + ); + firstOrchestrator.chat(request("session-1", "查最近3条操作日志"), operator()); + + AgentTool metricsTool = new AgentToolMetricsAlertsAgentTool(meterRegistry); + AgentToolRegistry registry = new AgentToolRegistry(List.of(metricsTool)); + AgentChatOrchestrator metricsOrchestrator = new AgentChatOrchestrator( + registry, + new DeterministicAgentPlanResolver(registry), + new AgentPolicyEngine(), + auditService, + objectMapper, + new AgentSessionContextStore() + ); + + AgentChatResponse response = metricsOrchestrator.chat(request("session-2", "智能体工具指标告警规则评估一下"), operator()); + + assertEquals("answered", response.getStatus()); + assertEquals("system.agent.metrics.alerts", response.getToolName()); + assertTrue(response.getAnswer().contains("ALERT")); + assertTrue(response.getArtifacts().stream().anyMatch(artifact -> "agentToolMetricsAlerts".equals(artifact.getType()))); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldAnswerRuntimeObservabilityThroughUnifiedRuntime() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder metricsRecorder = new AgentToolMetricsRecorder(meterRegistry); + AgentTool failingTool = failingReadonlyTool("system.operationLog.list"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentChatOrchestrator firstOrchestrator = orchestrator( + List.of(failingTool), + resolver(failingTool, Map.of("limit", 3)), + auditService, + new AgentSessionContextStore(), + metricsRecorder + ); + firstOrchestrator.chat(request("session-1", "查最近3条操作日志"), operator()); + + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + AgentSessionProperties sessionProperties = new AgentSessionProperties(); + AgentTool observabilityTool = new AgentRuntimeObservabilityAgentTool(catalogService, sessionProperties, meterRegistry); + when(catalogService.definitions()).thenReturn(List.of(observabilityTool.definition(), failingTool.definition())); + AgentToolRegistry registry = new AgentToolRegistry(List.of(observabilityTool)); + AgentChatOrchestrator observabilityOrchestrator = new AgentChatOrchestrator( + registry, + new DeterministicAgentPlanResolver(registry), + new AgentPolicyEngine(), + auditService, + objectMapper, + new AgentSessionContextStore() + ); + + AgentChatResponse response = observabilityOrchestrator.chat(request("session-2", "智能体运行时观测快照和告警总览"), operator()); + + assertEquals("answered", response.getStatus()); + assertEquals("system.agent.runtime.observability", response.getToolName()); + assertTrue(response.getAnswer().contains("ALERT")); + assertTrue(response.getArtifacts().stream().anyMatch(artifact -> "agentRuntimeObservability".equals(artifact.getType()))); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldAnswerAgentAuditListThroughUnifiedRuntime() { + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentAuditResponse audit = audit("audit-1", "system.operationLog.list", "EXECUTED"); + when(auditService.getAuditPage(any())).thenReturn(PageResult.of(1, 5, 1L, List.of(audit))); + AgentTool tool = new AgentAuditListAgentTool(auditService); + AgentToolRegistry registry = new AgentToolRegistry(List.of(tool)); + AgentChatOrchestrator orchestrator = new AgentChatOrchestrator( + registry, + new DeterministicAgentPlanResolver(registry), + new AgentPolicyEngine(), + auditService, + objectMapper, + new AgentSessionContextStore() + ); + + AgentChatResponse response = orchestrator.chat(request("session-1", "查最近5条已执行的智能体审计记录"), operator()); + + assertEquals("answered", response.getStatus()); + assertEquals("system.agent.audit.list", response.getToolName()); + assertTrue(response.getAnswer().contains("最近 1 条")); + assertFalse(response.getArtifacts().isEmpty()); + verify(auditService).getAuditPage(any()); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldAnswerAgentAuditDetailThroughUnifiedRuntime() { + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentAuditResponse audit = audit("agent-audit-1", "chat.userBan.unban", "EXECUTED"); + when(auditService.getByAuditId("agent-audit-1")).thenReturn(audit); + AgentTool tool = new AgentAuditDetailAgentTool(auditService); + AgentToolRegistry registry = new AgentToolRegistry(List.of(tool)); + AgentChatOrchestrator orchestrator = new AgentChatOrchestrator( + registry, + new DeterministicAgentPlanResolver(registry), + new AgentPolicyEngine(), + auditService, + objectMapper, + new AgentSessionContextStore() + ); + + AgentChatResponse response = orchestrator.chat(request("session-1", "查一下 agent-audit-1 的智能体审计详情"), operator()); + + assertEquals("answered", response.getStatus()); + assertEquals("system.agent.audit.detail", response.getToolName()); + assertTrue(response.getAnswer().contains("agent-audit-1")); + assertFalse(response.getArtifacts().isEmpty()); + verify(auditService).getByAuditId("agent-audit-1"); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldExplainFailureRecoveryThroughUnifiedRuntime() { + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("agent-audit-1")).thenReturn(failedAudit("agent-audit-1", "chat.userBan.unban")); + AgentTool tool = new AgentRecoveryExplainAgentTool(auditService, objectMapper); + AgentToolRegistry registry = new AgentToolRegistry(List.of(tool)); + AgentChatOrchestrator orchestrator = new AgentChatOrchestrator( + registry, + new DeterministicAgentPlanResolver(registry), + new AgentPolicyEngine(), + auditService, + objectMapper, + new AgentSessionContextStore() + ); + + AgentChatResponse response = orchestrator.chat(request("session-1", "解释一下 agent-audit-1 的失败恢复上下文"), operator()); + + assertEquals("answered", response.getStatus()); + assertEquals("system.agent.recovery.explain", response.getToolName()); + assertTrue(response.getAnswer().contains("不会自动重试或补偿")); + assertTrue(response.getArtifacts().stream().anyMatch(artifact -> "failureRecovery".equals(artifact.getType()))); + verify(auditService).getByAuditId("agent-audit-1"); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldDryRunFailureRecoveryThroughUnifiedRuntime() { + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("agent-audit-1")).thenReturn(failedAudit("agent-audit-1", "chat.userBan.unban")); + AgentTool tool = new AgentRecoveryDryRunAgentTool(auditService, objectMapper); + AgentToolRegistry registry = new AgentToolRegistry(List.of(tool)); + AgentChatOrchestrator orchestrator = new AgentChatOrchestrator( + registry, + new DeterministicAgentPlanResolver(registry), + new AgentPolicyEngine(), + auditService, + objectMapper, + new AgentSessionContextStore() + ); + + AgentChatResponse response = orchestrator.chat(request("session-1", "预演一下 agent-audit-1 能不能重试或补偿"), operator()); + + assertEquals("answered", response.getStatus()); + assertEquals("system.agent.recovery.dry_run", response.getToolName()); + assertTrue(response.getAnswer().contains("旧失败审计不可重试")); + assertTrue(response.getArtifacts().stream().anyMatch(artifact -> "failureRecoveryDryRun".equals(artifact.getType()))); + verify(auditService).getByAuditId("agent-audit-1"); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldRequireConfirmationForWriteRiskToolAndPersistPreview() { + AgentTool tool = writeTool("chat.userBan.unban"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentAuditResponse audit = audit("audit-1", "chat.userBan.unban", "PREVIEW"); + when(auditService.createPreview(any(), eq(100L), eq("admin"))).thenReturn(audit); + AgentChatOrchestrator orchestrator = orchestrator(List.of(tool), resolver(tool, Map.of("userId", 88)), auditService, new AgentSessionContextStore()); + + AgentChatResponse response = orchestrator.chat(request("session-1", "解除 88 禁言"), operator()); + + assertEquals("confirm_required", response.getStatus()); + assertEquals("audit-1", response.getAuditId()); + assertEquals("chat.userBan.unban", response.getToolName()); + assertEquals("medium", response.getRiskLevel()); + assertEquals("确认解除禁言", response.getConfirmation().getRequiredText()); + assertTrue(response.getAnswer().contains("确认解除禁言")); + verify(auditService).createPreview(any(), eq(100L), eq("admin")); + } + + @Test + void shouldExecuteConfirmedAuditedAction() { + AgentTool tool = writeTool("chat.userBan.unban"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("audit-1")).thenReturn(audit("audit-1", "chat.userBan.unban", "PREVIEW")); + when(auditService.confirm("audit-1")).thenReturn(audit("audit-1", "chat.userBan.unban", "CONFIRMED")); + when(auditService.recordResult(eq("audit-1"), any())).thenReturn(audit("audit-1", "chat.userBan.unban", "EXECUTED")); + AgentChatOrchestrator orchestrator = orchestrator(List.of(tool), emptyResolver(), auditService, new AgentSessionContextStore()); + + AgentChatRequest request = new AgentChatRequest(); + request.setSessionId("session-1"); + request.setAuditId("audit-1"); + request.setConfirmationText("确认解除禁言"); + + AgentChatResponse response = orchestrator.chat(request, operator()); + + assertEquals("executed", response.getStatus()); + assertEquals("audit-1", response.getAuditId()); + assertEquals("chat.userBan.unban", response.getToolName()); + assertEquals("已执行 chat.userBan.unban", response.getAnswer()); + verify(auditService).confirm("audit-1"); + verify(auditService).recordResult(eq("audit-1"), any()); + } + + @Test + void shouldRejectAuditedActionWithoutOperatorOwnership() { + AgentTool tool = writeTool("chat.userBan.unban"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentAuditResponse ownerlessAudit = audit("audit-1", "chat.userBan.unban", "PREVIEW"); + ownerlessAudit.setOperatorId(null); + when(auditService.getByAuditId("audit-1")).thenReturn(ownerlessAudit); + AgentChatOrchestrator orchestrator = orchestrator(List.of(tool), emptyResolver(), auditService, new AgentSessionContextStore()); + + AgentChatRequest request = new AgentChatRequest(); + request.setSessionId("session-1"); + request.setAuditId("audit-1"); + request.setConfirmationText("确认解除禁言"); + + AgentChatResponse response = orchestrator.chat(request, operator()); + + assertEquals("rejected", response.getStatus()); + assertEquals(AgentChatErrorCode.AUDIT_OPERATOR_MISMATCH.code(), response.getErrorCode()); + verify(auditService, never()).confirm("audit-1"); + verify(auditService, never()).recordResult(eq("audit-1"), any()); + } + + @Test + void shouldReplayCompletedAuditedActionWithoutReExecution() { + AgentTool tool = writeTool("chat.userBan.unban"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("audit-1")).thenReturn(executedAudit("audit-1", "chat.userBan.unban")); + AgentChatOrchestrator orchestrator = orchestrator(List.of(tool), emptyResolver(), auditService, new AgentSessionContextStore()); + + AgentChatRequest request = new AgentChatRequest(); + request.setSessionId("session-1"); + request.setAuditId("audit-1"); + request.setConfirmationText("确认解除禁言"); + + AgentChatResponse response = orchestrator.chat(request, operator()); + + assertEquals("executed", response.getStatus()); + assertEquals("audit-1", response.getAuditId()); + assertEquals("chat.userBan.unban", response.getToolName()); + assertEquals("已执行 chat.userBan.unban", response.getAnswer()); + assertTrue(response.getNextActions().contains("这是一次幂等重放,目标工具没有再次执行。")); + assertEquals("audit-1", response.getAuditId()); + verify(auditService, never()).confirm("audit-1"); + verify(auditService, never()).recordResult(eq("audit-1"), any()); + } + + @Test + void shouldReplayCompletedAuditAfterConcurrentConfirmWithoutReExecution() { + AgentTool tool = writeTool("chat.userBan.unban"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("audit-1")) + .thenReturn(audit("audit-1", "chat.userBan.unban", "PREVIEW")) + .thenReturn(executedAudit("audit-1", "chat.userBan.unban")); + when(auditService.confirm("audit-1")).thenThrow(new IllegalStateException("status changed")); + AgentChatOrchestrator orchestrator = orchestrator(List.of(tool), emptyResolver(), auditService, new AgentSessionContextStore()); + + AgentChatRequest request = new AgentChatRequest(); + request.setSessionId("session-1"); + request.setAuditId("audit-1"); + request.setConfirmationText("确认解除禁言"); + + AgentChatResponse response = orchestrator.chat(request, operator()); + + assertEquals("executed", response.getStatus()); + assertEquals("已执行 chat.userBan.unban", response.getAnswer()); + verify(auditService).confirm("audit-1"); + verify(auditService, never()).recordResult(eq("audit-1"), any()); + } + + @Test + void shouldAttachGenericRecoveryAdviceWhenConfirmedWriteToolFails() { + AgentTool tool = failingWriteTool("chat.userBan.unban"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("audit-1")).thenReturn(audit("audit-1", "chat.userBan.unban", "PREVIEW")); + when(auditService.confirm("audit-1")).thenReturn(audit("audit-1", "chat.userBan.unban", "CONFIRMED")); + when(auditService.recordResult(eq("audit-1"), any())).thenReturn(failedAudit("audit-1", "chat.userBan.unban")); + AgentChatOrchestrator orchestrator = orchestrator(List.of(tool), emptyResolver(), auditService, new AgentSessionContextStore()); + + AgentChatRequest request = new AgentChatRequest(); + request.setSessionId("session-1"); + request.setAuditId("audit-1"); + request.setConfirmationText("确认解除禁言"); + + AgentChatResponse response = orchestrator.chat(request, operator()); + + assertEquals("error", response.getStatus()); + assertEquals(AgentChatErrorCode.TOOL_EXECUTION_FAILED.code(), response.getErrorCode()); + assertTrue(response.getNextActions().contains("查询目标对象当前状态,确认是否发生部分写入。")); + assertTrue(response.getNextActions().contains("查看审计详情 audit-1,核对 payload、result 和 errorMessage。")); + assertTrue(response.getNextActions().contains("修复外部依赖、权限或输入数据后,重新发起同一自然语言请求生成新的预览。")); + AgentChatArtifact recovery = failureRecoveryArtifact(response); + assertEquals("失败恢复上下文", recovery.getTitle()); + assertEquals("audit-1", recovery.getData().get("auditId")); + assertEquals("agent-idempotency-audit-1", recovery.getData().get("idempotencyKey")); + assertEquals("chat.userBan.unban", recovery.getData().get("toolName")); + assertEquals("NEW_PREVIEW_REQUIRED", recovery.getData().get("retryPolicy")); + assertEquals(Boolean.FALSE, recovery.getData().get("sameAuditRetryAllowed")); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(AgentAuditResultRequest.class); + verify(auditService).recordResult(eq("audit-1"), resultCaptor.capture()); + String resultJson = resultCaptor.getValue().getResultJson(); + assertTrue(resultJson.contains("查询目标对象当前状态")); + assertTrue(resultJson.contains("查看审计详情 audit-1")); + assertTrue(resultJson.contains("failureRecovery")); + assertTrue(resultJson.contains("NEW_PREVIEW_REQUIRED")); + } + + @Test + void shouldReplayFailedAuditWithGenericRecoveryAdviceWithoutReExecution() { + AgentTool tool = writeTool("chat.userBan.unban"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("audit-1")).thenReturn(failedAudit("audit-1", "chat.userBan.unban")); + AgentChatOrchestrator orchestrator = orchestrator(List.of(tool), emptyResolver(), auditService, new AgentSessionContextStore()); + + AgentChatRequest request = new AgentChatRequest(); + request.setSessionId("session-1"); + request.setAuditId("audit-1"); + request.setConfirmationText("确认解除禁言"); + + AgentChatResponse response = orchestrator.chat(request, operator()); + + assertEquals("error", response.getStatus()); + assertEquals("执行失败 chat.userBan.unban", response.getAnswer()); + assertTrue(response.getNextActions().contains("查询目标对象当前状态,确认是否发生部分写入。")); + assertTrue(response.getNextActions().contains("查看审计详情 audit-1,核对 payload、result 和 errorMessage。")); + assertTrue(response.getNextActions().contains("这是一次幂等重放,目标工具没有再次执行。")); + AgentChatArtifact recovery = failureRecoveryArtifact(response); + assertEquals("audit-1", recovery.getData().get("auditId")); + assertEquals("agent-idempotency-audit-1", recovery.getData().get("idempotencyKey")); + assertEquals("chat.userBan.unban", recovery.getData().get("toolName")); + assertEquals("backend down", recovery.getData().get("errorMessage")); + verify(auditService, never()).confirm("audit-1"); + verify(auditService, never()).recordResult(eq("audit-1"), any()); + } + + @Test + void shouldRejectCompletedAuditCancellationWithoutOverwritingResult() { + AgentTool tool = writeTool("chat.userBan.unban"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("audit-1")).thenReturn(audit("audit-1", "chat.userBan.unban", "EXECUTED")); + AgentChatOrchestrator orchestrator = orchestrator(List.of(tool), emptyResolver(), auditService, new AgentSessionContextStore()); + + AgentChatRequest request = new AgentChatRequest(); + request.setSessionId("session-1"); + request.setAuditId("audit-1"); + request.setMessage("取消"); + + AgentChatResponse response = orchestrator.chat(request, operator()); + + assertEquals("rejected", response.getStatus()); + assertEquals(AgentChatErrorCode.AUDIT_STATUS_NOT_PREVIEW.code(), response.getErrorCode()); + verify(auditService, never()).cancel(eq("audit-1"), any()); + } + + @Test + void shouldRejectConcurrentConfirmWithoutExecutingTool() { + AgentTool tool = writeTool("chat.userBan.unban"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("audit-1")).thenReturn(audit("audit-1", "chat.userBan.unban", "PREVIEW")); + when(auditService.confirm("audit-1")).thenThrow(new IllegalStateException("status changed")); + AgentChatOrchestrator orchestrator = orchestrator(List.of(tool), emptyResolver(), auditService, new AgentSessionContextStore()); + + AgentChatRequest request = new AgentChatRequest(); + request.setSessionId("session-1"); + request.setAuditId("audit-1"); + request.setConfirmationText("确认解除禁言"); + + AgentChatResponse response = orchestrator.chat(request, operator()); + + assertEquals("rejected", response.getStatus()); + assertEquals(AgentChatErrorCode.AUDIT_STATUS_NOT_PREVIEW.code(), response.getErrorCode()); + verify(auditService, never()).recordResult(eq("audit-1"), any()); + } + + @Test + void shouldRejectUnregisteredResolverCandidateBeforePolicyOrAudit() { + AgentTool registeredTool = readonlyTool("system.operationLog.list"); + AgentTool phantomTool = readonlyTool("system.unknown"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentChatOrchestrator orchestrator = orchestrator(List.of(registeredTool), resolver(phantomTool, Map.of()), auditService, new AgentSessionContextStore()); + + AgentChatResponse response = orchestrator.chat(request("session-1", "执行未知工具"), operator()); + + assertEquals("rejected", response.getStatus()); + assertEquals(AgentChatErrorCode.TOOL_NOT_FOUND.code(), response.getErrorCode()); + assertTrue(response.getArtifacts().stream().anyMatch(artifact -> "toolCatalog".equals(artifact.getType()))); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldRejectPlannerClarificationWithoutAudit() { + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentPlanResolver planResolver = new AgentPlanResolver() { + @Override + public Optional resolve(String message) { + return Optional.empty(); + } + + @Override + public AgentPlanResolution resolvePlan(AgentExecutionContext context) { + return AgentPlanResolution.clarification( + "还需要补充这些信息后才能继续:userId", + List.of("请补充字段:userId") + ); + } + }; + AgentChatOrchestrator orchestrator = orchestrator(List.of(readonlyTool("system.operationLog.list")), planResolver, auditService, new AgentSessionContextStore()); + + AgentChatResponse response = orchestrator.chat(request("session-1", "解除禁言"), operator()); + + assertEquals("rejected", response.getStatus()); + assertEquals(AgentChatErrorCode.PLAN_CLARIFICATION_REQUIRED.code(), response.getErrorCode()); + assertTrue(response.getNextActions().contains("请补充字段:userId")); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldRejectPreviewBlockedByTool() { + AgentTool tool = writeTool("chat.userBan.unban", false); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentChatOrchestrator orchestrator = orchestrator(List.of(tool), resolver(tool, Map.of("userId", 88)), auditService, new AgentSessionContextStore()); + + AgentChatResponse response = orchestrator.chat(request("session-1", "解除 88 禁言"), operator()); + + assertEquals("rejected", response.getStatus()); + assertEquals(AgentChatErrorCode.PREVIEW_BLOCKED.code(), response.getErrorCode()); + assertEquals("chat.userBan.unban", response.getToolName()); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + @Test + void shouldRejectMissingOperatorPermissionBeforeAuditOrExecute() { + AgentTool tool = writeTool("chat.userBan.unban"); + tool.definition().getRequiredPermissions().add("agent:chat:user-ban:write"); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentChatOrchestrator orchestrator = orchestrator(List.of(tool), resolver(tool, Map.of("userId", 88)), auditService, new AgentSessionContextStore()); + + AgentChatResponse response = orchestrator.chat( + request("session-1", "解除 88 禁言"), + new AgentOperator(100L, "admin", "", List.of("ADMIN"), List.of("agent:chat:user-ban:read")) + ); + + assertEquals("rejected", response.getStatus()); + assertEquals(AgentChatErrorCode.POLICY_REJECTED.code(), response.getErrorCode()); + assertEquals("chat.userBan.unban", response.getToolName()); + assertTrue(response.getAnswer().contains("缺少权限")); + verify(auditService, never()).createPreview(any(), any(), any()); + } + + private AgentChatOrchestrator orchestrator( + List tools, + AgentPlanResolver planResolver, + SysAgentAuditService auditService, + AgentSessionContextStore sessionContextStore + ) { + return new AgentChatOrchestrator( + new AgentToolRegistry(tools), + planResolver, + new AgentPolicyEngine(), + auditService, + objectMapper, + sessionContextStore + ); + } + + private AgentChatOrchestrator orchestrator( + List tools, + AgentPlanResolver planResolver, + SysAgentAuditService auditService, + AgentSessionContextStore sessionContextStore, + AgentToolMetricsRecorder metricsRecorder + ) { + return new AgentChatOrchestrator( + new AgentToolRegistry(tools), + planResolver, + new AgentPolicyEngine(), + auditService, + objectMapper, + sessionContextStore, + metricsRecorder + ); + } + + private AgentPlanResolver resolver(AgentTool tool, Map input) { + return new AgentPlanResolver() { + @Override + public Optional resolve(String message) { + return Optional.empty(); + } + + @Override + public AgentPlanResolution resolvePlan(AgentExecutionContext context) { + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setSummary("resolved"); + call.setInput(input); + return AgentPlanResolution.resolved(new AgentResolvedToolCall(tool, call)); + } + }; + } + + private AgentPlanResolver emptyResolver() { + return new AgentPlanResolver() { + @Override + public Optional resolve(String message) { + return Optional.empty(); + } + }; + } + + private AgentTool readonlyTool(String name) { + AgentToolDefinition definition = definition(name, "readonly", "READONLY", false); + definition.setInputSchema(Map.of( + "limit", Map.of("type", "integer", "minimum", 1, "maximum", 100) + )); + return tool(definition, true); + } + + private AgentTool failingReadonlyTool(String name) { + AgentToolDefinition definition = definition(name, "readonly", "READONLY", false); + definition.setInputSchema(Map.of( + "limit", Map.of("type", "integer", "minimum", 1, "maximum", 100) + )); + return new AgentTool() { + @Override + public AgentToolDefinition definition() { + return definition; + } + + @Override + public Optional resolve(String message) { + return Optional.empty(); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("只读工具不需要写入预览"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(false); + result.setSummary("执行失败 " + definition.getName()); + result.setErrorMessage("backend down"); + return result; + } + }; + } + + private AgentTool writeTool(String name) { + return writeTool(name, true); + } + + private AgentTool writeTool(String name, boolean executablePreview) { + AgentToolDefinition definition = definition(name, "medium", "WRITE", true); + definition.setConfirmationRequired(true); + definition.setConfirmationText("确认解除禁言"); + definition.setInputSchema(Map.of( + "userId", Map.of("type", "integer", "minimum", 1) + )); + definition.setRequiredInputKeys(List.of("userId")); + return tool(definition, executablePreview); + } + + private AgentTool failingWriteTool(String name) { + AgentToolDefinition definition = definition(name, "medium", "WRITE", true); + definition.setConfirmationRequired(true); + definition.setConfirmationText("确认解除禁言"); + definition.setInputSchema(Map.of( + "userId", Map.of("type", "integer", "minimum", 1) + )); + definition.setRequiredInputKeys(List.of("userId")); + return new AgentTool() { + @Override + public AgentToolDefinition definition() { + return definition; + } + + @Override + public Optional resolve(String message) { + return Optional.empty(); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setExecutable(true); + preview.setSummary("预览 " + definition.getName()); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(false); + result.setSummary("执行失败 " + definition.getName()); + result.setErrorMessage("backend down"); + return result; + } + }; + } + + private AgentTool tool(AgentToolDefinition definition, boolean executablePreview) { + return new AgentTool() { + @Override + public AgentToolDefinition definition() { + return definition; + } + + @Override + public Optional resolve(String message) { + return Optional.empty(); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setExecutable(executablePreview); + preview.setSummary(executablePreview + ? "预览 " + definition.getName() + : "预览已阻止"); + preview.setBlockedReason(executablePreview ? null : "当前状态不允许执行"); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("已执行 " + definition.getName()); + return result; + } + }; + } + + private AgentToolDefinition definition(String name, String riskLevel, String riskCategory, boolean write) { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName(name); + definition.setTitle(name); + definition.setDescription("测试工具"); + definition.setIntent(name); + definition.setRoute("/test/" + name); + definition.setRiskLevel(riskLevel); + definition.setRiskCategory(riskCategory); + definition.setDestructive(write); + return definition; + } + + private AgentToolDefinition readinessDefinition(String name) { + AgentToolDefinition definition = definition(name, "readonly", "READONLY", false); + definition.setRequiredPermissions(List.of("agent:runtime:status:read")); + return definition; + } + + private AgentAuditResponse audit(String auditId, String actionId, String status) { + AgentAuditResponse audit = new AgentAuditResponse(); + audit.setAuditId(auditId); + audit.setActionId(actionId); + audit.setIdempotencyKey("agent-idempotency-" + auditId); + audit.setStatus(status); + audit.setSummary("预览 " + actionId); + audit.setRiskLevel("medium"); + audit.setRiskCategory("WRITE"); + audit.setPayloadJson("{\"userId\":88}"); + audit.setOperatorId(100L); + audit.setOperatorName("admin"); + return audit; + } + + private AgentAuditResponse executedAudit(String auditId, String actionId) { + AgentAuditResponse audit = audit(auditId, actionId, "EXECUTED"); + audit.setResultJson("{\"summary\":\"已执行 " + actionId + "\",\"artifacts\":[],\"nextActions\":[]}"); + return audit; + } + + private AgentAuditResponse failedAudit(String auditId, String actionId) { + AgentAuditResponse audit = audit(auditId, actionId, "FAILED"); + audit.setErrorMessage("backend down"); + audit.setResultJson("{\"summary\":\"执行失败 " + actionId + "\",\"artifacts\":[],\"nextActions\":[]}"); + return audit; + } + + private AgentChatArtifact failureRecoveryArtifact(AgentChatResponse response) { + return response.getArtifacts().stream() + .filter(artifact -> "failureRecovery".equals(artifact.getType())) + .findFirst() + .orElseThrow(); + } + + private RealtimeMonitorResponse lotteryMonitor() { + return RealtimeMonitorResponse.builder() + .systemStatus(RealtimeMonitorResponse.SystemStatus.builder() + .status("运行中") + .activeUsers(3) + .successRate(BigDecimal.valueOf(0.999)) + .build()) + .todayOverview(RealtimeMonitorResponse.TodayOverview.builder() + .totalDrawCount(9) + .totalCostPoints(90L) + .totalRewardPoints(30L) + .actualReturnRate(BigDecimal.valueOf(0.3333)) + .profitPoints(60L) + .profitRate(BigDecimal.valueOf(0.6667)) + .uniqueUserCount(3) + .build()) + .prizeStatusList(List.of()) + .build(); + } + + private AgentChatRequest request(String sessionId, String message) { + AgentChatRequest request = new AgentChatRequest(); + request.setSessionId(sessionId); + request.setMessage(message); + return request; + } + + private AgentOperator operator() { + return new AgentOperator(100L, "admin", "", List.of("ADMIN"), List.of( + "agent:runtime:tool-catalog:read", + "agent:runtime:status:read", + "agent:runtime:readiness:read", + "agent:runtime:session:read", + "agent:runtime:metrics:read", + "agent:runtime:audit:read", + "agent:system:operation-log:read", + "agent:system:operation-log:clean", + "agent:chat:user-ban:read", + "agent:chat:user-ban:write", + "agent:points:lottery:monitor:read" + )); + } + + private void assertCounter(SimpleMeterRegistry meterRegistry, + String toolName, + String phase, + String outcome, + String riskLevel, + String riskCategory) { + Counter counter = meterRegistry.find("xiaou.agent.tool.invocations") + .tag("tool", toolName) + .tag("phase", phase) + .tag("outcome", outcome) + .tag("risk_level", riskLevel) + .tag("risk_category", riskCategory) + .counter(); + assertNotNull(counter); + assertEquals(1D, counter.count()); + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AgentLiveWriteAcceptanceHarness.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentLiveWriteAcceptanceHarness.java new file mode 100644 index 000000000..9a1d1fcec --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentLiveWriteAcceptanceHarness.java @@ -0,0 +1,373 @@ +package com.xiaou.system.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mysql.cj.jdbc.MysqlDataSource; +import com.xiaou.ai.client.AiModelFactory; +import com.xiaou.ai.metrics.AiMetricsRecorder; +import com.xiaou.ai.metrics.AiRuntimeMetricsCollector; +import com.xiaou.ai.support.AiExecutionSupport; +import com.xiaou.common.config.AiProperties; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentChatRequest; +import com.xiaou.system.dto.AgentChatResponse; +import com.xiaou.system.service.SysAgentAuditService; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.io.Resources; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.LocalCacheScope; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.springframework.util.StringUtils; + +import java.io.InputStream; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +final class AgentLiveWriteAcceptanceHarness { + + private static final String ENABLE_ENV = "AGENT_LIVE_WRITE_TEST"; + private static final String BASE_URL_ENV = "XIAOU_AI_BASE_URL"; + private static final String API_KEY_ENV = "XIAOU_AI_API_KEY"; + private static final String MODEL_ENV = "XIAOU_AI_CHAT_MODEL"; + private static final String MYSQL_URL_ENV = "AGENT_TEST_MYSQL_URL"; + private static final String MYSQL_USERNAME_ENV = "AGENT_TEST_MYSQL_USERNAME"; + private static final String MYSQL_PASSWORD_ENV = "AGENT_TEST_MYSQL_PASSWORD"; + private static final String DEFAULT_MODEL = "gpt-5.5"; + private static final String AUDIT_MAPPER = "mapper/SysAgentAuditMapper.xml"; + + private static final String AUDIT_SCHEMA = """ + CREATE TABLE sys_agent_audit ( + id BIGINT NOT NULL AUTO_INCREMENT, + audit_id VARCHAR(80) NOT NULL, + confirmation_id VARCHAR(120) NOT NULL, + idempotency_key VARCHAR(160), + user_message VARCHAR(1000), + intent VARCHAR(120) NOT NULL, + action_id VARCHAR(160) NOT NULL, + route VARCHAR(200), + risk_level VARCHAR(40), + risk_category VARCHAR(80), + status VARCHAR(20) NOT NULL DEFAULT 'PREVIEW', + summary VARCHAR(500), + payload_json TEXT, + diff_json TEXT, + plan_json TEXT, + result_json TEXT, + error_message VARCHAR(1000), + operator_id BIGINT, + operator_name VARCHAR(50), + confirmed_time DATETIME, + executed_time DATETIME, + created_time DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_agent_audit_id (audit_id), + UNIQUE KEY uk_agent_idempotency_key (idempotency_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """; + + private final AcceptanceConfig config; + + private AgentLiveWriteAcceptanceHarness(AcceptanceConfig config) { + this.config = config; + } + + static AgentLiveWriteAcceptanceHarness requireEnabled() { + assumeTrue("true".equalsIgnoreCase(env(ENABLE_ENV)), + "Set AGENT_LIVE_WRITE_TEST=true to run the live write acceptance test"); + AcceptanceConfig config = loadConfig(); + assumeTrue(config.isComplete(), + "Live write acceptance requires AI and isolated MySQL environment variables"); + return new AgentLiveWriteAcceptanceHarness(config); + } + + void withIsolatedDatabase(List schemaStatements, + List mapperResources, + DatabaseScenario scenario) throws Exception { + String databaseName = "code_nest_agent_it_" + + UUID.randomUUID().toString().replace("-", "").substring(0, 12); + String serverUrl = normalizeServerUrl(config.mysqlUrl()); + createDatabase(serverUrl, databaseName); + try { + MysqlDataSource dataSource = dataSource(databaseUrl(serverUrl, databaseName)); + initializeSchema(dataSource, schemaStatements); + SqlSessionFactory sessionFactory = buildSessionFactory(dataSource, mapperResources); + try (SqlSession session = sessionFactory.openSession(true)) { + scenario.run(new DatabaseContext(session, dataSource)); + } + } finally { + dropDatabase(serverUrl, databaseName); + } + } + + AgentChatOrchestrator orchestrator(List tools, SysAgentAuditService auditService) { + AgentToolRegistry registry = new AgentToolRegistry(tools); + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + return orchestrator(registry, strictPlanResolver(registry, meterRegistry), auditService, meterRegistry); + } + + LlmAgentPlanResolver strictPlanResolver(AgentToolRegistry registry, MeterRegistry meterRegistry) { + AiProperties properties = new AiProperties(); + properties.setEnabled(true); + properties.setProvider("openai-compatible"); + properties.setBaseUrl(config.aiBaseUrl()); + properties.setApiKey(config.aiApiKey()); + properties.getModel().setChat(config.aiModel()); + properties.getTimeout().setReadMs(60000); + properties.getRetry().setMaxAttempts(2); + + AiMetricsRecorder metricsRecorder = new AiMetricsRecorder( + meterRegistry, + new AiRuntimeMetricsCollector(), + properties + ); + AiExecutionSupport executionSupport = new AiExecutionSupport( + new AiModelFactory(properties), + metricsRecorder, + properties + ); + return new LlmAgentPlanResolver( + new DeterministicAgentPlanResolver(registry), + registry, + executionSupport, + new ObjectMapper(), + false + ); + } + + AgentChatOrchestrator orchestrator(AgentToolRegistry registry, + AgentPlanResolver planResolver, + SysAgentAuditService auditService, + MeterRegistry meterRegistry) { + return new AgentChatOrchestrator( + registry, + planResolver, + new AgentPolicyEngine(), + auditService, + new ObjectMapper(), + new AgentSessionContextStore(), + new AgentToolMetricsRecorder(meterRegistry) + ); + } + + ConfirmedWriteResult executeConfirmedWrite(AgentChatOrchestrator orchestrator, + SysAgentAuditService auditService, + AgentOperator operator, + String sessionId, + String message, + AgentTool expectedTool, + Runnable previewStateAssertion) { + AgentChatRequest previewRequest = new AgentChatRequest(); + previewRequest.setSessionId(sessionId); + previewRequest.setMessage(message); + AgentChatResponse preview = orchestrator.chat(previewRequest, operator); + + AgentToolDefinition definition = expectedTool.definition(); + assertNotNull(preview); + assertEquals("confirm_required", preview.getStatus(), preview.getErrorMessage()); + assertEquals(definition.getName(), preview.getToolName()); + assertNotNull(preview.getAuditId()); + assertNotNull(preview.getConfirmation()); + assertNotNull(preview.getConfirmation().getRequiredText()); + assertFalse(preview.getConfirmation().getRequiredText().isBlank()); + assertEquals(definition.getConfirmationText(), preview.getConfirmation().getRequiredText()); + AgentAuditResponse previewAudit = auditService.getByAuditId(preview.getAuditId()); + assertNotNull(previewAudit); + assertEquals("PREVIEW", previewAudit.getStatus()); + assertTrace(preview, "audit.preview_created", "done"); + + previewStateAssertion.run(); + + AgentChatRequest confirmRequest = new AgentChatRequest(); + confirmRequest.setSessionId(sessionId); + confirmRequest.setAuditId(preview.getAuditId()); + confirmRequest.setConfirmationText(preview.getConfirmation().getRequiredText()); + AgentChatResponse executed = orchestrator.chat(confirmRequest, operator); + + assertNotNull(executed); + assertEquals("executed", executed.getStatus(), executed.getErrorMessage()); + assertEquals(definition.getName(), executed.getToolName()); + assertEquals(preview.getAuditId(), executed.getAuditId()); + AgentAuditResponse audit = auditService.getByAuditId(preview.getAuditId()); + assertNotNull(audit); + assertEquals("EXECUTED", audit.getStatus()); + assertNotNull(audit.getConfirmedTime()); + assertNotNull(audit.getExecutedTime()); + assertTrace(executed, "audit.confirmed", "done"); + assertTrace(executed, "tool.executed", "done"); + assertTrace(executed, "audit.result_recorded", "done"); + return new ConfirmedWriteResult(preview, executed, audit); + } + + private void initializeSchema(MysqlDataSource dataSource, List schemaStatements) throws Exception { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute(AUDIT_SCHEMA); + for (String schemaStatement : schemaStatements) { + if (StringUtils.hasText(schemaStatement)) { + statement.execute(schemaStatement); + } + } + } + } + + private SqlSessionFactory buildSessionFactory(MysqlDataSource dataSource, + List mapperResources) throws Exception { + Environment environment = new Environment( + "agent-live-write", + new JdbcTransactionFactory(), + dataSource + ); + Configuration configuration = new Configuration(environment); + configuration.setMapUnderscoreToCamelCase(true); + configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); + + Set resources = new LinkedHashSet<>(); + resources.add(AUDIT_MAPPER); + resources.addAll(mapperResources); + for (String resource : resources) { + parseMapper(configuration, resource); + } + return new SqlSessionFactoryBuilder().build(configuration); + } + + private void parseMapper(Configuration configuration, String resource) throws Exception { + try (InputStream input = Resources.getResourceAsStream(resource)) { + new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); + } + } + + private MysqlDataSource dataSource(String url) { + MysqlDataSource dataSource = new MysqlDataSource(); + dataSource.setURL(url); + dataSource.setUser(config.mysqlUsername()); + dataSource.setPassword(config.mysqlPassword()); + return dataSource; + } + + private void createDatabase(String serverUrl, String databaseName) throws Exception { + try (Connection connection = connection(serverUrl); + Statement statement = connection.createStatement()) { + statement.execute("CREATE DATABASE `" + databaseName + + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + } + } + + private void dropDatabase(String serverUrl, String databaseName) throws Exception { + try (Connection connection = connection(serverUrl); + Statement statement = connection.createStatement()) { + statement.execute("DROP DATABASE IF EXISTS `" + databaseName + "`"); + } + } + + private Connection connection(String url) throws Exception { + return java.sql.DriverManager.getConnection(url, config.mysqlUsername(), config.mysqlPassword()); + } + + private void assertTrace(AgentChatResponse response, String stage, String status) { + assertTrue(response.getTrace().stream().anyMatch(step -> + stage.equals(step.getStage()) && status.equals(step.getStatus())), + () -> "Missing trace " + stage + "=" + status + ": " + response.getTrace()); + } + + private static AcceptanceConfig loadConfig() { + String model = env(MODEL_ENV); + return new AcceptanceConfig( + env(BASE_URL_ENV), + env(API_KEY_ENV), + StringUtils.hasText(model) ? model : DEFAULT_MODEL, + env(MYSQL_URL_ENV), + env(MYSQL_USERNAME_ENV), + env(MYSQL_PASSWORD_ENV) + ); + } + + private static String env(String name) { + String value = System.getenv(name); + return value == null ? "" : value.trim(); + } + + private String normalizeServerUrl(String rawUrl) { + String value = rawUrl.trim().replace("jdbc:p6spy:mysql:", "jdbc:mysql:"); + int queryIndex = value.indexOf('?'); + String query = queryIndex >= 0 ? value.substring(queryIndex) : ""; + String base = queryIndex >= 0 ? value.substring(0, queryIndex) : value; + int pathIndex = base.indexOf('/', "jdbc:mysql://".length()); + if (pathIndex < 0) { + return base + "/" + query; + } + return base.substring(0, pathIndex + 1) + query; + } + + private String databaseUrl(String serverUrl, String databaseName) { + int queryIndex = serverUrl.indexOf('?'); + if (queryIndex < 0) { + return serverUrl + databaseName; + } + return serverUrl.substring(0, queryIndex) + databaseName + serverUrl.substring(queryIndex); + } + + @FunctionalInterface + interface DatabaseScenario { + void run(DatabaseContext context) throws Exception; + } + + record DatabaseContext(SqlSession session, MysqlDataSource dataSource) { + + T mapper(Class mapperType) { + return session.getMapper(mapperType); + } + + int queryForInt(String sql, Object... parameters) throws Exception { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + for (int i = 0; i < parameters.length; i++) { + statement.setObject(i + 1, parameters[i]); + } + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "Expected scalar query to return one row"); + return resultSet.getInt(1); + } + } + } + } + + record ConfirmedWriteResult(AgentChatResponse preview, + AgentChatResponse executed, + AgentAuditResponse audit) { + } + + private record AcceptanceConfig(String aiBaseUrl, + String aiApiKey, + String aiModel, + String mysqlUrl, + String mysqlUsername, + String mysqlPassword) { + + private boolean isComplete() { + return StringUtils.hasText(aiBaseUrl) + && StringUtils.hasText(aiApiKey) + && StringUtils.hasText(aiModel) + && StringUtils.hasText(mysqlUrl) + && StringUtils.hasText(mysqlUsername) + && StringUtils.hasText(mysqlPassword); + } + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AgentOperatorTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentOperatorTest.java new file mode 100644 index 000000000..baf32076f --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentOperatorTest.java @@ -0,0 +1,41 @@ +package com.xiaou.system.agent; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AgentOperatorTest { + + @Test + void shouldNormalizeRolesAndPermissions() { + AgentOperator operator = new AgentOperator( + 1L, + "admin", + " tenant-a ", + List.of(" ADMIN ", "", "ADMIN"), + List.of(" agent:read ", "", "agent:read") + ); + + assertEquals("tenant-a", operator.tenantId()); + assertEquals(List.of("ADMIN"), operator.roles()); + assertEquals(List.of("agent:read"), operator.permissions()); + assertTrue(operator.hasRole("ADMIN")); + assertTrue(operator.hasPermission("agent:read")); + assertFalse(operator.hasPermission("agent:write")); + } + + @Test + void shouldKeepBackwardCompatibleConstructor() { + AgentOperator operator = new AgentOperator(1L, "admin"); + + assertEquals(1L, operator.id()); + assertEquals("admin", operator.name()); + assertEquals("", operator.tenantId()); + assertTrue(operator.roles().isEmpty()); + assertTrue(operator.permissions().isEmpty()); + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AgentPermissionSeedContractTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentPermissionSeedContractTest.java new file mode 100644 index 000000000..058847ec5 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentPermissionSeedContractTest.java @@ -0,0 +1,106 @@ +package com.xiaou.system.agent; + +import com.xiaou.chat.service.ChatUserBanService; +import com.xiaou.points.service.LotteryAdminService; +import com.xiaou.system.service.SysAgentAuditService; +import com.xiaou.system.service.SysOperationLogService; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +class AgentPermissionSeedContractTest { + + private static final List PERMISSION_SEED_FILES = List.of( + "sql/MySql/code_nest_data.sql", + "sql/v2.4.0/admin_agent_permissions.sql" + ); + private static final Pattern AGENT_PERMISSION_PATTERN = Pattern.compile("'(agent:[^']+)'"); + private static final String ROLE_GRANT_MARKER = "ON permission_table.`permission_code` IN ("; + + private final SysOperationLogService operationLogService = mock(SysOperationLogService.class); + private final ChatUserBanService chatUserBanService = mock(ChatUserBanService.class); + private final LotteryAdminService lotteryAdminService = mock(LotteryAdminService.class); + private final SysAgentAuditService auditService = mock(SysAgentAuditService.class); + private final AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + + @Test + void builtInAgentToolPermissionsShouldBeSeededAndGrantedToSuperAdmin() throws IOException { + Set requiredPermissions = requiredPermissions(); + Path root = repositoryRoot(); + + for (String seedFile : PERMISSION_SEED_FILES) { + String sql = Files.readString(root.resolve(seedFile), StandardCharsets.UTF_8); + Set seededPermissions = extractAgentPermissions(sql); + Set grantedPermissions = extractSuperAdminGrantPermissions(sql); + + assertTrue(seededPermissions.containsAll(requiredPermissions), + seedFile + " missing sys_permission seed(s): " + missing(requiredPermissions, seededPermissions)); + assertTrue(grantedPermissions.containsAll(requiredPermissions), + seedFile + " missing SUPER_ADMIN grant seed(s): " + missing(requiredPermissions, grantedPermissions)); + } + } + + private Set requiredPermissions() { + Set permissions = new LinkedHashSet<>(); + for (AgentTool tool : AgentToolTestCatalog.builtInTools( + operationLogService, + chatUserBanService, + lotteryAdminService, + auditService, + catalogService + )) { + AgentToolDefinition definition = tool.definition(); + if (definition.getRequiredPermissions() != null) { + permissions.addAll(definition.getRequiredPermissions()); + } + } + return permissions; + } + + private Set extractAgentPermissions(String sql) { + Matcher matcher = AGENT_PERMISSION_PATTERN.matcher(sql); + Set permissions = new LinkedHashSet<>(); + while (matcher.find()) { + permissions.add(matcher.group(1)); + } + return permissions; + } + + private Set extractSuperAdminGrantPermissions(String sql) { + int markerIndex = sql.indexOf(ROLE_GRANT_MARKER); + if (markerIndex < 0) { + return Set.of(); + } + int whereIndex = sql.indexOf("WHERE role_table.`role_code` = 'SUPER_ADMIN'", markerIndex); + String grantBlock = whereIndex < 0 ? sql.substring(markerIndex) : sql.substring(markerIndex, whereIndex); + return extractAgentPermissions(grantBlock); + } + + private Set missing(Set required, Set actual) { + Set missing = new LinkedHashSet<>(required); + missing.removeAll(actual); + return missing; + } + + private Path repositoryRoot() { + Path current = Path.of("").toAbsolutePath(); + while (current != null && !Files.exists(current.resolve("sql/MySql/code_nest_data.sql"))) { + current = current.getParent(); + } + if (current == null) { + throw new IllegalStateException("Cannot locate repository root from " + Path.of("").toAbsolutePath()); + } + return current; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AgentPolicyEngineTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentPolicyEngineTest.java new file mode 100644 index 000000000..97f64467b --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentPolicyEngineTest.java @@ -0,0 +1,191 @@ +package com.xiaou.system.agent; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AgentPolicyEngineTest { + + private final AgentPolicyEngine policyEngine = new AgentPolicyEngine(); + + @Test + void shouldAllowReadonlyToolWithoutConfirmation() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.readonly"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + + AgentPolicyDecision decision = policyEngine.evaluate(definition, Map.of()); + + assertTrue(decision.isAllowed()); + assertFalse(decision.isConfirmationRequired()); + } + + @Test + void shouldRejectWhenOperatorMissesRequiredPermission() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.secured"); + definition.getRequiredPermissions().add("agent:system:read"); + + AgentPolicyDecision decision = policyEngine.evaluate( + definition, + Map.of(), + new AgentOperator(1L, "admin", "", List.of("ADMIN"), List.of("system:read")) + ); + + assertFalse(decision.isAllowed()); + assertEquals(AgentChatErrorCode.POLICY_REJECTED, decision.getErrorCode()); + assertTrue(decision.getRejectionReason().contains("缺少权限")); + } + + @Test + void shouldAllowWhenOperatorHasRequiredPermissionAndAnyRequiredRole() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.secured"); + definition.getRequiredPermissions().add("agent:system:read"); + definition.getRequiredRoles().add("SUPER_ADMIN"); + definition.getRequiredRoles().add("ADMIN"); + + AgentPolicyDecision decision = policyEngine.evaluate( + definition, + Map.of(), + new AgentOperator(1L, "admin", "", List.of("ADMIN"), List.of("agent:system:read")) + ); + + assertTrue(decision.isAllowed()); + assertFalse(decision.isConfirmationRequired()); + } + + @Test + void shouldRejectWhenOperatorHasNoneOfRequiredRoles() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.secured"); + definition.getRequiredRoles().add("SUPER_ADMIN"); + definition.getRequiredRoles().add("AUDITOR"); + + AgentPolicyDecision decision = policyEngine.evaluate( + definition, + Map.of(), + new AgentOperator(1L, "admin", "", List.of("ADMIN"), List.of()) + ); + + assertFalse(decision.isAllowed()); + assertTrue(decision.getRejectionReason().contains("缺少角色")); + } + + @Test + void shouldRejectCrossTenantInputWhenSameTenantScopeIsRequired() { + AgentToolDefinition definition = readonlyDefinition(Map.of( + "tenantId", Map.of("type", "string") + )); + definition.setTenantScope("SAME_TENANT"); + + AgentPolicyDecision decision = policyEngine.evaluate( + definition, + Map.of("tenantId", "tenant-b"), + new AgentOperator(1L, "admin", "tenant-a", List.of("ADMIN"), List.of()) + ); + + assertFalse(decision.isAllowed()); + assertTrue(decision.getRejectionReason().contains("租户")); + } + + @Test + void shouldRequireConfirmationForWriteRiskEvenWhenFlagIsMissing() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.write"); + definition.setRiskLevel("medium"); + definition.setRiskCategory("WRITE"); + definition.setConfirmationRequired(false); + definition.setConfirmationText("确认写入"); + + AgentPolicyDecision decision = policyEngine.evaluate(definition, Map.of()); + + assertTrue(decision.isAllowed()); + assertTrue(decision.isConfirmationRequired()); + assertEquals("确认写入", decision.getConfirmationText()); + } + + @Test + void shouldRejectWriteRiskWithoutConfirmationText() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.write"); + definition.setRiskLevel("medium"); + definition.setRiskCategory("WRITE"); + + AgentPolicyDecision decision = policyEngine.evaluate(definition, Map.of()); + + assertFalse(decision.isAllowed()); + assertEquals(AgentChatErrorCode.POLICY_REJECTED, decision.getErrorCode()); + assertTrue(decision.getRejectionReason().contains("确认文本")); + } + + @Test + void shouldRejectInputFieldsNotDeclaredInSchema() { + AgentToolDefinition definition = readonlyDefinition(Map.of( + "userId", Map.of("type", "integer") + )); + + AgentPolicyDecision decision = policyEngine.evaluate(definition, Map.of( + "userId", 88, + "sql", "drop table sys_user" + )); + + assertFalse(decision.isAllowed()); + assertEquals(AgentChatErrorCode.SCHEMA_VALIDATION_FAILED, decision.getErrorCode()); + assertTrue(decision.getRejectionReason().contains("未声明字段")); + } + + @Test + void shouldRejectStringShorterThanMinimumLength() { + AgentToolDefinition definition = readonlyDefinition(Map.of( + "reason", Map.of("type", "string", "minLength", 3) + )); + + AgentPolicyDecision decision = policyEngine.evaluate(definition, Map.of("reason", "no")); + + assertFalse(decision.isAllowed()); + assertEquals(AgentChatErrorCode.SCHEMA_VALIDATION_FAILED, decision.getErrorCode()); + assertTrue(decision.getRejectionReason().contains("长度必须大于等于 3")); + } + + @Test + void shouldRejectStringLongerThanMaximumLength() { + AgentToolDefinition definition = readonlyDefinition(Map.of( + "reason", Map.of("type", "string", "maxLength", 5) + )); + + AgentPolicyDecision decision = policyEngine.evaluate(definition, Map.of("reason", "too-long")); + + assertFalse(decision.isAllowed()); + assertEquals(AgentChatErrorCode.SCHEMA_VALIDATION_FAILED, decision.getErrorCode()); + assertTrue(decision.getRejectionReason().contains("长度必须小于等于 5")); + } + + @Test + void shouldRejectValueOutsideEnum() { + AgentToolDefinition definition = readonlyDefinition(Map.of( + "status", Map.of("type", "string", "enum", List.of("active", "disabled")) + )); + + AgentPolicyDecision decision = policyEngine.evaluate(definition, Map.of("status", "deleted")); + + assertFalse(decision.isAllowed()); + assertEquals(AgentChatErrorCode.SCHEMA_VALIDATION_FAILED, decision.getErrorCode()); + assertTrue(decision.getRejectionReason().contains("不在允许范围")); + } + + private AgentToolDefinition readonlyDefinition(Map inputSchema) { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.schemaTest"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(inputSchema); + return definition; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AgentRuntimeAllToolsUnifiedEntryTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentRuntimeAllToolsUnifiedEntryTest.java new file mode 100644 index 000000000..466f34324 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentRuntimeAllToolsUnifiedEntryTest.java @@ -0,0 +1,235 @@ +package com.xiaou.system.agent; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.ai.prompt.admin.AdminAgentPromptSpecs; +import com.xiaou.ai.support.AiExecutionSupport; +import com.xiaou.chat.service.ChatUserBanService; +import com.xiaou.points.service.LotteryAdminService; +import com.xiaou.system.dto.AgentAuditPreviewRequest; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentChatRequest; +import com.xiaou.system.dto.AgentChatResponse; +import com.xiaou.system.service.SysAgentAuditService; +import com.xiaou.system.service.SysOperationLogService; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentRuntimeAllToolsUnifiedEntryTest { + + private static final String FIXTURE_PATH = "/agent/admin-agent-planner-regression-cases.json"; + private static final TypeReference> CASE_LIST_TYPE = new TypeReference<>() { + }; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void shouldRouteEveryRegisteredToolThroughUnifiedChatRuntime() throws IOException { + AgentToolRegistry registry = probeRegistry(); + List resolvedCases = loadCases().stream() + .filter(testCase -> "RESOLVED".equals(testCase.expectedStatus)) + .toList(); + Map firstCaseByTool = firstCaseByTool(resolvedCases); + + for (AgentToolDefinition definition : registry.definitions()) { + PlannerRegressionCase testCase = firstCaseByTool.get(definition.getName()); + assertNotNull(testCase, "missing unified chat runtime fixture for tool: " + definition.getName()); + + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.createPreview( + org.mockito.ArgumentMatchers.any(AgentAuditPreviewRequest.class), + eq(100L), + eq("admin") + )).thenAnswer(invocation -> previewAudit(invocation.getArgument(0))); + + AgentChatOrchestrator orchestrator = new AgentChatOrchestrator( + registry, + llmResolver(registry, testCase), + new AgentPolicyEngine(), + auditService, + objectMapper, + new AgentSessionContextStore() + ); + + AgentChatResponse response = orchestrator.chat(request(testCase.message), operator(registry.definitions())); + + assertEquals(definition.getName(), response.getToolName(), testCase.id); + assertEquals(definition.getRiskLevel(), response.getRiskLevel(), testCase.id); + assertEquals(definition.getRiskCategory(), response.getRiskCategory(), testCase.id); + assertNotNull(response.getTraceId(), testCase.id); + assertFalse(response.getTrace().isEmpty(), testCase.id); + if (requiresConfirmation(definition)) { + assertEquals("confirm_required", response.getStatus(), testCase.id); + assertNotNull(response.getAuditId(), testCase.id); + assertNotNull(response.getConfirmation(), testCase.id); + } else { + assertEquals("answered", response.getStatus(), testCase.id); + assertTrue(response.getAnswer().contains(definition.getName()), testCase.id); + } + } + } + + private AgentToolRegistry probeRegistry() { + SysOperationLogService operationLogService = mock(SysOperationLogService.class); + ChatUserBanService chatUserBanService = mock(ChatUserBanService.class); + LotteryAdminService lotteryAdminService = mock(LotteryAdminService.class); + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + List realTools = AgentToolTestCatalog.builtInTools( + operationLogService, + chatUserBanService, + lotteryAdminService, + auditService, + catalogService + ); + return new AgentToolRegistry(realTools.stream() + .map(ProbeAgentTool::new) + .collect(Collectors.toList())); + } + + private LlmAgentPlanResolver llmResolver(AgentToolRegistry registry, PlannerRegressionCase testCase) { + AiExecutionSupport aiExecutionSupport = mock(AiExecutionSupport.class); + when(aiExecutionSupport.chatWithFallback( + eq("admin.agent.plan"), + eq(AdminAgentPromptSpecs.PLAN), + org.mockito.ArgumentMatchers.>any(), + org.mockito.ArgumentMatchers.>any(), + org.mockito.ArgumentMatchers.>any() + )).thenAnswer(invocation -> { + Function parser = invocation.getArgument(3); + return parser.apply(testCase.aiResponse); + }); + return new LlmAgentPlanResolver( + new DeterministicAgentPlanResolver(registry), + registry, + aiExecutionSupport, + objectMapper + ); + } + + private Map firstCaseByTool(List cases) { + Map result = new LinkedHashMap<>(); + for (PlannerRegressionCase testCase : cases) { + result.putIfAbsent(testCase.expectedToolName, testCase); + } + return result; + } + + private List loadCases() throws IOException { + InputStream inputStream = getClass().getResourceAsStream(FIXTURE_PATH); + assertNotNull(inputStream, "Missing fixture: " + FIXTURE_PATH); + try (inputStream) { + return objectMapper.readValue(inputStream, CASE_LIST_TYPE); + } + } + + private AgentChatRequest request(String message) { + AgentChatRequest request = new AgentChatRequest(); + request.setSessionId("all-tools-unified-entry"); + request.setMessage(message); + return request; + } + + private AgentOperator operator(List definitions) { + Set permissions = new LinkedHashSet<>(); + Set roles = new LinkedHashSet<>(List.of("ADMIN", "SUPER_ADMIN")); + for (AgentToolDefinition definition : definitions) { + if (definition.getRequiredPermissions() != null) { + permissions.addAll(definition.getRequiredPermissions()); + } + if (definition.getRequiredRoles() != null) { + roles.addAll(definition.getRequiredRoles()); + } + } + return new AgentOperator(100L, "admin", "tenant-1", new ArrayList<>(roles), new ArrayList<>(permissions)); + } + + private AgentAuditResponse previewAudit(AgentAuditPreviewRequest request) { + AgentAuditResponse audit = new AgentAuditResponse(); + audit.setAuditId("audit-" + request.getActionId()); + audit.setConfirmationId(request.getConfirmationId()); + audit.setIdempotencyKey(request.getIdempotencyKey()); + audit.setActionId(request.getActionId()); + audit.setRiskLevel(request.getRiskLevel()); + audit.setRiskCategory(request.getRiskCategory()); + audit.setSummary(request.getSummary()); + audit.setStatus("PREVIEW"); + audit.setOperatorId(100L); + audit.setOperatorName("admin"); + return audit; + } + + private boolean requiresConfirmation(AgentToolDefinition definition) { + if (definition.isConfirmationRequired() || definition.isDestructive()) { + return true; + } + String riskLevel = definition.getRiskLevel() == null ? "" : definition.getRiskLevel().trim(); + String riskCategory = definition.getRiskCategory() == null ? "" : definition.getRiskCategory().trim(); + return !"readonly".equalsIgnoreCase(riskLevel) || !"READONLY".equalsIgnoreCase(riskCategory); + } + + private static final class ProbeAgentTool implements AgentTool { + + private final AgentTool delegate; + + private ProbeAgentTool(AgentTool delegate) { + this.delegate = delegate; + } + + @Override + public AgentToolDefinition definition() { + return delegate.definition(); + } + + @Override + public Optional resolve(String message) { + return Optional.empty(); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + AgentToolPreview preview = new AgentToolPreview(); + preview.setExecutable(true); + preview.setSummary("统一入口预览 " + definition().getName()); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("统一入口执行 " + definition().getName()); + return result; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static class PlannerRegressionCase { + public String id; + public String message; + public String aiResponse; + public String expectedStatus; + public String expectedToolName; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AgentRuntimeLiveAiSmokeTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentRuntimeLiveAiSmokeTest.java new file mode 100644 index 000000000..1d59d1259 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentRuntimeLiveAiSmokeTest.java @@ -0,0 +1,249 @@ +package com.xiaou.system.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.ai.client.AiModelFactory; +import com.xiaou.ai.metrics.AiMetricsRecorder; +import com.xiaou.ai.metrics.AiRuntimeMetricsCollector; +import com.xiaou.ai.support.AiExecutionSupport; +import com.xiaou.common.config.AiProperties; +import com.xiaou.chat.service.ChatUserBanService; +import com.xiaou.points.service.LotteryAdminService; +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.dto.AgentChatRequest; +import com.xiaou.system.dto.AgentChatResponse; +import com.xiaou.system.service.SysAgentAuditService; +import com.xiaou.system.service.SysOperationLogService; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentRuntimeLiveAiSmokeTest { + + private static final String ENABLE_ENV = "AGENT_LIVE_AI_TEST"; + private static final String BASE_URL_ENV = "XIAOU_AI_BASE_URL"; + private static final String API_KEY_ENV = "XIAOU_AI_API_KEY"; + private static final String MODEL_ENV = "XIAOU_AI_CHAT_MODEL"; + private static final String DEFAULT_MODEL = "gpt-5.5"; + + @Test + void shouldResolveToolCatalogWithLiveOpenAiCompatiblePlanner() { + assumeTrue(isEnabled(), "Set AGENT_LIVE_AI_TEST=true to run the live AI smoke test"); + + LiveAiConfig config = loadConfig(); + assumeTrue(StringUtils.hasText(config.baseUrl()), "Set XIAOU_AI_BASE_URL for the live AI smoke test"); + assumeTrue(StringUtils.hasText(config.apiKey()), "Set XIAOU_AI_API_KEY for the live AI smoke test"); + + AgentToolRegistry registry = new AgentToolRegistry(List.of(llmOnlyToolCatalogTool())); + AiProperties properties = aiProperties(config); + AiMetricsRecorder metricsRecorder = new AiMetricsRecorder( + new SimpleMeterRegistry(), + new AiRuntimeMetricsCollector(), + properties + ); + AiExecutionSupport aiExecutionSupport = new AiExecutionSupport( + new AiModelFactory(properties), + metricsRecorder, + properties + ); + LlmAgentPlanResolver resolver = new LlmAgentPlanResolver( + new DeterministicAgentPlanResolver(registry), + registry, + aiExecutionSupport, + new ObjectMapper(), + false + ); + + AgentPlanResolution resolution = resolver.resolvePlan("管理员想知道后台智能体现在有哪些可用工具,请选择工具目录查询能力。"); + + assertTrue(resolution.isResolved(), () -> "Live planner should resolve a registered backend tool, got: " + + resolution.getMessage()); + assertEquals("system.agent.tools.list", resolution.getResolvedCall().call().getToolName()); + assertEquals(Map.of(), resolution.getResolvedCall().call().getInput()); + } + + @Test + void shouldExecuteRealReadonlyToolsThroughLiveUnifiedChatRuntime() { + assumeTrue(isEnabled(), "Set AGENT_LIVE_AI_TEST=true to run the live AI smoke test"); + + LiveAiConfig config = loadConfig(); + assumeTrue(StringUtils.hasText(config.baseUrl()), "Set XIAOU_AI_BASE_URL for the live AI smoke test"); + assumeTrue(StringUtils.hasText(config.apiKey()), "Set XIAOU_AI_API_KEY for the live AI smoke test"); + + LiveRuntime runtime = liveRuntime(config); + + AgentChatResponse catalogResponse = runtime.orchestrator().chat( + request("live-agent-catalog", "请返回当前后端可调用能力的完整注册描述,重点给出名称、风险和输入 schema。"), + runtime.operator() + ); + AgentChatArtifact catalogArtifact = assertExecuted( + catalogResponse, + "system.agent.tools.list", + "agentToolCatalog" + ); + Object tools = catalogArtifact.getData().get("tools"); + assertTrue(tools instanceof List && ((List) tools).size() == 26, + () -> "Real catalog tool should return all registered tools, got: " + tools); + + AgentChatResponse statusResponse = runtime.orchestrator().chat( + request("live-agent-status", "请给出后端执行引擎当前是否可用,并报告注册能力总数。"), + runtime.operator() + ); + AgentChatArtifact statusArtifact = assertExecuted( + statusResponse, + "system.agent.runtime.status", + "agentRuntimeStatus" + ); + assertEquals("UP", statusArtifact.getData().get("status")); + assertEquals(26, statusArtifact.getData().get("toolCount")); + } + + private LiveRuntime liveRuntime(LiveAiConfig config) { + @SuppressWarnings("unchecked") + ObjectProvider registryProvider = mock(ObjectProvider.class); + AgentToolCatalogService catalogService = new AgentToolCatalogService(registryProvider); + AgentSessionProperties sessionProperties = new AgentSessionProperties(); + + AgentToolRegistry registry = new AgentToolRegistry(AgentToolTestCatalog.builtInTools( + mock(SysOperationLogService.class), + mock(ChatUserBanService.class), + mock(LotteryAdminService.class), + mock(SysAgentAuditService.class), + catalogService + )); + when(registryProvider.getIfAvailable()).thenReturn(registry); + + AiProperties properties = aiProperties(config); + AiMetricsRecorder metricsRecorder = new AiMetricsRecorder( + new SimpleMeterRegistry(), + new AiRuntimeMetricsCollector(), + properties + ); + AiExecutionSupport aiExecutionSupport = new AiExecutionSupport( + new AiModelFactory(properties), + metricsRecorder, + properties + ); + LlmAgentPlanResolver resolver = new LlmAgentPlanResolver( + new DeterministicAgentPlanResolver(registry), + registry, + aiExecutionSupport, + new ObjectMapper(), + false + ); + AgentChatOrchestrator orchestrator = new AgentChatOrchestrator( + registry, + resolver, + new AgentPolicyEngine(), + mock(SysAgentAuditService.class), + new ObjectMapper(), + new AgentSessionContextStore() + ); + AgentOperator operator = new AgentOperator( + 100L, + "live-test-admin", + "live-test-tenant", + List.of("ADMIN", "SUPER_ADMIN"), + List.of("agent:runtime:tool-catalog:read", "agent:runtime:status:read") + ); + return new LiveRuntime(orchestrator, operator); + } + + private AgentChatRequest request(String sessionId, String message) { + AgentChatRequest request = new AgentChatRequest(); + request.setSessionId(sessionId); + request.setMessage(message); + return request; + } + + private AgentChatArtifact assertExecuted(AgentChatResponse response, + String expectedToolName, + String expectedArtifactType) { + assertNotNull(response); + assertEquals("answered", response.getStatus(), response.getErrorMessage()); + assertEquals(expectedToolName, response.getToolName()); + assertTrue(response.getTrace().stream().anyMatch(step -> + "tool.executed".equals(step.getStage()) && "done".equals(step.getStatus())), + () -> "Real tool execute trace is missing: " + response.getTrace()); + assertFalse(response.getArtifacts().isEmpty(), "Real tool should return structured artifacts"); + return response.getArtifacts().stream() + .filter(artifact -> expectedArtifactType.equals(artifact.getType())) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Expected real artifact " + expectedArtifactType + ", got: " + response.getArtifacts() + )); + } + + private boolean isEnabled() { + return "true".equalsIgnoreCase(env(ENABLE_ENV)); + } + + private LiveAiConfig loadConfig() { + return new LiveAiConfig( + env(BASE_URL_ENV), + env(API_KEY_ENV), + StringUtils.hasText(env(MODEL_ENV)) ? env(MODEL_ENV) : DEFAULT_MODEL + ); + } + + private AiProperties aiProperties(LiveAiConfig config) { + AiProperties properties = new AiProperties(); + properties.setEnabled(true); + properties.setProvider("openai-compatible"); + properties.setBaseUrl(config.baseUrl()); + properties.setApiKey(config.apiKey()); + properties.getModel().setChat(config.model()); + properties.getTimeout().setReadMs(60000); + properties.getRetry().setMaxAttempts(2); + return properties; + } + + private AgentTool llmOnlyToolCatalogTool() { + return new AgentTool() { + @Override + public AgentToolDefinition definition() { + return AgentToolDefinitionBuilder.readonly("system.agent.tools.list", "查询智能体工具目录") + .description("列出当前后端统一智能体已注册工具及其输入、风险和访问声明。") + .route("/admin/agent/chat") + .permission("agent:runtime:tool-catalog:read") + .build(); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + return new AgentToolPreview(); + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("live smoke catalog"); + return result; + } + }; + } + + private String env(String name) { + String value = System.getenv(name); + return value == null ? "" : value.trim(); + } + + private record LiveAiConfig(String baseUrl, String apiKey, String model) { + } + + private record LiveRuntime(AgentChatOrchestrator orchestrator, AgentOperator operator) { + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AgentRuntimeLiveWriteAcceptanceTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentRuntimeLiveWriteAcceptanceTest.java new file mode 100644 index 000000000..f8df551b0 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentRuntimeLiveWriteAcceptanceTest.java @@ -0,0 +1,584 @@ +package com.xiaou.system.agent; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.chat.domain.ChatUserBan; +import com.xiaou.chat.mapper.ChatRoomMapper; +import com.xiaou.chat.mapper.ChatUserBanMapper; +import com.xiaou.chat.service.ChatRoomService; +import com.xiaou.chat.service.ChatUserBanService; +import com.xiaou.chat.service.impl.ChatRoomServiceImpl; +import com.xiaou.chat.service.impl.ChatUserBanServiceImpl; +import com.xiaou.points.domain.LotteryPrizeConfig; +import com.xiaou.points.domain.LotteryStatisticsDaily; +import com.xiaou.points.mapper.LotteryAdjustHistoryMapper; +import com.xiaou.points.mapper.LotteryDrawRecordMapper; +import com.xiaou.points.mapper.LotteryPrizeConfigMapper; +import com.xiaou.points.mapper.LotteryStatisticsDailyMapper; +import com.xiaou.points.mapper.UserLotteryLimitMapper; +import com.xiaou.points.service.LotteryAdminService; +import com.xiaou.points.service.LotteryNormalizeService; +import com.xiaou.points.service.LotteryStockService; +import com.xiaou.points.service.impl.LotteryAdminServiceImpl; +import com.xiaou.points.service.impl.LotteryNormalizeServiceImpl; +import com.xiaou.points.service.impl.LotteryStockServiceImpl; +import com.xiaou.system.agent.AgentLiveWriteAcceptanceHarness.DatabaseContext; +import com.xiaou.system.domain.SysAgentAudit; +import com.xiaou.system.domain.SysOperationLog; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentChatRequest; +import com.xiaou.system.dto.AgentChatResponse; +import com.xiaou.system.mapper.SysAgentAuditMapper; +import com.xiaou.system.mapper.SysOperationLogMapper; +import com.xiaou.system.service.SysAgentAuditService; +import com.xiaou.system.service.SysOperationLogService; +import com.xiaou.system.service.impl.SysAgentAuditServiceImpl; +import com.xiaou.system.service.impl.SysOperationLogServiceImpl; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AgentRuntimeLiveWriteAcceptanceTest { + + private static final int OPERATION_LOG_RETENTION_DAYS = 30; + private static final long LIVE_USER_ID = 88L; + private static final String FAILED_AUDIT_ID = "agent-audit-1"; + private static final String FIXTURE_PATH = "/agent/admin-agent-planner-regression-cases.json"; + private static final TypeReference> CASE_LIST_TYPE = new TypeReference<>() { + }; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void shouldRouteAndExecuteEveryRegisteredToolWithLiveAiAndRealBackendServices() throws Exception { + AgentLiveWriteAcceptanceHarness harness = AgentLiveWriteAcceptanceHarness.requireEnabled(); + harness.withIsolatedDatabase(schemaStatements(), mapperResources(), context -> { + RealServices services = realServices(context); + SeededFixtures fixtures = seedFixtures(context, services); + + try (LiveRuntime runtime = liveRuntime(harness, services)) { + List scenarios = resolvedScenarios(); + Set registeredTools = runtime.registry().definitions().stream() + .map(AgentToolDefinition::getName) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + Set coveredTools = scenarios.stream() + .map(testCase -> testCase.expectedToolName) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + + assertEquals(26, registeredTools.size(), "Live acceptance must use the complete production tool registry"); + assertEquals(registeredTools, coveredTools, "Every registered production tool needs one live scenario"); + + for (PlannerRegressionCase scenario : scenarios) { + AgentChatResponse response = runtime.orchestrator().chat( + request("live-all-" + scenario.id, scenario.message), + runtime.operator() + ); + assertEquals(scenario.expectedToolName, response.getToolName(), diagnostics(scenario, response)); + + AgentToolDefinition definition = runtime.registry().find(scenario.expectedToolName) + .orElseThrow() + .definition(); + if (requiresConfirmation(definition)) { + confirmAndAssertWrite(runtime, services, fixtures, scenario, response); + } else { + assertEquals("answered", response.getStatus(), diagnostics(scenario, response)); + assertTrue(hasTrace(response, "tool.executed", "done"), diagnostics(scenario, response)); + assertFalse(response.getArtifacts().isEmpty(), diagnostics(scenario, response)); + } + } + + AgentChatResponse clarification = runtime.orchestrator().chat( + request("live-missing-required-input", "请解除当前用户的禁言,但我没有提供用户编号。"), + runtime.operator() + ); + assertEquals("rejected", clarification.getStatus(), clarification.getAnswer()); + assertEquals(AgentChatErrorCode.PLAN_CLARIFICATION_REQUIRED.code(), clarification.getErrorCode()); + + assertNull(services.chatUserBanService().getActiveBan(LIVE_USER_ID)); + assertNull(services.operationLogMapper().selectById(fixtures.expiredOperationLog().getId())); + assertNotNull(services.operationLogMapper().selectById(fixtures.recentOperationLog().getId())); + } + }); + } + + private LiveRuntime liveRuntime(AgentLiveWriteAcceptanceHarness harness, RealServices services) { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AnnotationConfigApplicationContext spring = new AnnotationConfigApplicationContext(); + spring.registerBean(ObjectMapper.class, () -> objectMapper); + spring.registerBean(MeterRegistry.class, () -> meterRegistry); + spring.registerBean(SysAgentAuditService.class, services::auditService); + spring.registerBean(SysOperationLogService.class, services::operationLogService); + spring.registerBean(ChatUserBanService.class, services::chatUserBanService); + spring.registerBean(LotteryAdminService.class, services::lotteryAdminService); + spring.registerBean(AgentSessionProperties.class, AgentSessionProperties::new); + spring.registerBean(AgentPolicyEngine.class, AgentPolicyEngine::new); + spring.registerBean(AgentToolCatalogService.class); + spring.registerBean(AgentToolRegistry.class); + spring.scan("com.xiaou.system.agent.tools"); + spring.refresh(); + + AgentToolRegistry registry = spring.getBean(AgentToolRegistry.class); + LlmAgentPlanResolver strictResolver = harness.strictPlanResolver(registry, meterRegistry); + spring.getBeanFactory().registerSingleton("liveAgentPlanResolver", strictResolver); + assertSame(strictResolver, spring.getBeanProvider(AgentPlanResolver.class).getIfAvailable()); + + AgentChatOrchestrator orchestrator = harness.orchestrator( + registry, + strictResolver, + services.auditService(), + meterRegistry + ); + return new LiveRuntime(orchestrator, registry, operator(registry), spring); + } + + private RealServices realServices(DatabaseContext context) { + ChatRoomMapper roomMapper = context.mapper(ChatRoomMapper.class); + ChatUserBanMapper banMapper = context.mapper(ChatUserBanMapper.class); + SysOperationLogMapper operationLogMapper = context.mapper(SysOperationLogMapper.class); + SysAgentAuditMapper auditMapper = context.mapper(SysAgentAuditMapper.class); + + ChatRoomService roomService = new ChatRoomServiceImpl(roomMapper); + ChatUserBanService chatUserBanService = new ChatUserBanServiceImpl(banMapper, roomService); + SysOperationLogService operationLogService = new SysOperationLogServiceImpl(operationLogMapper); + SysAgentAuditService auditService = new SysAgentAuditServiceImpl(auditMapper); + + LotteryPrizeConfigMapper prizeConfigMapper = context.mapper(LotteryPrizeConfigMapper.class); + LotteryDrawRecordMapper drawRecordMapper = context.mapper(LotteryDrawRecordMapper.class); + LotteryStatisticsDailyMapper statisticsMapper = context.mapper(LotteryStatisticsDailyMapper.class); + LotteryAdjustHistoryMapper adjustHistoryMapper = context.mapper(LotteryAdjustHistoryMapper.class); + UserLotteryLimitMapper userLimitMapper = context.mapper(UserLotteryLimitMapper.class); + LotteryNormalizeService normalizeService = new LotteryNormalizeServiceImpl(prizeConfigMapper); + LotteryStockService stockService = new LotteryStockServiceImpl(prizeConfigMapper, null); + LotteryAdminService lotteryAdminService = new LotteryAdminServiceImpl( + prizeConfigMapper, + drawRecordMapper, + statisticsMapper, + adjustHistoryMapper, + userLimitMapper, + normalizeService, + stockService + ); + + return new RealServices( + auditService, + operationLogService, + chatUserBanService, + lotteryAdminService, + auditMapper, + operationLogMapper, + banMapper, + prizeConfigMapper, + statisticsMapper + ); + } + + private SeededFixtures seedFixtures(DatabaseContext context, RealServices services) throws Exception { + ChatUserBan seededBan = seedActiveBan(services.chatUserBanMapper(), LIVE_USER_ID); + SysOperationLog expiredLog = seedOperationLog( + services.operationLogMapper(), + "expired", + LocalDateTime.now().minusDays(OPERATION_LOG_RETENTION_DAYS + 15L) + ); + SysOperationLog recentLog = seedOperationLog( + services.operationLogMapper(), + "recent", + LocalDateTime.now().minusDays(2) + ); + seedFailedAudit(services.auditMapper()); + seedLottery(services.prizeConfigMapper(), services.statisticsMapper()); + + assertEquals(1, context.queryForInt("SELECT COUNT(*) FROM chat_user_bans WHERE id = ?", seededBan.getId())); + return new SeededFixtures(seededBan, expiredLog, recentLog); + } + + private void confirmAndAssertWrite(LiveRuntime runtime, + RealServices services, + SeededFixtures fixtures, + PlannerRegressionCase scenario, + AgentChatResponse preview) { + assertEquals("confirm_required", preview.getStatus(), diagnostics(scenario, preview)); + assertNotNull(preview.getAuditId()); + assertNotNull(preview.getConfirmation()); + assertNotNull(preview.getConfirmation().getRequiredText()); + + if ("chat.userBan.unban".equals(scenario.expectedToolName)) { + assertNotNull(services.chatUserBanService().getActiveBan(LIVE_USER_ID)); + } else if ("system.operationLog.cleanExpired".equals(scenario.expectedToolName)) { + assertNotNull(services.operationLogMapper().selectById(fixtures.expiredOperationLog().getId())); + assertNotNull(services.operationLogMapper().selectById(fixtures.recentOperationLog().getId())); + } + + AgentChatRequest confirmation = new AgentChatRequest(); + confirmation.setSessionId("live-confirm-" + scenario.id); + confirmation.setAuditId(preview.getAuditId()); + confirmation.setConfirmationText(preview.getConfirmation().getRequiredText()); + AgentChatResponse executed = runtime.orchestrator().chat(confirmation, runtime.operator()); + + assertEquals("executed", executed.getStatus(), diagnostics(scenario, executed)); + assertEquals(scenario.expectedToolName, executed.getToolName()); + assertTrue(hasTrace(executed, "audit.confirmed", "done")); + assertTrue(hasTrace(executed, "tool.executed", "done")); + assertTrue(hasTrace(executed, "audit.result_recorded", "done")); + AgentAuditResponse audit = services.auditService().getByAuditId(preview.getAuditId()); + assertNotNull(audit); + assertEquals("EXECUTED", audit.getStatus()); + } + + private AgentOperator operator(AgentToolRegistry registry) { + Set permissions = new LinkedHashSet<>(); + Set roles = new LinkedHashSet<>(List.of("ADMIN", "SUPER_ADMIN")); + for (AgentToolDefinition definition : registry.definitions()) { + if (definition.getRequiredPermissions() != null) { + permissions.addAll(definition.getRequiredPermissions()); + } + if (definition.getRequiredRoles() != null) { + roles.addAll(definition.getRequiredRoles()); + } + } + return new AgentOperator( + 100L, + "live-all-tools-admin", + "live-test-tenant", + new ArrayList<>(roles), + new ArrayList<>(permissions) + ); + } + + private List resolvedScenarios() throws IOException { + Map firstByTool = new LinkedHashMap<>(); + for (PlannerRegressionCase testCase : loadCases()) { + if ("RESOLVED".equals(testCase.expectedStatus)) { + firstByTool.putIfAbsent(testCase.expectedToolName, testCase); + } + } + return new ArrayList<>(firstByTool.values()); + } + + private List loadCases() throws IOException { + InputStream inputStream = getClass().getResourceAsStream(FIXTURE_PATH); + assertNotNull(inputStream, "Missing fixture: " + FIXTURE_PATH); + try (inputStream) { + return objectMapper.readValue(inputStream, CASE_LIST_TYPE); + } + } + + private AgentChatRequest request(String sessionId, String message) { + AgentChatRequest request = new AgentChatRequest(); + request.setSessionId(sessionId); + request.setMessage(message); + return request; + } + + private boolean requiresConfirmation(AgentToolDefinition definition) { + return definition.isConfirmationRequired() + || definition.isDestructive() + || !"readonly".equalsIgnoreCase(definition.getRiskLevel()) + || !"READONLY".equalsIgnoreCase(definition.getRiskCategory()); + } + + private boolean hasTrace(AgentChatResponse response, String stage, String status) { + return response.getTrace().stream().anyMatch(step -> + stage.equals(step.getStage()) && status.equals(step.getStatus())); + } + + private String diagnostics(PlannerRegressionCase scenario, AgentChatResponse response) { + return scenario.id + ": status=" + response.getStatus() + + ", tool=" + response.getToolName() + + ", error=" + response.getErrorCode() + + ", answer=" + response.getAnswer(); + } + + private ChatUserBan seedActiveBan(ChatUserBanMapper mapper, long userId) { + ChatUserBan ban = new ChatUserBan(); + ban.setUserId(userId); + ban.setRoomId(1L); + ban.setBanReason("agent live all-tools acceptance fixture"); + ban.setBanStartTime(new java.util.Date()); + ban.setBanEndTime(null); + ban.setOperatorId(100L); + ban.setStatus(1); + assertEquals(1, mapper.insert(ban)); + assertNotNull(ban.getId()); + return ban; + } + + private SysOperationLog seedOperationLog(SysOperationLogMapper mapper, + String fixtureType, + LocalDateTime operationTime) { + SysOperationLog operationLog = new SysOperationLog(); + operationLog.setOperationId("agent-live-" + fixtureType + "-" + UUID.randomUUID()); + operationLog.setModule("agent-live-all-tools"); + operationLog.setOperationType("DELETE"); + operationLog.setDescription("isolated " + fixtureType + " operation log fixture"); + operationLog.setMethod("AgentRuntimeLiveWriteAcceptanceTest"); + operationLog.setRequestUri("/test/agent/live-all-tools"); + operationLog.setRequestMethod("POST"); + operationLog.setOperatorId(100L); + operationLog.setOperatorName("live-all-tools-admin"); + operationLog.setStatus(0); + operationLog.setOperationTime(operationTime); + operationLog.setCostTime(1L); + assertEquals(1, mapper.insert(operationLog)); + assertNotNull(operationLog.getId()); + return operationLog; + } + + private void seedFailedAudit(SysAgentAuditMapper mapper) { + LocalDateTime now = LocalDateTime.now(); + SysAgentAudit audit = new SysAgentAudit(); + audit.setAuditId(FAILED_AUDIT_ID); + audit.setConfirmationId("failed-confirmation-1"); + audit.setIdempotencyKey("failed-idempotency-1"); + audit.setUserMessage("历史失败写入请求"); + audit.setIntent("chat.userBan.unban"); + audit.setActionId("chat.userBan.unban"); + audit.setRoute("/admin/agent/chat"); + audit.setRiskLevel("medium"); + audit.setRiskCategory("WRITE"); + audit.setStatus("FAILED"); + audit.setSummary("历史写入执行失败"); + audit.setPayloadJson("{\"userId\":999}"); + audit.setDiffJson("[]"); + audit.setPlanJson("[]"); + audit.setResultJson("{\"summary\":\"历史写入执行失败\",\"artifacts\":[],\"nextActions\":[]}"); + audit.setErrorMessage("isolated backend failure"); + audit.setOperatorId(100L); + audit.setOperatorName("live-all-tools-admin"); + audit.setConfirmedTime(now.minusMinutes(1)); + audit.setExecutedTime(now); + audit.setCreatedTime(now.minusMinutes(2)); + audit.setUpdatedTime(now); + assertEquals(1, mapper.insert(audit)); + } + + private void seedLottery(LotteryPrizeConfigMapper prizeMapper, + LotteryStatisticsDailyMapper statisticsMapper) { + LotteryStatisticsDaily statistics = new LotteryStatisticsDaily(); + statistics.setStatDate(LocalDate.now()); + statistics.setTotalDrawCount(9); + statistics.setTotalCostPoints(90L); + statistics.setTotalRewardPoints(30L); + statistics.setPlatformProfitPoints(60L); + statistics.setActualReturnRate(new BigDecimal("0.3333")); + statistics.setUniqueUserCount(3); + statistics.setSpecialPrizeCount(0); + statistics.setFirstPrizeCount(0); + statistics.setSecondPrizeCount(0); + statistics.setThirdPrizeCount(0); + statistics.setFourthPrizeCount(0); + statistics.setFifthPrizeCount(0); + statistics.setSixthPrizeCount(0); + statistics.setNoPrizeCount(9); + assertEquals(1, statisticsMapper.insert(statistics)); + + LotteryPrizeConfig prize = new LotteryPrizeConfig(); + prize.setPrizeName("Live acceptance prize"); + prize.setPrizeLevel(8); + prize.setPrizePoints(0); + prize.setBaseProbability(BigDecimal.ONE); + prize.setCurrentProbability(BigDecimal.ONE); + prize.setTargetReturnRate(BigDecimal.ZERO); + prize.setMaxReturnRate(BigDecimal.ONE); + prize.setMinReturnRate(BigDecimal.ZERO); + prize.setDailyStock(-1); + prize.setTotalStock(-1); + prize.setCurrentStock(-1); + prize.setDisplayOrder(1); + prize.setPrizeIcon("live"); + prize.setPrizeDesc("isolated live acceptance fixture"); + prize.setAdjustStrategy("FIXED"); + prize.setIsActive(1); + assertEquals(1, prizeMapper.insert(prize)); + } + + private List mapperResources() { + return List.of( + "mapper/ChatRoomMapper.xml", + "mapper/ChatUserBanMapper.xml", + "mapper/SysOperationLogMapper.xml", + "mapper/LotteryPrizeConfigMapper.xml", + "mapper/LotteryDrawRecordMapper.xml", + "mapper/LotteryStatisticsDailyMapper.xml", + "mapper/LotteryAdjustHistoryMapper.xml", + "mapper/UserLotteryLimitMapper.xml" + ); + } + + private List schemaStatements() { + return List.of( + """ + CREATE TABLE chat_rooms ( + id BIGINT NOT NULL AUTO_INCREMENT, + room_name VARCHAR(100) NOT NULL, + room_type TINYINT DEFAULT 1, + description VARCHAR(500), + max_users INT DEFAULT 0, + status TINYINT DEFAULT 1, + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """, + """ + CREATE TABLE chat_user_bans ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + room_id BIGINT NOT NULL, + ban_reason VARCHAR(500), + ban_start_time DATETIME DEFAULT CURRENT_TIMESTAMP, + ban_end_time DATETIME NULL, + operator_id BIGINT, + status TINYINT DEFAULT 1, + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_user_room_status (user_id, room_id, status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """, + """ + CREATE TABLE sys_operation_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + operation_id VARCHAR(64) NOT NULL, + module VARCHAR(50), + operation_type VARCHAR(20), + description VARCHAR(500), + method VARCHAR(200), + request_uri VARCHAR(500), + request_method VARCHAR(10), + request_params TEXT, + response_data TEXT, + operator_id BIGINT, + operator_name VARCHAR(50), + operator_ip VARCHAR(128), + operation_location VARCHAR(255), + browser VARCHAR(50), + os VARCHAR(50), + status TINYINT DEFAULT 0, + error_msg VARCHAR(2000), + operation_time DATETIME DEFAULT CURRENT_TIMESTAMP, + cost_time BIGINT DEFAULT 0, + PRIMARY KEY (id), + INDEX idx_operation_time (operation_time) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """, + """ + CREATE TABLE lottery_prize_config ( + id BIGINT NOT NULL AUTO_INCREMENT, + prize_name VARCHAR(100) NOT NULL, + prize_level INT NOT NULL, + prize_points INT NOT NULL DEFAULT 0, + base_probability DECIMAL(12,8) NOT NULL DEFAULT 0, + current_probability DECIMAL(12,8) NOT NULL DEFAULT 0, + target_return_rate DECIMAL(12,8) DEFAULT 0, + max_return_rate DECIMAL(12,8) DEFAULT 1, + min_return_rate DECIMAL(12,8) DEFAULT 0, + actual_return_rate DECIMAL(12,8) DEFAULT 0, + total_draw_count INT DEFAULT 0, + total_win_count INT DEFAULT 0, + today_draw_count INT DEFAULT 0, + today_win_count INT DEFAULT 0, + daily_stock INT DEFAULT -1, + total_stock INT DEFAULT -1, + current_stock INT DEFAULT -1, + display_order INT DEFAULT 0, + prize_icon VARCHAR(255), + prize_desc VARCHAR(500), + is_active TINYINT DEFAULT 1, + is_suspended TINYINT DEFAULT 0, + suspend_reason VARCHAR(500), + suspend_until DATETIME NULL, + adjust_strategy VARCHAR(30), + last_adjust_time DATETIME NULL, + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """, + """ + CREATE TABLE lottery_statistics_daily ( + id BIGINT NOT NULL AUTO_INCREMENT, + stat_date DATE NOT NULL, + total_draw_count INT DEFAULT 0, + total_cost_points BIGINT DEFAULT 0, + total_reward_points BIGINT DEFAULT 0, + profit_points BIGINT DEFAULT 0, + actual_return_rate DECIMAL(12,8) DEFAULT 0, + unique_user_count INT DEFAULT 0, + special_prize_count INT DEFAULT 0, + first_prize_count INT DEFAULT 0, + second_prize_count INT DEFAULT 0, + third_prize_count INT DEFAULT 0, + fourth_prize_count INT DEFAULT 0, + fifth_prize_count INT DEFAULT 0, + sixth_prize_count INT DEFAULT 0, + no_prize_count INT DEFAULT 0, + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_stat_date (stat_date) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """, + """ + INSERT INTO chat_rooms (id, room_name, room_type, description, max_users, status) + VALUES (1, 'Agent acceptance room', 1, 'isolated live all-tools test', 0, 1) + """ + ); + } + + private record LiveRuntime(AgentChatOrchestrator orchestrator, + AgentToolRegistry registry, + AgentOperator operator, + AnnotationConfigApplicationContext spring) implements AutoCloseable { + + @Override + public void close() { + spring.close(); + } + } + + private record RealServices(SysAgentAuditService auditService, + SysOperationLogService operationLogService, + ChatUserBanService chatUserBanService, + LotteryAdminService lotteryAdminService, + SysAgentAuditMapper auditMapper, + SysOperationLogMapper operationLogMapper, + ChatUserBanMapper chatUserBanMapper, + LotteryPrizeConfigMapper prizeConfigMapper, + LotteryStatisticsDailyMapper statisticsMapper) { + } + + private record SeededFixtures(ChatUserBan seededBan, + SysOperationLog expiredOperationLog, + SysOperationLog recentOperationLog) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static class PlannerRegressionCase { + public String id; + public String message; + public String expectedStatus; + public String expectedToolName; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AgentSessionContextStoreTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentSessionContextStoreTest.java new file mode 100644 index 000000000..eee89e0e1 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentSessionContextStoreTest.java @@ -0,0 +1,135 @@ +package com.xiaou.system.agent; + +import com.xiaou.system.dto.AgentChatRequest; +import com.xiaou.system.dto.AgentChatResponse; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AgentSessionContextStoreTest { + + @Test + void shouldRecordRecentTurnsBySessionId() { + AgentSessionContextStore store = new AgentSessionContextStore(); + + store.record(request("session-1", "查最近3条操作日志"), + response("answered", "system.operationLog.list", "已查询到最近 3 条操作日志。")); + + AgentSessionSnapshot snapshot = store.snapshot("session-1"); + + assertEquals("session-1", snapshot.getSessionId()); + assertEquals(1, snapshot.getRecentTurns().size()); + assertEquals("查最近3条操作日志", snapshot.getRecentTurns().get(0).getMessage()); + assertEquals("answered", snapshot.getRecentTurns().get(0).getStatus()); + assertEquals("system.operationLog.list", snapshot.getRecentTurns().get(0).getToolName()); + } + + @Test + void shouldKeepSessionsIsolated() { + AgentSessionContextStore store = new AgentSessionContextStore(); + + store.record(request("session-a", "查日志"), response("answered", "system.operationLog.list", "日志结果")); + store.record(request("session-b", "查禁言"), response("answered", "chat.userBan.active", "禁言结果")); + + assertEquals("system.operationLog.list", store.snapshot("session-a").getRecentTurns().get(0).getToolName()); + assertEquals("chat.userBan.active", store.snapshot("session-b").getRecentTurns().get(0).getToolName()); + } + + @Test + void shouldScopeSameSessionIdByOperator() { + AgentSessionContextStore store = new AgentSessionContextStore(); + + store.record(request("shared-session", "管理员一的请求"), + response("answered", "system.operationLog.list", "管理员一的结果"), 100L); + store.record(request("shared-session", "管理员二的请求"), + response("answered", "chat.userBan.active", "管理员二的结果"), 200L); + + AgentSessionSnapshot firstOperator = store.snapshot("shared-session", 100L); + AgentSessionSnapshot secondOperator = store.snapshot("shared-session", 200L); + + assertEquals("shared-session", firstOperator.getSessionId()); + assertEquals(1, firstOperator.getRecentTurns().size()); + assertEquals("system.operationLog.list", firstOperator.getRecentTurns().get(0).getToolName()); + assertEquals(1, secondOperator.getRecentTurns().size()); + assertEquals("chat.userBan.active", secondOperator.getRecentTurns().get(0).getToolName()); + } + + @Test + void shouldIgnoreBlankSessionId() { + AgentSessionContextStore store = new AgentSessionContextStore(); + + store.record(request(" ", "查日志"), response("answered", "system.operationLog.list", "日志结果")); + + assertTrue(store.snapshot(" ").getRecentTurns().isEmpty()); + } + + @Test + void shouldKeepOnlyRecentTurns() { + AgentSessionContextStore store = new AgentSessionContextStore(); + + for (int i = 1; i <= 8; i++) { + store.record(request("session-1", "message-" + i), response("answered", "tool-" + i, "answer-" + i)); + } + + AgentSessionSnapshot snapshot = store.snapshot("session-1"); + + assertEquals(6, snapshot.getRecentTurns().size()); + assertEquals("message-3", snapshot.getRecentTurns().get(0).getMessage()); + assertEquals("message-8", snapshot.getRecentTurns().get(5).getMessage()); + } + + @Test + void shouldDelegateTurnsToReplaceableRepository() { + CapturingSessionContextRepository repository = new CapturingSessionContextRepository(); + AgentSessionContextStore store = new AgentSessionContextStore(repository); + + store.record(request(" session-1 ", "查日志"), response("answered", "system.operationLog.list", "日志结果")); + + assertEquals("session-1", repository.sessionId); + assertEquals(6, repository.maxRecentTurns); + assertEquals("查日志", repository.turns.get(0).getMessage()); + assertEquals("system.operationLog.list", repository.turns.get(0).getToolName()); + assertEquals(1, store.snapshot("session-1").getRecentTurns().size()); + } + + private AgentChatRequest request(String sessionId, String message) { + AgentChatRequest request = new AgentChatRequest(); + request.setSessionId(sessionId); + request.setMessage(message); + return request; + } + + private AgentChatResponse response(String status, String toolName, String answer) { + AgentChatResponse response = new AgentChatResponse(); + response.setStatus(status); + response.setToolName(toolName); + response.setAnswer(answer); + response.setTraceId("agent-trace-test"); + return response; + } + + private static class CapturingSessionContextRepository implements AgentSessionContextRepository { + private String sessionId; + private int maxRecentTurns; + private final List turns = new ArrayList<>(); + + @Override + public AgentSessionSnapshot snapshot(String sessionId) { + AgentSessionSnapshot snapshot = new AgentSessionSnapshot(); + snapshot.setSessionId(sessionId); + snapshot.setRecentTurns(new ArrayList<>(turns)); + return snapshot; + } + + @Override + public void appendTurn(String sessionId, AgentSessionTurn turn, int maxRecentTurns) { + this.sessionId = sessionId; + this.maxRecentTurns = maxRecentTurns; + this.turns.add(turn); + } + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolDefinitionBuilderTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolDefinitionBuilderTest.java new file mode 100644 index 000000000..55d6032b7 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolDefinitionBuilderTest.java @@ -0,0 +1,54 @@ +package com.xiaou.system.agent; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AgentToolDefinitionBuilderTest { + + @Test + void readonlyBuilderShouldFillGenericReadonlyDefaults() { + AgentToolDefinition definition = AgentToolDefinitionBuilder.readonly("demo.user.lookup", "查询用户") + .description("按用户ID查询用户摘要。") + .route("/admin/demo/users") + .permission("agent:demo:user:read") + .input("userId", Map.of("type", "integer", "minimum", 1), true) + .build(); + + assertEquals("demo.user.lookup", definition.getName()); + assertEquals("查询用户", definition.getTitle()); + assertEquals("按用户ID查询用户摘要。", definition.getDescription()); + assertEquals("demo.user.lookup", definition.getIntent()); + assertEquals("/admin/demo/users", definition.getRoute()); + assertEquals("readonly", definition.getRiskLevel()); + assertEquals("READONLY", definition.getRiskCategory()); + assertFalse(definition.isDestructive()); + assertFalse(definition.isConfirmationRequired()); + assertEquals(List.of("agent:demo:user:read"), definition.getRequiredPermissions()); + assertEquals(List.of("userId"), definition.getRequiredInputKeys()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void writeBuilderShouldRequireConfirmationByDefault() { + AgentToolDefinition definition = AgentToolDefinitionBuilder.write("demo.user.disable", "停用用户") + .description("停用指定用户。") + .route("/admin/demo/users/disable") + .permission("agent:demo:user:write") + .confirmationText("确认停用用户") + .input("userId", Map.of("type", "integer", "minimum", 1), true) + .build(); + + assertEquals("medium", definition.getRiskLevel()); + assertEquals("WRITE", definition.getRiskCategory()); + assertTrue(definition.isDestructive()); + assertTrue(definition.isConfirmationRequired()); + assertEquals("确认停用用户", definition.getConfirmationText()); + assertEquals(List.of("agent:demo:user:write"), definition.getRequiredPermissions()); + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolDefinitionContractTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolDefinitionContractTest.java new file mode 100644 index 000000000..8f3a30cd8 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolDefinitionContractTest.java @@ -0,0 +1,117 @@ +package com.xiaou.system.agent; + +import com.xiaou.chat.service.ChatUserBanService; +import com.xiaou.points.service.LotteryAdminService; +import com.xiaou.system.service.SysAgentAuditService; +import com.xiaou.system.service.SysOperationLogService; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +class AgentToolDefinitionContractTest { + + private final SysOperationLogService operationLogService = mock(SysOperationLogService.class); + private final ChatUserBanService chatUserBanService = mock(ChatUserBanService.class); + private final LotteryAdminService lotteryAdminService = mock(LotteryAdminService.class); + private final SysAgentAuditService auditService = mock(SysAgentAuditService.class); + private final AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + + @Test + void builtInAgentToolsShouldFollowGenericDefinitionContract() { + List tools = AgentToolTestCatalog.builtInTools( + operationLogService, + chatUserBanService, + lotteryAdminService, + auditService, + catalogService + ); + + for (AgentTool tool : tools) { + AgentToolDefinition definition = tool.definition(); + assertText(definition.getName(), definition.getName() + ".name"); + assertText(definition.getTitle(), definition.getName() + ".title"); + assertText(definition.getDescription(), definition.getName() + ".description"); + assertText(definition.getIntent(), definition.getName() + ".intent"); + assertText(definition.getRoute(), definition.getName() + ".route"); + assertText(definition.getRiskLevel(), definition.getName() + ".riskLevel"); + assertText(definition.getRiskCategory(), definition.getName() + ".riskCategory"); + assertTrue(definition.getRoute().startsWith("/"), definition.getName() + " route must be an admin route path"); + assertTrue(Set.of("ANY", "SAME_TENANT").contains(definition.getTenantScope()), + definition.getName() + " tenantScope must be ANY or SAME_TENANT"); + + assertSchemaContract(definition); + assertAccessContract(definition); + assertConfirmationContract(definition); + } + } + + @Test + void builtInAgentToolsShouldBeAcceptedByRegistry() { + List tools = AgentToolTestCatalog.builtInTools( + operationLogService, + chatUserBanService, + lotteryAdminService, + auditService, + catalogService + ); + + AgentToolRegistry registry = new AgentToolRegistry(tools); + + assertTrue(registry.definitions().size() >= 4); + } + + private void assertSchemaContract(AgentToolDefinition definition) { + Map schema = definition.getInputSchema() == null ? Map.of() : definition.getInputSchema(); + for (String requiredKey : definition.getRequiredInputKeys()) { + assertText(requiredKey, definition.getName() + ".requiredInputKeys"); + assertTrue(schema.containsKey(requiredKey), + definition.getName() + " requiredInputKey is missing from inputSchema: " + requiredKey); + } + for (Map.Entry entry : schema.entrySet()) { + assertText(entry.getKey(), definition.getName() + ".inputSchema key"); + assertTrue(entry.getValue() instanceof Map, + definition.getName() + " inputSchema field must be an object: " + entry.getKey()); + Object type = ((Map) entry.getValue()).get("type"); + assertTrue(type instanceof String && hasText((String) type), + definition.getName() + " inputSchema field must declare type: " + entry.getKey()); + } + } + + private void assertAccessContract(AgentToolDefinition definition) { + assertFalse(definition.getRequiredPermissions().isEmpty(), + definition.getName() + " must declare at least one agent permission"); + for (String permission : definition.getRequiredPermissions()) { + assertTrue(hasText(permission) && permission.startsWith("agent:"), + definition.getName() + " permission must use agent:* namespace: " + permission); + } + } + + private void assertConfirmationContract(AgentToolDefinition definition) { + boolean readonly = "READONLY".equals(definition.getRiskCategory()) + && "readonly".equalsIgnoreCase(definition.getRiskLevel()) + && !definition.isDestructive(); + if (readonly) { + assertFalse(definition.isConfirmationRequired(), + definition.getName() + " readonly tool should not require confirmation"); + return; + } + + assertTrue(definition.isConfirmationRequired(), + definition.getName() + " write/destructive tool must require confirmation"); + assertText(definition.getConfirmationText(), definition.getName() + ".confirmationText"); + } + + private void assertText(String value, String label) { + assertTrue(hasText(value), label + " must not be blank"); + } + + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolMetricsRecorderTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolMetricsRecorderTest.java new file mode 100644 index 000000000..3ff8db994 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolMetricsRecorderTest.java @@ -0,0 +1,75 @@ +package com.xiaou.system.agent; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AgentToolMetricsRecorderTest { + + @Test + void shouldRecordToolInvocationDurationAndCount() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder recorder = new AgentToolMetricsRecorder(meterRegistry); + AgentToolMetricSample sample = sample("system.operationLog.list", "execute", "success", 12_000_000L); + + recorder.recordInvocation(sample); + + Timer timer = meterRegistry.find("xiaou.agent.tool.duration") + .tag("tool", "system.operationLog.list") + .tag("phase", "execute") + .tag("outcome", "success") + .tag("risk_level", "readonly") + .tag("risk_category", "READONLY") + .timer(); + Counter counter = meterRegistry.find("xiaou.agent.tool.invocations") + .tag("tool", "system.operationLog.list") + .tag("phase", "execute") + .tag("outcome", "success") + .tag("risk_level", "readonly") + .tag("risk_category", "READONLY") + .counter(); + + assertNotNull(timer); + assertNotNull(counter); + assertEquals(1, timer.count()); + assertTrue(timer.totalTime(TimeUnit.MILLISECONDS) >= 12); + assertEquals(1D, counter.count()); + } + + @Test + void shouldNotUseHighCardinalityTraceOrSessionAsMeterTags() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder recorder = new AgentToolMetricsRecorder(meterRegistry); + AgentToolMetricSample sample = sample("chat.userBan.unban", "preview", "blocked", 1_000_000L); + sample.setTraceId("agent-trace-123"); + sample.setSessionId("session-456"); + + recorder.recordInvocation(sample); + + assertNull(meterRegistry.find("xiaou.agent.tool.duration") + .tag("trace_id", "agent-trace-123") + .timer()); + assertNull(meterRegistry.find("xiaou.agent.tool.duration") + .tag("session_id", "session-456") + .timer()); + } + + private AgentToolMetricSample sample(String toolName, String phase, String outcome, long durationNanos) { + AgentToolMetricSample sample = new AgentToolMetricSample(); + sample.setToolName(toolName); + sample.setPhase(phase); + sample.setOutcome(outcome); + sample.setRiskLevel("readonly"); + sample.setRiskCategory("READONLY"); + sample.setDurationNanos(durationNanos); + return sample; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolRegistryTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolRegistryTest.java new file mode 100644 index 000000000..1fa41695c --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolRegistryTest.java @@ -0,0 +1,45 @@ +package com.xiaou.system.agent; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class AgentToolRegistryTest { + + @Test + void shouldRejectDuplicateToolNames() { + AgentTool first = tool("system.duplicate"); + AgentTool second = tool("system.duplicate"); + + assertThrows(IllegalStateException.class, () -> new AgentToolRegistry(List.of(first, second))); + } + + private AgentTool tool(String name) { + return new AgentTool() { + @Override + public AgentToolDefinition definition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName(name); + return definition; + } + + @Override + public Optional resolve(String message) { + return Optional.empty(); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + return new AgentToolPreview(); + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + return new AgentToolResult(); + } + }; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolTestCatalog.java b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolTestCatalog.java new file mode 100644 index 000000000..de7bc501d --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/AgentToolTestCatalog.java @@ -0,0 +1,92 @@ +package com.xiaou.system.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.chat.service.ChatUserBanService; +import com.xiaou.points.service.LotteryAdminService; +import com.xiaou.system.agent.tools.AgentAuditDetailAgentTool; +import com.xiaou.system.agent.tools.AgentToolCatalogAgentTool; +import com.xiaou.system.agent.tools.AgentToolDetailAgentTool; +import com.xiaou.system.agent.tools.AgentAuditListAgentTool; +import com.xiaou.system.agent.tools.AgentOperatorSelfAgentTool; +import com.xiaou.system.agent.tools.AgentPlannerDryRunAgentTool; +import com.xiaou.system.agent.tools.AgentPolicyDryRunAgentTool; +import com.xiaou.system.agent.tools.AgentPlannerDiagnosticsAgentTool; +import com.xiaou.system.agent.tools.AgentRequestDryRunAgentTool; +import com.xiaou.system.agent.tools.AgentRuntimeObservabilityAgentTool; +import com.xiaou.system.agent.tools.AgentRuntimeReadinessAgentTool; +import com.xiaou.system.agent.tools.AgentRuntimeStatusAgentTool; +import com.xiaou.system.agent.tools.AgentRecoveryDryRunAgentTool; +import com.xiaou.system.agent.tools.AgentRecoveryExplainAgentTool; +import com.xiaou.system.agent.tools.AgentSessionContextAgentTool; +import com.xiaou.system.agent.tools.AgentToolAccessCheckAgentTool; +import com.xiaou.system.agent.tools.AgentToolDefinitionValidateAgentTool; +import com.xiaou.system.agent.tools.AgentToolMetricsAlertsAgentTool; +import com.xiaou.system.agent.tools.AgentToolMetricsHealthAgentTool; +import com.xiaou.system.agent.tools.AgentToolMetricsSummaryAgentTool; +import com.xiaou.system.agent.tools.AgentToolSearchAgentTool; +import com.xiaou.system.agent.tools.ChatUserBanStatusAgentTool; +import com.xiaou.system.agent.tools.ChatUserUnbanAgentTool; +import com.xiaou.system.agent.tools.LotteryRealtimeMonitorAgentTool; +import com.xiaou.system.agent.tools.OperationLogCleanAgentTool; +import com.xiaou.system.agent.tools.OperationLogListAgentTool; +import com.xiaou.system.service.SysAgentAuditService; +import com.xiaou.system.service.SysOperationLogService; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.springframework.beans.factory.ObjectProvider; + +import java.util.List; + +import static org.mockito.Mockito.mock; + +/** + * 测试侧内置工具目录,保证 planner fixture 与真实后端工具定义对齐。 + * + * @author xiaou + */ +public final class AgentToolTestCatalog { + + private AgentToolTestCatalog() { + } + + public static List builtInTools( + SysOperationLogService operationLogService, + ChatUserBanService chatUserBanService, + LotteryAdminService lotteryAdminService, + SysAgentAuditService auditService, + AgentToolCatalogService catalogService + ) { + return List.of( + new AgentToolSearchAgentTool(catalogService), + new AgentToolCatalogAgentTool(catalogService), + new AgentToolDetailAgentTool(catalogService), + new AgentToolDefinitionValidateAgentTool(catalogService), + new AgentToolAccessCheckAgentTool(catalogService), + new AgentRuntimeStatusAgentTool(catalogService, new AgentSessionProperties()), + new AgentRuntimeReadinessAgentTool(catalogService, new AgentSessionProperties()), + new AgentRuntimeObservabilityAgentTool(catalogService, new AgentSessionProperties(), new SimpleMeterRegistry()), + new AgentSessionContextAgentTool(new AgentSessionProperties()), + new AgentOperatorSelfAgentTool(), + new AgentPlannerDiagnosticsAgentTool(catalogService), + new AgentPlannerDryRunAgentTool(planResolverProvider()), + new AgentRequestDryRunAgentTool(planResolverProvider(), new AgentPolicyEngine()), + new AgentPolicyDryRunAgentTool(catalogService, new AgentPolicyEngine()), + new AgentToolMetricsSummaryAgentTool(new SimpleMeterRegistry()), + new AgentToolMetricsHealthAgentTool(new SimpleMeterRegistry()), + new AgentToolMetricsAlertsAgentTool(new SimpleMeterRegistry()), + new AgentAuditListAgentTool(auditService), + new AgentAuditDetailAgentTool(auditService), + new AgentRecoveryExplainAgentTool(auditService, new ObjectMapper()), + new AgentRecoveryDryRunAgentTool(auditService, new ObjectMapper()), + new OperationLogListAgentTool(operationLogService), + new OperationLogCleanAgentTool(operationLogService), + new ChatUserBanStatusAgentTool(chatUserBanService), + new ChatUserUnbanAgentTool(chatUserBanService), + new LotteryRealtimeMonitorAgentTool(lotteryAdminService) + ); + } + + @SuppressWarnings("unchecked") + private static ObjectProvider planResolverProvider() { + return mock(ObjectProvider.class); + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/DbAgentSessionContextRepositoryTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/DbAgentSessionContextRepositoryTest.java new file mode 100644 index 000000000..f0de9bb29 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/DbAgentSessionContextRepositoryTest.java @@ -0,0 +1,107 @@ +package com.xiaou.system.agent; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.system.domain.SysAgentSessionContext; +import com.xiaou.system.mapper.SysAgentSessionContextMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class DbAgentSessionContextRepositoryTest { + + private static final TypeReference> TURN_LIST_TYPE = new TypeReference<>() { + }; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void shouldReadSnapshotFromDatabase() throws Exception { + SysAgentSessionContextMapper mapper = mock(SysAgentSessionContextMapper.class); + when(mapper.selectBySessionId("session-1")).thenReturn(record(List.of(turn("message-1", "tool-1")))); + DbAgentSessionContextRepository repository = new DbAgentSessionContextRepository(objectMapper, mapper); + + AgentSessionSnapshot snapshot = repository.snapshot("session-1"); + + assertEquals("session-1", snapshot.getSessionId()); + assertEquals(1, snapshot.getRecentTurns().size()); + assertEquals("message-1", snapshot.getRecentTurns().get(0).getMessage()); + assertEquals("tool-1", snapshot.getRecentTurns().get(0).getToolName()); + } + + @Test + void shouldAppendTurnAndKeepRecentWindow() throws Exception { + SysAgentSessionContextMapper mapper = mock(SysAgentSessionContextMapper.class); + when(mapper.selectBySessionId("session-1")).thenReturn(record(List.of( + turn("message-1", "tool-1"), + turn("message-2", "tool-2") + ))); + DbAgentSessionContextRepository repository = new DbAgentSessionContextRepository(objectMapper, mapper); + + repository.appendTurn("session-1", turn("message-3", "tool-3"), 2); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SysAgentSessionContext.class); + verify(mapper).upsert(captor.capture()); + SysAgentSessionContext saved = captor.getValue(); + List turns = objectMapper.readValue(saved.getTurnsJson(), TURN_LIST_TYPE); + assertEquals("session-1", saved.getSessionId()); + assertEquals(2, turns.size()); + assertEquals("message-2", turns.get(0).getMessage()); + assertEquals("message-3", turns.get(1).getMessage()); + assertNotNull(saved.getCreatedTime()); + assertNotNull(saved.getUpdatedTime()); + } + + @Test + void shouldReturnEmptySnapshotWhenStoredJsonIsInvalid() { + SysAgentSessionContextMapper mapper = mock(SysAgentSessionContextMapper.class); + SysAgentSessionContext record = new SysAgentSessionContext(); + record.setSessionId("session-1"); + record.setTurnsJson("{bad-json"); + when(mapper.selectBySessionId("session-1")).thenReturn(record); + DbAgentSessionContextRepository repository = new DbAgentSessionContextRepository(objectMapper, mapper); + + AgentSessionSnapshot snapshot = repository.snapshot("session-1"); + + assertEquals("session-1", snapshot.getSessionId()); + assertTrue(snapshot.getRecentTurns().isEmpty()); + } + + @Test + void shouldIgnoreBlankSessionId() { + SysAgentSessionContextMapper mapper = mock(SysAgentSessionContextMapper.class); + DbAgentSessionContextRepository repository = new DbAgentSessionContextRepository(objectMapper, mapper); + + repository.appendTurn(" ", turn("message-1", "tool-1"), 6); + + assertTrue(repository.snapshot(" ").getRecentTurns().isEmpty()); + verify(mapper, never()).upsert(org.mockito.ArgumentMatchers.any()); + verify(mapper, never()).selectBySessionId(org.mockito.ArgumentMatchers.any()); + } + + private SysAgentSessionContext record(List turns) throws Exception { + SysAgentSessionContext record = new SysAgentSessionContext(); + record.setSessionId("session-1"); + record.setTurnsJson(objectMapper.writeValueAsString(turns)); + return record; + } + + private AgentSessionTurn turn(String message, String toolName) { + AgentSessionTurn turn = new AgentSessionTurn(); + turn.setMessage(message); + turn.setStatus("answered"); + turn.setToolName(toolName); + turn.setAnswer("answer"); + turn.setTraceId("agent-trace-test"); + return turn; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/InMemoryAgentSessionContextRepositoryTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/InMemoryAgentSessionContextRepositoryTest.java new file mode 100644 index 000000000..752239c17 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/InMemoryAgentSessionContextRepositoryTest.java @@ -0,0 +1,68 @@ +package com.xiaou.system.agent; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InMemoryAgentSessionContextRepositoryTest { + + @Test + void shouldAppendTurnsAndReturnSnapshotBySessionId() { + InMemoryAgentSessionContextRepository repository = new InMemoryAgentSessionContextRepository(); + + repository.appendTurn("session-1", turn("message-1", "tool-1"), 6); + + AgentSessionSnapshot snapshot = repository.snapshot("session-1"); + + assertEquals("session-1", snapshot.getSessionId()); + assertEquals(1, snapshot.getRecentTurns().size()); + assertEquals("message-1", snapshot.getRecentTurns().get(0).getMessage()); + assertEquals("tool-1", snapshot.getRecentTurns().get(0).getToolName()); + } + + @Test + void shouldKeepOnlyConfiguredRecentTurns() { + InMemoryAgentSessionContextRepository repository = new InMemoryAgentSessionContextRepository(); + + for (int i = 1; i <= 5; i++) { + repository.appendTurn("session-1", turn("message-" + i, "tool-" + i), 3); + } + + AgentSessionSnapshot snapshot = repository.snapshot("session-1"); + + assertEquals(3, snapshot.getRecentTurns().size()); + assertEquals("message-3", snapshot.getRecentTurns().get(0).getMessage()); + assertEquals("message-5", snapshot.getRecentTurns().get(2).getMessage()); + } + + @Test + void shouldReturnDefensiveSnapshotCopy() { + InMemoryAgentSessionContextRepository repository = new InMemoryAgentSessionContextRepository(); + repository.appendTurn("session-1", turn("message-1", "tool-1"), 6); + + AgentSessionSnapshot snapshot = repository.snapshot("session-1"); + snapshot.getRecentTurns().clear(); + + assertEquals(1, repository.snapshot("session-1").getRecentTurns().size()); + } + + @Test + void shouldIgnoreBlankSessionId() { + InMemoryAgentSessionContextRepository repository = new InMemoryAgentSessionContextRepository(); + + repository.appendTurn(" ", turn("message-1", "tool-1"), 6); + + assertTrue(repository.snapshot(" ").getRecentTurns().isEmpty()); + } + + private AgentSessionTurn turn(String message, String toolName) { + AgentSessionTurn turn = new AgentSessionTurn(); + turn.setMessage(message); + turn.setStatus("answered"); + turn.setToolName(toolName); + turn.setAnswer("answer"); + turn.setTraceId("agent-trace-test"); + return turn; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/LlmAgentPlanResolverTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/LlmAgentPlanResolverTest.java new file mode 100644 index 000000000..3bee67d26 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/LlmAgentPlanResolverTest.java @@ -0,0 +1,264 @@ +package com.xiaou.system.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.ai.prompt.admin.AdminAgentPromptSpecs; +import com.xiaou.ai.support.AiExecutionSupport; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LlmAgentPlanResolverTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void shouldPreferLlmPlannerBeforeDeterministicFallback() { + AiExecutionSupport aiExecutionSupport = mock(AiExecutionSupport.class); + AgentToolRegistry registry = new AgentToolRegistry(List.of(resolvingTool("system.operationLog.list"))); + LlmAgentPlanResolver resolver = resolver(registry, aiExecutionSupport); + stubAiResponse(aiExecutionSupport, """ + { + "toolName": "system.operationLog.list", + "input": {}, + "confidence": 0.95, + "missingFields": [] + } + """); + + AgentPlanResolution resolution = resolver.resolvePlan("查最近3条操作日志"); + + assertTrue(resolution.isResolved()); + assertEquals("system.operationLog.list", resolution.getResolvedCall().call().getToolName()); + assertEquals("LLM planner candidate", resolution.getResolvedCall().call().getSummary()); + verify(aiExecutionSupport).chatWithFallback( + eq("admin.agent.plan"), + eq(AdminAgentPromptSpecs.PLAN), + org.mockito.ArgumentMatchers.>any(), + org.mockito.ArgumentMatchers.>any(), + org.mockito.ArgumentMatchers.>any() + ); + } + + @Test + void shouldUseDeterministicResolverOnlyWhenLlmPlannerHasNoResolution() { + AiExecutionSupport aiExecutionSupport = mock(AiExecutionSupport.class); + AgentToolRegistry registry = new AgentToolRegistry(List.of(resolvingTool("system.operationLog.list"))); + LlmAgentPlanResolver resolver = resolver(registry, aiExecutionSupport); + when(aiExecutionSupport.chatWithFallback( + eq("admin.agent.plan"), + eq(AdminAgentPromptSpecs.PLAN), + org.mockito.ArgumentMatchers.>any(), + org.mockito.ArgumentMatchers.>any(), + org.mockito.ArgumentMatchers.>any() + )).thenReturn(AgentPlanResolution.empty()); + + AgentPlanResolution resolution = resolver.resolvePlan("查最近3条操作日志"); + + assertTrue(resolution.isResolved()); + assertEquals("system.operationLog.list", resolution.getResolvedCall().call().getToolName()); + assertEquals("deterministic", resolution.getResolvedCall().call().getSummary()); + } + + @Test + void shouldResolveLlmCandidateWithRegisteredToolAndSchemaFilteredInput() { + AiExecutionSupport aiExecutionSupport = mock(AiExecutionSupport.class); + AgentToolRegistry registry = new AgentToolRegistry(List.of(tool("chat.userBan.unban"))); + LlmAgentPlanResolver resolver = resolver(registry, aiExecutionSupport); + stubAiResponse(aiExecutionSupport, """ + { + "toolName": "chat.userBan.unban", + "input": {"userId": 88, "sql": "drop table sys_user"}, + "confidence": 0.92, + "missingFields": [] + } + """); + + AgentPlanResolution resolution = resolver.resolvePlan("解除 88 的禁言"); + + assertTrue(resolution.isResolved()); + assertEquals("chat.userBan.unban", resolution.getResolvedCall().call().getToolName()); + assertEquals(Map.of("userId", 88), resolution.getResolvedCall().call().getInput()); + } + + @Test + void shouldAskForClarificationWhenPlannerReportsMissingFields() { + AiExecutionSupport aiExecutionSupport = mock(AiExecutionSupport.class); + AgentToolRegistry registry = new AgentToolRegistry(List.of(tool("chat.userBan.unban"))); + LlmAgentPlanResolver resolver = resolver(registry, aiExecutionSupport); + stubAiResponse(aiExecutionSupport, """ + { + "toolName": "chat.userBan.unban", + "input": {}, + "confidence": 0.88, + "missingFields": ["userId"] + } + """); + + AgentPlanResolution resolution = resolver.resolvePlan("解除禁言"); + + assertFalse(resolution.isResolved()); + assertEquals(AgentChatErrorCode.PLAN_CLARIFICATION_REQUIRED, resolution.getErrorCode()); + assertTrue(resolution.getMessage().contains("userId")); + } + + @Test + void shouldAskForClarificationWhenRegisteredToolRequiresMoreInput() { + AiExecutionSupport aiExecutionSupport = mock(AiExecutionSupport.class); + AgentToolRegistry registry = new AgentToolRegistry(List.of(tool("chat.userBan.unban"))); + LlmAgentPlanResolver resolver = resolver(registry, aiExecutionSupport); + stubAiResponse(aiExecutionSupport, """ + { + "toolName": "chat.userBan.unban", + "input": {}, + "confidence": 0.88, + "missingFields": [] + } + """); + + AgentPlanResolution resolution = resolver.resolvePlan("解除禁言"); + + assertFalse(resolution.isResolved()); + assertEquals(AgentChatErrorCode.PLAN_CLARIFICATION_REQUIRED, resolution.getErrorCode()); + assertTrue(resolution.getNextActions().contains("请补充字段:userId")); + } + + @Test + void shouldPassSessionContextToPlannerPrompt() { + AiExecutionSupport aiExecutionSupport = mock(AiExecutionSupport.class); + AgentToolRegistry registry = new AgentToolRegistry(List.of(tool("chat.userBan.unban"))); + LlmAgentPlanResolver resolver = resolver(registry, aiExecutionSupport); + ArgumentCaptor> variablesCaptor = ArgumentCaptor.forClass(Map.class); + when(aiExecutionSupport.chatWithFallback( + eq("admin.agent.plan"), + eq(AdminAgentPromptSpecs.PLAN), + variablesCaptor.capture(), + org.mockito.ArgumentMatchers.>any(), + org.mockito.ArgumentMatchers.>any() + )).thenReturn(AgentPlanResolution.empty()); + + AgentSessionSnapshot snapshot = new AgentSessionSnapshot(); + snapshot.setSessionId("session-1"); + AgentSessionTurn turn = new AgentSessionTurn(); + turn.setMessage("查 88 的禁言状态"); + turn.setStatus("answered"); + turn.setToolName("chat.userBan.active"); + turn.setAnswer("用户 88 当前处于禁言中"); + snapshot.setRecentTurns(List.of(turn)); + + resolver.resolvePlan(new AgentExecutionContext( + "session-1", + "那就解除", + new AgentOperator(1L, "admin"), + AgentRuntimeTrace.start(), + snapshot + )); + + Map variables = variablesCaptor.getValue(); + assertEquals("那就解除", variables.get("message")); + assertTrue(String.valueOf(variables.get("sessionContextJson")).contains("chat.userBan.active")); + assertTrue(String.valueOf(variables.get("sessionContextJson")).contains("查 88 的禁言状态")); + } + + private LlmAgentPlanResolver resolver(AgentToolRegistry registry, AiExecutionSupport aiExecutionSupport) { + return new LlmAgentPlanResolver( + new DeterministicAgentPlanResolver(registry), + registry, + aiExecutionSupport, + objectMapper + ); + } + + private void stubAiResponse(AiExecutionSupport aiExecutionSupport, String content) { + when(aiExecutionSupport.chatWithFallback( + eq("admin.agent.plan"), + eq(AdminAgentPromptSpecs.PLAN), + org.mockito.ArgumentMatchers.>any(), + org.mockito.ArgumentMatchers.>any(), + org.mockito.ArgumentMatchers.>any() + )).thenAnswer(invocation -> { + Function parser = invocation.getArgument(3); + return parser.apply(content); + }); + } + + private AgentTool resolvingTool(String name) { + return new AgentTool() { + @Override + public AgentToolDefinition definition() { + return toolDefinition(name, Map.of(), List.of()); + } + + @Override + public Optional resolve(String message) { + AgentToolCall call = new AgentToolCall(); + call.setToolName(name); + call.setSummary("deterministic"); + call.setInput(new LinkedHashMap<>()); + return Optional.of(call); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + return new AgentToolPreview(); + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("done"); + return result; + } + }; + } + + private AgentTool tool(String name) { + return new AgentTool() { + @Override + public AgentToolDefinition definition() { + return toolDefinition(name, Map.of( + "userId", Map.of("type", "integer", "minimum", 1) + ), List.of("userId")); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + return new AgentToolPreview(); + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("done"); + return result; + } + }; + } + + private AgentToolDefinition toolDefinition(String name, Map inputSchema, List requiredInputKeys) { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName(name); + definition.setTitle(name); + definition.setDescription("测试工具"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(inputSchema); + definition.setRequiredInputKeys(requiredInputKeys); + return definition; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/RedisAgentSessionContextRepositoryTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/RedisAgentSessionContextRepositoryTest.java new file mode 100644 index 000000000..2f6bc0c5f --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/RedisAgentSessionContextRepositoryTest.java @@ -0,0 +1,125 @@ +package com.xiaou.system.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.StaticListableBeanFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RedisAgentSessionContextRepositoryTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void shouldAppendTurnToRedisAndKeepRecentWindow() throws Exception { + RedisFixture fixture = redisFixture(); + AgentSessionProperties properties = properties(); + RedisAgentSessionContextRepository repository = repository(fixture, properties); + when(fixture.valueOperations.get("xiaou:admin:agent:session:session-1")) + .thenReturn(objectMapper.writeValueAsString(List.of( + turn("message-1", "tool-1"), + turn("message-2", "tool-2") + ))); + + repository.appendTurn("session-1", turn("message-3", "tool-3"), 2); + + verify(fixture.valueOperations).set(eq("xiaou:admin:agent:session:session-1"), + org.mockito.ArgumentMatchers.argThat(json -> + json.contains("message-2") + && json.contains("message-3") + && !json.contains("message-1"))); + verify(fixture.redisTemplate).expire("xiaou:admin:agent:session:session-1", Duration.ofSeconds(3600)); + } + + @Test + void shouldReadSnapshotFromRedis() throws Exception { + RedisFixture fixture = redisFixture(); + RedisAgentSessionContextRepository repository = repository(fixture, properties()); + when(fixture.valueOperations.get("xiaou:admin:agent:session:session-1")) + .thenReturn(objectMapper.writeValueAsString(List.of(turn("message-1", "tool-1")))); + + AgentSessionSnapshot snapshot = repository.snapshot("session-1"); + + assertEquals("session-1", snapshot.getSessionId()); + assertEquals(1, snapshot.getRecentTurns().size()); + assertEquals("message-1", snapshot.getRecentTurns().get(0).getMessage()); + assertEquals("tool-1", snapshot.getRecentTurns().get(0).getToolName()); + } + + @Test + void shouldReturnEmptySnapshotWhenRedisIsUnavailable() { + RedisAgentSessionContextRepository repository = new RedisAgentSessionContextRepository( + objectMapper, + properties(), + new StaticListableBeanFactory().getBeanProvider(StringRedisTemplate.class) + ); + + AgentSessionSnapshot snapshot = repository.snapshot("session-1"); + + assertEquals("session-1", snapshot.getSessionId()); + assertTrue(snapshot.getRecentTurns().isEmpty()); + } + + @Test + void shouldSkipExpireWhenTtlIsDisabled() { + RedisFixture fixture = redisFixture(); + AgentSessionProperties properties = properties(); + properties.setTtlSeconds(0); + RedisAgentSessionContextRepository repository = repository(fixture, properties); + + repository.appendTurn("session-1", turn("message-1", "tool-1"), 6); + + verify(fixture.valueOperations).set(eq("xiaou:admin:agent:session:session-1"), anyString()); + verify(fixture.redisTemplate, never()).expire(eq("xiaou:admin:agent:session:session-1"), org.mockito.ArgumentMatchers.any(Duration.class)); + } + + private RedisAgentSessionContextRepository repository(RedisFixture fixture, AgentSessionProperties properties) { + StaticListableBeanFactory beanFactory = new StaticListableBeanFactory(); + beanFactory.addBean("stringRedisTemplate", fixture.redisTemplate); + return new RedisAgentSessionContextRepository( + objectMapper, + properties, + beanFactory.getBeanProvider(StringRedisTemplate.class) + ); + } + + @SuppressWarnings("unchecked") + private RedisFixture redisFixture() { + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + return new RedisFixture(redisTemplate, valueOperations); + } + + private AgentSessionProperties properties() { + AgentSessionProperties properties = new AgentSessionProperties(); + properties.setRedisKeyPrefix("xiaou:admin:agent:session"); + properties.setTtlSeconds(3600); + return properties; + } + + private AgentSessionTurn turn(String message, String toolName) { + AgentSessionTurn turn = new AgentSessionTurn(); + turn.setMessage(message); + turn.setStatus("answered"); + turn.setToolName(toolName); + turn.setAnswer("answer"); + turn.setTraceId("agent-trace-test"); + return turn; + } + + private record RedisFixture(StringRedisTemplate redisTemplate, ValueOperations valueOperations) { + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentAuditDetailAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentAuditDetailAgentToolTest.java new file mode 100644 index 000000000..389223f9e --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentAuditDetailAgentToolTest.java @@ -0,0 +1,94 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.service.SysAgentAuditService; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AgentAuditDetailAgentToolTest { + + @Test + void shouldResolveAuditDetailQuestionWithAuditId() { + AgentAuditDetailAgentTool tool = new AgentAuditDetailAgentTool(mock(SysAgentAuditService.class)); + + AgentToolCall call = tool.resolve("查一下 agent-audit-1 的智能体审计详情").orElseThrow(); + + assertEquals("system.agent.audit.detail", call.getToolName()); + assertEquals("agent-audit-1", call.getInput().get("auditId")); + assertTrue(tool.resolve("查询操作日志").isEmpty()); + } + + @Test + void shouldLoadAuditDetailAndReturnArtifact() { + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("agent-audit-1")).thenReturn(audit()); + AgentAuditDetailAgentTool tool = new AgentAuditDetailAgentTool(auditService); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of("auditId", "agent-audit-1")); + AgentToolResult result = tool.execute(call, null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("agent-audit-1")); + assertEquals(1, result.getArtifacts().size()); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentAuditDetail", artifact.getType()); + assertEquals("agent-audit-1", artifact.getData().get("auditId")); + assertFalse(result.getNextActions().isEmpty()); + verify(auditService).getByAuditId("agent-audit-1"); + } + + @Test + void shouldReturnErrorWhenAuditDetailMissing() { + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("agent-audit-missing")).thenReturn(null); + AgentAuditDetailAgentTool tool = new AgentAuditDetailAgentTool(auditService); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of("auditId", "agent-audit-missing")); + AgentToolResult result = tool.execute(call, null); + + assertFalse(result.isSuccess()); + assertTrue(result.getSummary().contains("没有找到")); + assertEquals("智能体审计记录不存在: agent-audit-missing", result.getErrorMessage()); + } + + private AgentAuditResponse audit() { + AgentAuditResponse response = new AgentAuditResponse(); + response.setAuditId("agent-audit-1"); + response.setConfirmationId("agent-confirmation-1"); + response.setUserMessage("解除用户 88 禁言"); + response.setActionId("chat.userBan.unban"); + response.setIntent("chat.userBan.unban"); + response.setRoute("/admin/agent/chat"); + response.setRiskLevel("medium"); + response.setRiskCategory("WRITE"); + response.setStatus("EXECUTED"); + response.setSummary("已解除禁言"); + response.setPayloadJson("{\"userId\":88}"); + response.setDiffJson("[]"); + response.setPlanJson("[]"); + response.setResultJson("{\"summary\":\"已解除禁言\"}"); + response.setOperatorId(100L); + response.setOperatorName("admin"); + response.setConfirmedTime(LocalDateTime.now()); + response.setExecutedTime(LocalDateTime.now()); + response.setCreatedTime(LocalDateTime.now()); + response.setUpdatedTime(LocalDateTime.now()); + return response; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentAuditListAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentAuditListAgentToolTest.java new file mode 100644 index 000000000..2969cb033 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentAuditListAgentToolTest.java @@ -0,0 +1,75 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.common.core.domain.PageResult; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentAuditQueryRequest; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.service.SysAgentAuditService; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AgentAuditListAgentToolTest { + + @Test + void shouldResolveAuditListQuestionWithStatusFilter() { + AgentAuditListAgentTool tool = new AgentAuditListAgentTool(mock(SysAgentAuditService.class)); + + AgentToolCall call = tool.resolve("查最近5条失败的智能体审计记录").orElseThrow(); + + assertEquals("system.agent.audit.list", call.getToolName()); + assertEquals(5, call.getInput().get("pageSize")); + assertEquals("FAILED", call.getInput().get("status")); + assertTrue(tool.resolve("查询操作日志").isEmpty()); + assertTrue(tool.resolve("查一下 agent-audit-1 的智能体审计详情").isEmpty()); + } + + @Test + void shouldQueryAuditPageAndReturnArtifact() { + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getAuditPage(any())).thenReturn(PageResult.of(1, 2, 1L, List.of(audit()))); + AgentAuditListAgentTool tool = new AgentAuditListAgentTool(auditService); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of("pageNum", 1, "pageSize", 2, "status", "EXECUTED")); + AgentToolResult result = tool.execute(call, null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("最近 1 条")); + assertEquals(1, result.getArtifacts().size()); + assertFalse(result.getNextActions().isEmpty()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AgentAuditQueryRequest.class); + verify(auditService).getAuditPage(captor.capture()); + assertEquals(1, captor.getValue().getPageNum()); + assertEquals(2, captor.getValue().getPageSize()); + assertEquals("EXECUTED", captor.getValue().getStatus()); + } + + private AgentAuditResponse audit() { + AgentAuditResponse response = new AgentAuditResponse(); + response.setAuditId("agent-audit-1"); + response.setActionId("system.operationLog.list"); + response.setIntent("system.operationLog.list"); + response.setStatus("EXECUTED"); + response.setRiskCategory("READONLY"); + response.setSummary("已查询操作日志"); + response.setOperatorName("admin"); + response.setCreatedTime(LocalDateTime.now()); + response.setExecutedTime(LocalDateTime.now()); + return response; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentOperatorSelfAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentOperatorSelfAgentToolTest.java new file mode 100644 index 000000000..cde538b6c --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentOperatorSelfAgentToolTest.java @@ -0,0 +1,84 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentOperator; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AgentOperatorSelfAgentToolTest { + + @Test + void shouldResolveCurrentOperatorQuestion() { + AgentOperatorSelfAgentTool tool = new AgentOperatorSelfAgentTool(); + + AgentToolCall call = tool.resolve("我在智能体里的角色和权限是什么").orElseThrow(); + + assertEquals("system.agent.operator.self", call.getToolName()); + assertTrue(call.getInput().isEmpty()); + assertTrue(tool.resolve("查询操作日志").isEmpty()); + } + + @Test + void shouldReturnCurrentOperatorArtifact() { + AgentOperatorSelfAgentTool tool = new AgentOperatorSelfAgentTool(); + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of()); + + AgentOperator operator = new AgentOperator( + 7L, + "Alice", + "tenant-a", + List.of("SUPER_ADMIN", "OPS", "SUPER_ADMIN"), + List.of("agent:runtime:operator:read", "agent:chat:user-ban:read", "*") + ); + AgentToolResult result = tool.execute(call, new AgentExecutionContext("session-1", "我的权限", operator)); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("Alice")); + assertTrue(result.getSummary().contains("2 个角色")); + assertEquals(1, result.getArtifacts().size()); + assertFalse(result.getNextActions().isEmpty()); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentOperatorSelf", artifact.getType()); + assertEquals(7L, artifact.getData().get("operatorId")); + assertEquals("Alice", artifact.getData().get("operatorName")); + assertEquals("tenant-a", artifact.getData().get("tenantId")); + assertEquals(2, artifact.getData().get("roleCount")); + assertEquals(3, artifact.getData().get("permissionCount")); + assertEquals(List.of("OPS", "SUPER_ADMIN"), artifact.getData().get("roles")); + assertEquals(List.of("*", "agent:chat:user-ban:read", "agent:runtime:operator:read"), + artifact.getData().get("permissions")); + assertEquals(true, artifact.getData().get("hasWildcardPermission")); + assertEquals(true, artifact.getData().get("selfOnly")); + } + + @Test + void shouldHandleMissingOperatorAsEmptyRuntimeContext() { + AgentOperatorSelfAgentTool tool = new AgentOperatorSelfAgentTool(); + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of()); + + AgentToolResult result = tool.execute(call, null); + + assertTrue(result.isSuccess()); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertNull(artifact.getData().get("operatorId")); + assertEquals("", artifact.getData().get("operatorName")); + assertEquals("", artifact.getData().get("tenantId")); + assertEquals(0, artifact.getData().get("roleCount")); + assertEquals(0, artifact.getData().get("permissionCount")); + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentPlannerDiagnosticsAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentPlannerDiagnosticsAgentToolTest.java new file mode 100644 index 000000000..d2c4df3fe --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentPlannerDiagnosticsAgentToolTest.java @@ -0,0 +1,80 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentPlannerDiagnosticsAgentToolTest { + + @Test + void shouldResolvePlannerDiagnosticsQuestion() { + AgentPlannerDiagnosticsAgentTool tool = new AgentPlannerDiagnosticsAgentTool(catalogService()); + + AgentToolCall call = tool.resolve("planner 结构化输出契约和命中规则是什么").orElseThrow(); + + assertEquals("system.agent.planner.diagnostics", call.getToolName()); + assertTrue(call.getInput().isEmpty()); + assertTrue(tool.resolve("查询操作日志").isEmpty()); + } + + @Test + void shouldReturnPlannerDiagnosticsArtifact() { + AgentPlannerDiagnosticsAgentTool tool = new AgentPlannerDiagnosticsAgentTool(catalogService()); + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of()); + + AgentToolResult result = tool.execute(call, null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("2 个")); + assertEquals(1, result.getArtifacts().size()); + assertFalse(result.getNextActions().isEmpty()); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentPlannerDiagnostics", artifact.getType()); + assertEquals(2, artifact.getData().get("registeredToolCount")); + assertEquals(List.of("chat.userBan.unban", "system.agent.tools.list"), artifact.getData().get("registeredToolNames")); + assertEquals("admin_agent.plan", ((Map) artifact.getData().get("prompt")).get("key")); + assertEquals("v1", ((Map) artifact.getData().get("prompt")).get("version")); + assertEquals(List.of("toolName", "input", "confidence", "missingFields"), + ((Map) artifact.getData().get("structuredOutput")).get("requiredFields")); + assertEquals(0.6D, artifact.getData().get("minimumConfidence")); + } + + private AgentToolCatalogService catalogService() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of( + definition("system.agent.tools.list"), + definition("chat.userBan.unban") + )); + return catalogService; + } + + private AgentToolDefinition definition(String name) { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName(name); + definition.setTitle(name); + definition.setDescription(name); + definition.setIntent(name); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of()); + definition.setRequiredInputKeys(List.of()); + definition.setRequiredPermissions(List.of("agent:test:read")); + return definition; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentPlannerDryRunAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentPlannerDryRunAgentToolTest.java new file mode 100644 index 000000000..604eb7b4c --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentPlannerDryRunAgentToolTest.java @@ -0,0 +1,162 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentPlanResolution; +import com.xiaou.system.agent.AgentPlanResolver; +import com.xiaou.system.agent.AgentResolvedToolCall; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentPlannerDryRunAgentToolTest { + + @Test + void shouldResolvePlannerDryRunQuestionAndExtractTargetMessage() { + AgentPlannerDryRunAgentTool tool = new AgentPlannerDryRunAgentTool(provider(null)); + + AgentToolCall call = tool.resolve("planner 预演:帮我查最近3条操作日志").orElseThrow(); + + assertEquals("system.agent.planner.dry_run", call.getToolName()); + assertEquals("帮我查最近3条操作日志", call.getInput().get("message")); + assertTrue(tool.resolve("planner 结构化输出契约和命中规则是什么").isEmpty()); + } + + @Test + void shouldReportResolvedCandidateWithoutExecutingTargetTool() { + AgentPlanResolver resolver = mock(AgentPlanResolver.class); + TrackingAgentTool targetTool = new TrackingAgentTool(); + AgentToolCall targetCall = new AgentToolCall(); + targetCall.setToolName("chat.userBan.unban"); + targetCall.setSummary("解除聊天禁言"); + targetCall.setInput(Map.of("userId", 88)); + when(resolver.resolvePlan(argThat((AgentExecutionContext context) -> "帮我把用户88解除禁言".equals(context.message())))) + .thenReturn(AgentPlanResolution.resolved(new AgentResolvedToolCall(targetTool, targetCall))); + AgentPlannerDryRunAgentTool tool = new AgentPlannerDryRunAgentTool(provider(resolver)); + + AgentToolResult result = tool.execute(call(tool, "帮我把用户88解除禁言"), + new AgentExecutionContext("session-1", "planner 预演", null)); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("chat.userBan.unban")); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentPlannerDryRun", artifact.getType()); + assertEquals("RESOLVED", artifact.getData().get("status")); + assertEquals("chat.userBan.unban", artifact.getData().get("toolName")); + assertEquals(Map.of("userId", 88), artifact.getData().get("input")); + assertEquals(true, artifact.getData().get("dryRunOnly")); + assertFalse(targetTool.previewCalled); + assertFalse(targetTool.executeCalled); + } + + @Test + void shouldReportClarificationWhenPlannerNeedsMoreInput() { + AgentPlanResolver resolver = mock(AgentPlanResolver.class); + when(resolver.resolvePlan("帮我解除禁言")) + .thenReturn(AgentPlanResolution.clarification("还需要补充这些信息后才能继续:userId", List.of("请补充字段:userId"))); + AgentPlannerDryRunAgentTool tool = new AgentPlannerDryRunAgentTool(provider(resolver)); + + AgentToolResult result = tool.execute(call(tool, "帮我解除禁言"), null); + + assertTrue(result.isSuccess()); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("CLARIFICATION", artifact.getData().get("status")); + assertTrue(String.valueOf(artifact.getData().get("message")).contains("userId")); + assertEquals(List.of("请补充字段:userId"), artifact.getData().get("nextActions")); + } + + @Test + void shouldReportEmptyWhenPlannerDoesNotResolveTool() { + AgentPlanResolver resolver = mock(AgentPlanResolver.class); + when(resolver.resolvePlan("随便聊聊")).thenReturn(AgentPlanResolution.empty()); + AgentPlannerDryRunAgentTool tool = new AgentPlannerDryRunAgentTool(provider(resolver)); + + AgentToolResult result = tool.execute(call(tool, "随便聊聊"), null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("未命中")); + assertEquals("EMPTY", result.getArtifacts().get(0).getData().get("status")); + } + + @Test + void shouldReturnErrorWhenPlannerResolverUnavailable() { + AgentPlannerDryRunAgentTool tool = new AgentPlannerDryRunAgentTool(provider(null)); + + AgentToolResult result = tool.execute(call(tool, "帮我查日志"), null); + + assertFalse(result.isSuccess()); + assertTrue(result.getSummary().contains("planner 不可用")); + } + + private AgentToolCall call(AgentPlannerDryRunAgentTool tool, String message) { + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of("message", message)); + return call; + } + + @SuppressWarnings("unchecked") + private ObjectProvider provider(AgentPlanResolver resolver) { + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(resolver); + return provider; + } + + private AgentToolDefinition targetDefinition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("chat.userBan.unban"); + definition.setTitle("解除聊天禁言"); + definition.setDescription("解除指定用户当前生效的聊天禁言"); + definition.setIntent("chat.userBan.unban"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("medium"); + definition.setRiskCategory("WRITE"); + definition.setConfirmationRequired(true); + definition.setConfirmationText("确认解除禁言"); + definition.setInputSchema(Map.of("userId", Map.of("type", "integer", "minimum", 1))); + definition.setRequiredInputKeys(List.of("userId")); + definition.setRequiredPermissions(List.of("agent:chat:user-ban:write")); + return definition; + } + + private class TrackingAgentTool implements AgentTool { + private boolean previewCalled; + private boolean executeCalled; + + @Override + public AgentToolDefinition definition() { + return targetDefinition(); + } + + @Override + public Optional resolve(String message) { + return Optional.empty(); + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + executeCalled = true; + return new AgentToolResult(); + } + + @Override + public com.xiaou.system.agent.AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + previewCalled = true; + return new com.xiaou.system.agent.AgentToolPreview(); + } + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentPolicyDryRunAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentPolicyDryRunAgentToolTest.java new file mode 100644 index 000000000..44925b4db --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentPolicyDryRunAgentToolTest.java @@ -0,0 +1,130 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentOperator; +import com.xiaou.system.agent.AgentPolicyEngine; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentPolicyDryRunAgentToolTest { + + @Test + void shouldResolvePolicyDryRunQuestionWithRegisteredToolName() { + AgentPolicyDryRunAgentTool tool = new AgentPolicyDryRunAgentTool(catalogService(writeTool()), new AgentPolicyEngine()); + + AgentToolCall call = tool.resolve("对 chat.userBan.unban 做 policy dry run").orElseThrow(); + + assertEquals("system.agent.policy.dry_run", call.getToolName()); + assertEquals("chat.userBan.unban", call.getInput().get("toolName")); + assertTrue(tool.resolve("chat.userBan.unban 这个工具需要什么权限").isEmpty()); + } + + @Test + void shouldReportConfirmationRequiredWhenPolicyAllowsWriteTool() { + AgentPolicyDryRunAgentTool tool = new AgentPolicyDryRunAgentTool(catalogService(writeTool()), new AgentPolicyEngine()); + AgentToolCall call = call(tool, "chat.userBan.unban", Map.of("userId", 88)); + AgentOperator operator = new AgentOperator(7L, "Alice", "tenant-a", List.of("SUPER_ADMIN"), + List.of("agent:chat:user-ban:write", "agent:runtime:policy:read")); + + AgentToolResult result = tool.execute(call, new AgentExecutionContext("session-1", "策略预检", operator)); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("需要强确认")); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentPolicyDryRun", artifact.getType()); + assertEquals("chat.userBan.unban", artifact.getData().get("toolName")); + assertEquals(Map.of("userId", 88), artifact.getData().get("input")); + assertEquals(true, artifact.getData().get("allowed")); + assertEquals(true, artifact.getData().get("confirmationRequired")); + assertEquals("确认解除禁言", artifact.getData().get("confirmationText")); + assertEquals("", artifact.getData().get("errorCode")); + assertEquals("", artifact.getData().get("rejectionReason")); + } + + @Test + void shouldReportSchemaRejectionForMissingRequiredInput() { + AgentPolicyDryRunAgentTool tool = new AgentPolicyDryRunAgentTool(catalogService(writeTool()), new AgentPolicyEngine()); + AgentOperator operator = new AgentOperator(7L, "Alice", "tenant-a", List.of("SUPER_ADMIN"), + List.of("agent:chat:user-ban:write", "agent:runtime:policy:read")); + + AgentToolResult result = tool.execute(call(tool, "chat.userBan.unban", Map.of()), + new AgentExecutionContext("session-1", "策略预检", operator)); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("拒绝")); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals(false, artifact.getData().get("allowed")); + assertEquals("SCHEMA_VALIDATION_FAILED", artifact.getData().get("errorCode")); + assertTrue(String.valueOf(artifact.getData().get("rejectionReason")).contains("userId")); + } + + @Test + void shouldReportPermissionRejectionForCurrentOperator() { + AgentPolicyDryRunAgentTool tool = new AgentPolicyDryRunAgentTool(catalogService(writeTool()), new AgentPolicyEngine()); + AgentOperator operator = new AgentOperator(7L, "Alice", "tenant-a", List.of("SUPER_ADMIN"), + List.of("agent:runtime:policy:read")); + + AgentToolResult result = tool.execute(call(tool, "chat.userBan.unban", Map.of("userId", 88)), + new AgentExecutionContext("session-1", "策略预检", operator)); + + assertTrue(result.isSuccess()); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals(false, artifact.getData().get("allowed")); + assertEquals("POLICY_REJECTED", artifact.getData().get("errorCode")); + assertTrue(String.valueOf(artifact.getData().get("rejectionReason")).contains("agent:chat:user-ban:write")); + } + + @Test + void shouldReturnErrorWhenTargetToolMissing() { + AgentPolicyDryRunAgentTool tool = new AgentPolicyDryRunAgentTool(catalogService(), new AgentPolicyEngine()); + + AgentToolResult result = tool.execute(call(tool, "system.unknown", Map.of()), null); + + assertFalse(result.isSuccess()); + assertTrue(result.getSummary().contains("没有找到")); + assertEquals("后端智能体工具不存在: system.unknown", result.getErrorMessage()); + } + + private AgentToolCall call(AgentPolicyDryRunAgentTool tool, String toolName, Map input) { + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of("toolName", toolName, "input", input)); + return call; + } + + private AgentToolCatalogService catalogService(AgentToolDefinition... definitions) { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of(definitions)); + return catalogService; + } + + private AgentToolDefinition writeTool() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("chat.userBan.unban"); + definition.setTitle("解除聊天禁言"); + definition.setDescription("解除指定用户当前生效的聊天禁言"); + definition.setIntent("chat.userBan.unban"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("medium"); + definition.setRiskCategory("WRITE"); + definition.setConfirmationRequired(true); + definition.setConfirmationText("确认解除禁言"); + definition.setInputSchema(Map.of("userId", Map.of("type", "integer", "minimum", 1))); + definition.setRequiredInputKeys(List.of("userId")); + definition.setRequiredPermissions(List.of("agent:chat:user-ban:write")); + return definition; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRecoveryDryRunAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRecoveryDryRunAgentToolTest.java new file mode 100644 index 000000000..67e7de0cf --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRecoveryDryRunAgentToolTest.java @@ -0,0 +1,159 @@ +package com.xiaou.system.agent.tools; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.service.SysAgentAuditService; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class AgentRecoveryDryRunAgentToolTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void shouldResolveRecoveryDryRunQuestionWithAuditId() { + AgentRecoveryDryRunAgentTool tool = new AgentRecoveryDryRunAgentTool(mock(SysAgentAuditService.class), objectMapper); + + AgentToolCall call = tool.resolve("帮我预演一下 agent-audit-1 能不能重试或补偿").orElseThrow(); + + assertEquals("system.agent.recovery.dry_run", call.getToolName()); + assertEquals("agent-audit-1", call.getInput().get("auditId")); + assertTrue(tool.resolve("解释一下 agent-audit-1 的失败恢复上下文").isEmpty()); + } + + @Test + void shouldBlockSameAuditRetryAndRequireNewPreviewFromPersistedRecoveryArtifact() { + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("agent-audit-1")).thenReturn(failedAuditWithRecoveryArtifact()); + AgentRecoveryDryRunAgentTool tool = new AgentRecoveryDryRunAgentTool(auditService, objectMapper); + + AgentToolResult result = tool.execute(call("agent-audit-1"), null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("旧失败审计不可重试")); + assertTrue(result.getSummary().contains("只读")); + AgentChatArtifact artifact = dryRunArtifact(result); + assertEquals("agent-audit-1", artifact.getData().get("auditId")); + assertEquals("agent-idempotency-1", artifact.getData().get("idempotencyKey")); + assertEquals("chat.userBan.unban", artifact.getData().get("toolName")); + assertEquals("NEW_PREVIEW_REQUIRED", artifact.getData().get("retryPolicy")); + assertEquals(Boolean.FALSE, artifact.getData().get("sameAuditRetryAllowed")); + assertEquals(Boolean.TRUE, artifact.getData().get("requiresNewPreview")); + assertEquals(Boolean.FALSE, artifact.getData().get("safeToExecuteNow")); + assertEquals(Boolean.FALSE, artifact.getData().get("compensationAllowed")); + assertEquals(Boolean.TRUE, artifact.getData().get("dryRunOnly")); + assertEquals(Boolean.FALSE, artifact.getData().get("targetExecuted")); + assertEquals(Boolean.FALSE, artifact.getData().get("auditWritten")); + assertTrue(result.getNextActions().contains("查询目标对象当前状态,确认是否发生部分写入。")); + verify(auditService).getByAuditId("agent-audit-1"); + verifyNoMoreInteractions(auditService); + } + + @Test + void shouldFallbackForLegacyFailedAuditWithoutRecoveryArtifactAndStayReadonly() { + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("agent-audit-legacy")).thenReturn(legacyFailedAudit()); + AgentRecoveryDryRunAgentTool tool = new AgentRecoveryDryRunAgentTool(auditService, objectMapper); + + AgentToolResult result = tool.execute(call("agent-audit-legacy"), null); + + assertTrue(result.isSuccess()); + AgentChatArtifact artifact = dryRunArtifact(result); + assertEquals("agent-audit-legacy", artifact.getData().get("auditId")); + assertEquals("chat.userBan.unban", artifact.getData().get("toolName")); + assertEquals("backend down", artifact.getData().get("errorMessage")); + assertEquals(Boolean.FALSE, artifact.getData().get("sameAuditRetryAllowed")); + assertEquals(Boolean.TRUE, artifact.getData().get("requiresNewPreview")); + assertEquals(Boolean.FALSE, artifact.getData().get("targetExecuted")); + verify(auditService).getByAuditId("agent-audit-legacy"); + verifyNoMoreInteractions(auditService); + } + + @Test + void shouldReturnErrorWhenAuditMissing() { + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("agent-audit-missing")).thenReturn(null); + AgentRecoveryDryRunAgentTool tool = new AgentRecoveryDryRunAgentTool(auditService, objectMapper); + + AgentToolResult result = tool.execute(call("agent-audit-missing"), null); + + assertFalse(result.isSuccess()); + assertTrue(result.getSummary().contains("没有找到")); + verify(auditService).getByAuditId("agent-audit-missing"); + verifyNoMoreInteractions(auditService); + } + + private AgentToolCall call(String auditId) { + AgentToolCall call = new AgentToolCall(); + call.setToolName("system.agent.recovery.dry_run"); + call.setInput(Map.of("auditId", auditId)); + return call; + } + + private AgentChatArtifact dryRunArtifact(AgentToolResult result) { + return result.getArtifacts().stream() + .filter(item -> "failureRecoveryDryRun".equals(item.getType())) + .findFirst() + .orElseThrow(); + } + + private AgentAuditResponse failedAuditWithRecoveryArtifact() { + AgentAuditResponse audit = legacyFailedAudit(); + audit.setAuditId("agent-audit-1"); + audit.setIdempotencyKey("agent-idempotency-1"); + audit.setResultJson(""" + { + "summary": "执行失败 chat.userBan.unban", + "artifacts": [ + { + "type": "failureRecovery", + "title": "失败恢复上下文", + "data": { + "auditId": "agent-audit-1", + "idempotencyKey": "agent-idempotency-1", + "toolName": "chat.userBan.unban", + "riskLevel": "medium", + "riskCategory": "WRITE", + "status": "FAILED", + "errorMessage": "backend down", + "retryPolicy": "NEW_PREVIEW_REQUIRED", + "sameAuditRetryAllowed": false, + "requiresNewPreview": true, + "recommendedActions": [ + "查询目标对象当前状态,确认是否发生部分写入。", + "修复外部依赖、权限或输入数据后,重新发起同一自然语言请求生成新的预览。" + ] + } + } + ], + "nextActions": [] + } + """); + return audit; + } + + private AgentAuditResponse legacyFailedAudit() { + AgentAuditResponse audit = new AgentAuditResponse(); + audit.setAuditId("agent-audit-legacy"); + audit.setIdempotencyKey("agent-idempotency-legacy"); + audit.setActionId("chat.userBan.unban"); + audit.setRiskLevel("medium"); + audit.setRiskCategory("WRITE"); + audit.setStatus("FAILED"); + audit.setErrorMessage("backend down"); + audit.setResultJson("{\"summary\":\"执行失败 chat.userBan.unban\",\"artifacts\":[],\"nextActions\":[]}"); + return audit; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRecoveryExplainAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRecoveryExplainAgentToolTest.java new file mode 100644 index 000000000..ad7e9e9cc --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRecoveryExplainAgentToolTest.java @@ -0,0 +1,140 @@ +package com.xiaou.system.agent.tools; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.service.SysAgentAuditService; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AgentRecoveryExplainAgentToolTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void shouldResolveFailureRecoveryQuestionWithAuditId() { + AgentRecoveryExplainAgentTool tool = new AgentRecoveryExplainAgentTool(mock(SysAgentAuditService.class), objectMapper); + + AgentToolCall call = tool.resolve("解释一下 agent-audit-1 的失败恢复上下文").orElseThrow(); + + assertEquals("system.agent.recovery.explain", call.getToolName()); + assertEquals("agent-audit-1", call.getInput().get("auditId")); + assertTrue(tool.resolve("查询操作日志").isEmpty()); + } + + @Test + void shouldExplainPersistedFailureRecoveryArtifact() { + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("agent-audit-1")).thenReturn(failedAuditWithRecoveryArtifact()); + AgentRecoveryExplainAgentTool tool = new AgentRecoveryExplainAgentTool(auditService, objectMapper); + + AgentToolResult result = tool.execute(call("agent-audit-1"), null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("agent-audit-1")); + assertTrue(result.getSummary().contains("不会自动重试或补偿")); + AgentChatArtifact artifact = result.getArtifacts().stream() + .filter(item -> "failureRecovery".equals(item.getType())) + .findFirst() + .orElseThrow(); + assertEquals("agent-audit-1", artifact.getData().get("auditId")); + assertEquals("agent-idempotency-1", artifact.getData().get("idempotencyKey")); + assertEquals("NEW_PREVIEW_REQUIRED", artifact.getData().get("retryPolicy")); + assertEquals(Boolean.FALSE, artifact.getData().get("sameAuditRetryAllowed")); + assertTrue(result.getNextActions().contains("查询目标对象当前状态,确认是否发生部分写入。")); + verify(auditService).getByAuditId("agent-audit-1"); + } + + @Test + void shouldFallbackForLegacyFailedAuditWithoutRecoveryArtifact() { + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("agent-audit-legacy")).thenReturn(legacyFailedAudit()); + AgentRecoveryExplainAgentTool tool = new AgentRecoveryExplainAgentTool(auditService, objectMapper); + + AgentToolResult result = tool.execute(call("agent-audit-legacy"), null); + + assertTrue(result.isSuccess()); + AgentChatArtifact artifact = result.getArtifacts().stream() + .filter(item -> "failureRecovery".equals(item.getType())) + .findFirst() + .orElseThrow(); + assertEquals("agent-audit-legacy", artifact.getData().get("auditId")); + assertEquals("chat.userBan.unban", artifact.getData().get("toolName")); + assertEquals("backend down", artifact.getData().get("errorMessage")); + assertEquals(Boolean.TRUE, artifact.getData().get("requiresNewPreview")); + } + + @Test + void shouldReturnErrorWhenAuditMissing() { + SysAgentAuditService auditService = mock(SysAgentAuditService.class); + when(auditService.getByAuditId("agent-audit-missing")).thenReturn(null); + AgentRecoveryExplainAgentTool tool = new AgentRecoveryExplainAgentTool(auditService, objectMapper); + + AgentToolResult result = tool.execute(call("agent-audit-missing"), null); + + assertFalse(result.isSuccess()); + assertTrue(result.getSummary().contains("没有找到")); + } + + private AgentToolCall call(String auditId) { + AgentToolCall call = new AgentToolCall(); + call.setToolName("system.agent.recovery.explain"); + call.setInput(Map.of("auditId", auditId)); + return call; + } + + private AgentAuditResponse failedAuditWithRecoveryArtifact() { + AgentAuditResponse audit = legacyFailedAudit(); + audit.setAuditId("agent-audit-1"); + audit.setIdempotencyKey("agent-idempotency-1"); + audit.setResultJson(""" + { + "summary": "执行失败 chat.userBan.unban", + "artifacts": [ + { + "type": "failureRecovery", + "title": "失败恢复上下文", + "data": { + "auditId": "agent-audit-1", + "idempotencyKey": "agent-idempotency-1", + "toolName": "chat.userBan.unban", + "errorMessage": "backend down", + "retryPolicy": "NEW_PREVIEW_REQUIRED", + "sameAuditRetryAllowed": false, + "requiresNewPreview": true, + "recommendedActions": [ + "查询目标对象当前状态,确认是否发生部分写入。", + "修复外部依赖、权限或输入数据后,重新发起同一自然语言请求生成新的预览。" + ] + } + } + ], + "nextActions": [] + } + """); + return audit; + } + + private AgentAuditResponse legacyFailedAudit() { + AgentAuditResponse audit = new AgentAuditResponse(); + audit.setAuditId("agent-audit-legacy"); + audit.setIdempotencyKey("agent-idempotency-legacy"); + audit.setActionId("chat.userBan.unban"); + audit.setRiskLevel("medium"); + audit.setRiskCategory("WRITE"); + audit.setStatus("FAILED"); + audit.setErrorMessage("backend down"); + audit.setResultJson("{\"summary\":\"执行失败 chat.userBan.unban\",\"artifacts\":[],\"nextActions\":[]}"); + return audit; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRequestDryRunAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRequestDryRunAgentToolTest.java new file mode 100644 index 000000000..eda60b3be --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRequestDryRunAgentToolTest.java @@ -0,0 +1,226 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentOperator; +import com.xiaou.system.agent.AgentPlanResolution; +import com.xiaou.system.agent.AgentPlanResolver; +import com.xiaou.system.agent.AgentResolvedToolCall; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.dto.AgentChatDiffItem; +import com.xiaou.system.dto.AgentChatPlanStep; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentRequestDryRunAgentToolTest { + + @Test + void shouldResolveRequestDryRunAndExtractTargetMessage() { + AgentRequestDryRunAgentTool tool = new AgentRequestDryRunAgentTool(provider(null), new com.xiaou.system.agent.AgentPolicyEngine()); + + AgentToolCall call = tool.resolve("运行时预演:帮我查最近3条操作日志").orElseThrow(); + + assertEquals("system.agent.request.dry_run", call.getToolName()); + assertEquals("帮我查最近3条操作日志", call.getInput().get("message")); + assertTrue(tool.resolve("planner 预演:帮我查日志").isEmpty()); + assertTrue(tool.resolve("对 chat.userBan.unban 做策略预检").isEmpty()); + } + + @Test + void shouldReportReadonlyRequestWithoutExecutingTargetTool() { + AgentPlanResolver resolver = mock(AgentPlanResolver.class); + TrackingAgentTool targetTool = new TrackingAgentTool(readonlyDefinition()); + AgentToolCall targetCall = targetCall("system.operationLog.list", Map.of("pageSize", 3)); + when(resolver.resolvePlan(argThat((AgentExecutionContext context) -> "帮我查最近3条操作日志".equals(context.message())))) + .thenReturn(AgentPlanResolution.resolved(new AgentResolvedToolCall(targetTool, targetCall))); + AgentRequestDryRunAgentTool tool = new AgentRequestDryRunAgentTool(provider(resolver), new com.xiaou.system.agent.AgentPolicyEngine()); + + AgentToolResult result = tool.execute(call(tool, "帮我查最近3条操作日志"), + new AgentExecutionContext("session-1", "运行时预演", operator("agent:system:operation-log:read"))); + + assertTrue(result.isSuccess()); + assertFalse(targetTool.previewCalled); + assertFalse(targetTool.executeCalled); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentRequestDryRun", artifact.getType()); + assertEquals("WOULD_EXECUTE_READONLY", artifact.getData().get("status")); + assertEquals("system.operationLog.list", artifact.getData().get("toolName")); + assertEquals(true, artifact.getData().get("policyAllowed")); + assertEquals(false, artifact.getData().get("confirmationRequired")); + assertEquals(false, artifact.getData().get("targetExecuted")); + assertEquals(false, artifact.getData().get("auditCreated")); + } + + @Test + void shouldPreviewWriteRequestWithoutExecutingOrAuditingTargetTool() { + AgentPlanResolver resolver = mock(AgentPlanResolver.class); + TrackingAgentTool targetTool = new TrackingAgentTool(writeDefinition()); + AgentToolCall targetCall = targetCall("chat.userBan.unban", Map.of("userId", 88)); + when(resolver.resolvePlan(argThat((AgentExecutionContext context) -> "帮我把用户88解除禁言".equals(context.message())))) + .thenReturn(AgentPlanResolution.resolved(new AgentResolvedToolCall(targetTool, targetCall))); + AgentRequestDryRunAgentTool tool = new AgentRequestDryRunAgentTool(provider(resolver), new com.xiaou.system.agent.AgentPolicyEngine()); + + AgentToolResult result = tool.execute(call(tool, "帮我把用户88解除禁言"), + new AgentExecutionContext("session-1", "运行时预演", operator("agent:chat:user-ban:write"))); + + assertTrue(result.isSuccess()); + assertTrue(targetTool.previewCalled); + assertFalse(targetTool.executeCalled); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("CONFIRM_REQUIRED", artifact.getData().get("status")); + assertEquals(true, artifact.getData().get("policyAllowed")); + assertEquals(true, artifact.getData().get("confirmationRequired")); + assertEquals("确认解除禁言", artifact.getData().get("confirmationText")); + assertEquals(true, artifact.getData().get("targetPreviewed")); + assertEquals(false, artifact.getData().get("targetExecuted")); + assertEquals(false, artifact.getData().get("auditCreated")); + assertTrue(result.getSummary().contains("需要强确认")); + assertEquals(1, result.getDiff().size()); + } + + @Test + void shouldRejectByPolicyWithoutPreviewingTargetTool() { + AgentPlanResolver resolver = mock(AgentPlanResolver.class); + TrackingAgentTool targetTool = new TrackingAgentTool(writeDefinition()); + AgentToolCall targetCall = targetCall("chat.userBan.unban", Map.of("userId", 88)); + when(resolver.resolvePlan(argThat((AgentExecutionContext context) -> "帮我把用户88解除禁言".equals(context.message())))) + .thenReturn(AgentPlanResolution.resolved(new AgentResolvedToolCall(targetTool, targetCall))); + AgentRequestDryRunAgentTool tool = new AgentRequestDryRunAgentTool(provider(resolver), new com.xiaou.system.agent.AgentPolicyEngine()); + + AgentToolResult result = tool.execute(call(tool, "帮我把用户88解除禁言"), + new AgentExecutionContext("session-1", "运行时预演", operator("agent:runtime:policy:read"))); + + assertTrue(result.isSuccess()); + assertFalse(targetTool.previewCalled); + assertFalse(targetTool.executeCalled); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("POLICY_REJECTED", artifact.getData().get("status")); + assertEquals(false, artifact.getData().get("policyAllowed")); + assertTrue(String.valueOf(artifact.getData().get("rejectionReason")).contains("agent:chat:user-ban:write")); + } + + @Test + void shouldReportClarificationAndEmptyPlannerOutcomes() { + AgentPlanResolver resolver = mock(AgentPlanResolver.class); + when(resolver.resolvePlan("帮我解除禁言")) + .thenReturn(AgentPlanResolution.clarification("还需要补充这些信息后才能继续:userId", List.of("请补充字段:userId"))); + when(resolver.resolvePlan("随便聊聊")).thenReturn(AgentPlanResolution.empty()); + AgentRequestDryRunAgentTool tool = new AgentRequestDryRunAgentTool(provider(resolver), new com.xiaou.system.agent.AgentPolicyEngine()); + + AgentToolResult clarification = tool.execute(call(tool, "帮我解除禁言"), null); + AgentToolResult empty = tool.execute(call(tool, "随便聊聊"), null); + + assertTrue(clarification.isSuccess()); + assertEquals("CLARIFICATION", clarification.getArtifacts().get(0).getData().get("status")); + assertTrue(empty.isSuccess()); + assertEquals("EMPTY", empty.getArtifacts().get(0).getData().get("status")); + } + + private AgentToolCall call(AgentRequestDryRunAgentTool tool, String message) { + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of("message", message)); + return call; + } + + private AgentToolCall targetCall(String toolName, Map input) { + AgentToolCall call = new AgentToolCall(); + call.setToolName(toolName); + call.setSummary("target candidate"); + call.setInput(input); + return call; + } + + private AgentOperator operator(String... permissions) { + return new AgentOperator(7L, "Alice", "tenant-a", List.of("SUPER_ADMIN"), List.of(permissions)); + } + + @SuppressWarnings("unchecked") + private ObjectProvider provider(AgentPlanResolver resolver) { + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(resolver); + return provider; + } + + private AgentToolDefinition readonlyDefinition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.operationLog.list"); + definition.setTitle("查询操作日志"); + definition.setDescription("分页查询系统操作日志"); + definition.setIntent("system.operationLog.list"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of("pageSize", Map.of("type", "integer", "minimum", 1))); + definition.setRequiredPermissions(List.of("agent:system:operation-log:read")); + return definition; + } + + private AgentToolDefinition writeDefinition() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("chat.userBan.unban"); + definition.setTitle("解除聊天禁言"); + definition.setDescription("解除指定用户当前生效的聊天禁言"); + definition.setIntent("chat.userBan.unban"); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("medium"); + definition.setRiskCategory("WRITE"); + definition.setConfirmationRequired(true); + definition.setConfirmationText("确认解除禁言"); + definition.setInputSchema(Map.of("userId", Map.of("type", "integer", "minimum", 1))); + definition.setRequiredInputKeys(List.of("userId")); + definition.setRequiredPermissions(List.of("agent:chat:user-ban:write")); + return definition; + } + + private static class TrackingAgentTool implements AgentTool { + private final AgentToolDefinition definition; + private boolean previewCalled; + private boolean executeCalled; + + private TrackingAgentTool(AgentToolDefinition definition) { + this.definition = definition; + } + + @Override + public AgentToolDefinition definition() { + return definition; + } + + @Override + public Optional resolve(String message) { + return Optional.empty(); + } + + @Override + public AgentToolPreview preview(AgentToolCall call, AgentExecutionContext context) { + previewCalled = true; + AgentToolPreview preview = new AgentToolPreview(); + preview.setSummary("将解除用户 88 的聊天禁言。"); + preview.getPlan().add(new AgentChatPlanStep("preview", "预演解除禁言", "done")); + preview.getDiff().add(new AgentChatDiffItem("chat.userBan", "before", "muted", "active")); + return preview; + } + + @Override + public AgentToolResult execute(AgentToolCall call, AgentExecutionContext context) { + executeCalled = true; + throw new AssertionError("request dry-run must not execute target tool"); + } + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRuntimeObservabilityAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRuntimeObservabilityAgentToolTest.java new file mode 100644 index 000000000..7f1aa3fa5 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRuntimeObservabilityAgentToolTest.java @@ -0,0 +1,175 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentSessionProperties; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolMetricSample; +import com.xiaou.system.agent.AgentToolMetricsRecorder; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentRuntimeObservabilityAgentToolTest { + + @Test + void shouldResolveRuntimeObservabilityQuestion() { + AgentRuntimeObservabilityAgentTool tool = new AgentRuntimeObservabilityAgentTool( + mock(AgentToolCatalogService.class), + new AgentSessionProperties(), + new SimpleMeterRegistry() + ); + + AgentToolCall call = tool.resolve("智能体运行时观测快照和告警总览").orElseThrow(); + + assertEquals("system.agent.runtime.observability", call.getToolName()); + assertTrue(call.getInput().isEmpty()); + assertTrue(tool.resolve("智能体工具调用指标怎么样").isEmpty()); + assertTrue(tool.resolve("查询操作日志").isEmpty()); + } + + @Test + void shouldWarnWhenMetricsAreMissing() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of( + readonlyTool("system.agent.metrics.summary"), + writeTool("chat.userBan.unban") + )); + AgentSessionProperties properties = new AgentSessionProperties(); + properties.setRepository("db"); + AgentRuntimeObservabilityAgentTool tool = new AgentRuntimeObservabilityAgentTool( + catalogService, + properties, + new SimpleMeterRegistry() + ); + + AgentToolResult result = tool.execute(call(tool), null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("WARN")); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentRuntimeObservability", artifact.getType()); + assertEquals("WARN", artifact.getData().get("status")); + + Map tools = (Map) artifact.getData().get("tools"); + Map metrics = (Map) artifact.getData().get("metrics"); + Map alerts = (Map) artifact.getData().get("alerts"); + assertEquals(2, tools.get("toolCount")); + assertEquals(0D, metrics.get("invocationCount")); + assertEquals("WARN", metrics.get("status")); + assertEquals("WARN", alerts.get("status")); + assertFalse(result.getNextActions().isEmpty()); + } + + @Test + void shouldReportOkWhenRuntimeMetricsAreHealthy() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of(readonlyTool("system.operationLog.list"))); + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder recorder = new AgentToolMetricsRecorder(meterRegistry); + recorder.recordInvocation(sample("system.operationLog.list", "execute", "success", "readonly", "READONLY", 12_000_000L)); + AgentRuntimeObservabilityAgentTool tool = new AgentRuntimeObservabilityAgentTool( + catalogService, + new AgentSessionProperties(), + meterRegistry + ); + + AgentToolResult result = tool.execute(call(tool), null); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("OK", artifact.getData().get("status")); + Map metrics = (Map) artifact.getData().get("metrics"); + Map alerts = (Map) artifact.getData().get("alerts"); + assertEquals("HEALTHY", metrics.get("status")); + assertEquals("OK", alerts.get("status")); + assertEquals(0, alerts.get("alertCount")); + } + + @Test + void shouldReportAlertWhenErrorMetricsExist() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of(readonlyTool("system.operationLog.list"))); + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder recorder = new AgentToolMetricsRecorder(meterRegistry); + recorder.recordInvocation(sample("system.operationLog.list", "execute", "error", "readonly", "READONLY", 18_000_000L)); + AgentRuntimeObservabilityAgentTool tool = new AgentRuntimeObservabilityAgentTool( + catalogService, + new AgentSessionProperties(), + meterRegistry + ); + + AgentToolResult result = tool.execute(call(tool), null); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("ALERT", artifact.getData().get("status")); + Map metrics = (Map) artifact.getData().get("metrics"); + Map alerts = (Map) artifact.getData().get("alerts"); + assertEquals(1D, metrics.get("errorCount")); + assertEquals(1, alerts.get("alertCount")); + assertTrue(String.valueOf(alerts.get("alerts")).contains("agent.tool.errors")); + } + + private AgentToolCall call(AgentRuntimeObservabilityAgentTool tool) { + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of()); + return call; + } + + private AgentToolDefinition readonlyTool(String name) { + AgentToolDefinition definition = baseTool(name); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setRequiredPermissions(List.of("agent:runtime:status:read")); + return definition; + } + + private AgentToolDefinition writeTool(String name) { + AgentToolDefinition definition = baseTool(name); + definition.setRiskLevel("medium"); + definition.setRiskCategory("WRITE"); + definition.setDestructive(true); + definition.setRequiredPermissions(List.of("agent:test:write")); + return definition; + } + + private AgentToolDefinition baseTool(String name) { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName(name); + definition.setTitle(name); + definition.setDescription("测试工具"); + definition.setIntent(name); + definition.setRoute("/admin/agent/chat"); + definition.setInputSchema(Map.of()); + definition.setRequiredInputKeys(List.of()); + return definition; + } + + private AgentToolMetricSample sample( + String toolName, + String phase, + String outcome, + String riskLevel, + String riskCategory, + long durationNanos + ) { + AgentToolMetricSample sample = new AgentToolMetricSample(); + sample.setToolName(toolName); + sample.setPhase(phase); + sample.setOutcome(outcome); + sample.setRiskLevel(riskLevel); + sample.setRiskCategory(riskCategory); + sample.setDurationNanos(durationNanos); + return sample; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRuntimeReadinessAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRuntimeReadinessAgentToolTest.java new file mode 100644 index 000000000..38ca01f1f --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRuntimeReadinessAgentToolTest.java @@ -0,0 +1,159 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentSessionProperties; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentRuntimeReadinessAgentToolTest { + + @Test + void shouldResolveRuntimeReadinessQuestion() { + AgentRuntimeReadinessAgentTool tool = new AgentRuntimeReadinessAgentTool( + mock(AgentToolCatalogService.class), + new AgentSessionProperties() + ); + + AgentToolCall call = tool.resolve("帮我做一下智能体运行时上线 readiness 自检").orElseThrow(); + + assertEquals("system.agent.runtime.readiness", call.getToolName()); + assertTrue(call.getInput().isEmpty()); + assertTrue(tool.resolve("智能体现在状态怎么样").isEmpty()); + } + + @Test + void shouldReportReadyWhenCoreRuntimeIsComplete() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(completeCoreDefinitions()); + AgentSessionProperties properties = new AgentSessionProperties(); + properties.setRepository("db"); + AgentRuntimeReadinessAgentTool tool = new AgentRuntimeReadinessAgentTool(catalogService, properties); + + AgentToolResult result = tool.execute(call(tool), null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("READY")); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentRuntimeReadiness", artifact.getType()); + assertEquals("READY", artifact.getData().get("status")); + assertEquals(0, artifact.getData().get("blockedCount")); + assertEquals(0, artifact.getData().get("warnCount")); + assertTrue(String.valueOf(artifact.getData().get("checks")).contains("coreTools.present")); + } + + @Test + void shouldBlockWhenCoreToolsAreMissing() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of(readonlyTool("system.agent.tools.list"))); + AgentRuntimeReadinessAgentTool tool = new AgentRuntimeReadinessAgentTool(catalogService, new AgentSessionProperties()); + + AgentToolResult result = tool.execute(call(tool), null); + + assertTrue(result.isSuccess()); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("BLOCKED", artifact.getData().get("status")); + assertTrue(((Number) artifact.getData().get("blockedCount")).intValue() >= 1); + assertTrue(result.getNextActions().contains("先补齐 BLOCKED 检查项,再继续扩展业务工具。")); + } + + @Test + void shouldWarnForInvalidSessionConfigurationWithoutBlockingToolCatalog() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(completeCoreDefinitions()); + AgentSessionProperties properties = new AgentSessionProperties(); + properties.setRepository("filesystem"); + properties.setMaxRecentTurns(0); + AgentRuntimeReadinessAgentTool tool = new AgentRuntimeReadinessAgentTool(catalogService, properties); + + AgentToolResult result = tool.execute(call(tool), null); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("WARN", artifact.getData().get("status")); + assertEquals(0, artifact.getData().get("blockedCount")); + assertTrue(((Number) artifact.getData().get("warnCount")).intValue() >= 1); + assertTrue(String.valueOf(artifact.getData().get("checks")).contains("session.repository")); + assertTrue(result.getNextActions().contains("修复 WARN 检查项后再做上线验收。")); + } + + @Test + void shouldBlockInvalidWriteToolWithoutConfirmation() { + List definitions = new ArrayList<>(completeCoreDefinitions()); + AgentToolDefinition writeTool = readonlyTool("chat.userBan.unban"); + writeTool.setRiskLevel("medium"); + writeTool.setRiskCategory("WRITE"); + writeTool.setRequiredPermissions(List.of("agent:chat:user-ban:write")); + writeTool.setConfirmationRequired(false); + definitions.add(writeTool); + + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(definitions); + AgentRuntimeReadinessAgentTool tool = new AgentRuntimeReadinessAgentTool(catalogService, new AgentSessionProperties()); + + AgentToolResult result = tool.execute(call(tool), null); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("BLOCKED", artifact.getData().get("status")); + assertTrue(String.valueOf(artifact.getData().get("checks")).contains("toolDefinitions.writeConfirmation")); + } + + private AgentToolCall call(AgentRuntimeReadinessAgentTool tool) { + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of()); + return call; + } + + private List completeCoreDefinitions() { + return List.of( + readonlyTool("system.agent.tools.list"), + readonlyTool("system.agent.tools.detail"), + readonlyTool("system.agent.tools.search"), + readonlyTool("system.agent.tools.validate"), + readonlyTool("system.agent.tools.access_check"), + readonlyTool("system.agent.runtime.status"), + readonlyTool("system.agent.runtime.readiness"), + readonlyTool("system.agent.runtime.observability"), + readonlyTool("system.agent.session.context"), + readonlyTool("system.agent.operator.self"), + readonlyTool("system.agent.planner.diagnostics"), + readonlyTool("system.agent.planner.dry_run"), + readonlyTool("system.agent.request.dry_run"), + readonlyTool("system.agent.policy.dry_run"), + readonlyTool("system.agent.metrics.summary"), + readonlyTool("system.agent.metrics.health"), + readonlyTool("system.agent.metrics.alerts"), + readonlyTool("system.agent.audit.list"), + readonlyTool("system.agent.audit.detail"), + readonlyTool("system.agent.recovery.explain"), + readonlyTool("system.agent.recovery.dry_run") + ); + } + + private AgentToolDefinition readonlyTool(String name) { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName(name); + definition.setTitle(name); + definition.setDescription("测试工具"); + definition.setIntent(name); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of()); + definition.setRequiredInputKeys(List.of()); + definition.setRequiredPermissions(List.of("agent:runtime:status:read")); + return definition; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRuntimeStatusAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRuntimeStatusAgentToolTest.java new file mode 100644 index 000000000..7fbe3e306 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentRuntimeStatusAgentToolTest.java @@ -0,0 +1,100 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentSessionProperties; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentRuntimeStatusAgentToolTest { + + @Test + void shouldResolveRuntimeStatusQuestion() { + AgentRuntimeStatusAgentTool tool = new AgentRuntimeStatusAgentTool( + mock(AgentToolCatalogService.class), + new AgentSessionProperties() + ); + + AgentToolCall call = tool.resolve("智能体现在状态怎么样").orElseThrow(); + + assertEquals("system.agent.runtime.status", call.getToolName()); + assertTrue(call.getInput().isEmpty()); + assertTrue(tool.resolve("查询操作日志").isEmpty()); + } + + @Test + void shouldNotResolveMoreSpecificRuntimeChecks() { + AgentRuntimeStatusAgentTool tool = new AgentRuntimeStatusAgentTool( + mock(AgentToolCatalogService.class), + new AgentSessionProperties() + ); + + assertTrue(tool.resolve("智能体工具调用健康和告警情况怎么样").isEmpty()); + assertTrue(tool.resolve("智能体上线前就绪状态自检").isEmpty()); + } + + @Test + void shouldReturnRuntimeStatusArtifact() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of( + definition("system.agent.tools.list", "readonly", "READONLY", false), + definition("chat.userBan.unban", "medium", "WRITE", false), + definition("system.operationLog.cleanExpired", "high", "DESTRUCTIVE", true) + )); + AgentSessionProperties properties = new AgentSessionProperties(); + properties.setRepository("redis"); + properties.setMaxRecentTurns(8); + properties.setRedisKeyPrefix("test:agent"); + properties.setTtlSeconds(120L); + AgentRuntimeStatusAgentTool tool = new AgentRuntimeStatusAgentTool(catalogService, properties); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of()); + AgentToolResult result = tool.execute(call, null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("已注册 3 个后端工具")); + assertEquals(1, result.getArtifacts().size()); + assertFalse(result.getNextActions().isEmpty()); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentRuntimeStatus", artifact.getType()); + assertEquals("UP", artifact.getData().get("status")); + assertEquals(3, artifact.getData().get("toolCount")); + assertEquals(1, artifact.getData().get("readonlyToolCount")); + assertEquals(2, artifact.getData().get("writeRiskToolCount")); + assertEquals(1, artifact.getData().get("destructiveToolCount")); + assertEquals(Map.of( + "repository", "redis", + "maxRecentTurns", 8, + "ttlSeconds", 120L, + "redisKeyPrefix", "test:agent" + ), artifact.getData().get("session")); + } + + private AgentToolDefinition definition(String name, String riskLevel, String riskCategory, boolean destructive) { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName(name); + definition.setTitle(name); + definition.setDescription("测试工具"); + definition.setIntent(name); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel(riskLevel); + definition.setRiskCategory(riskCategory); + definition.setDestructive(destructive); + definition.setRequiredPermissions(List.of("agent:test")); + return definition; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentSessionContextAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentSessionContextAgentToolTest.java new file mode 100644 index 000000000..13f1cd0db --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentSessionContextAgentToolTest.java @@ -0,0 +1,99 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentRuntimeTrace; +import com.xiaou.system.agent.AgentSessionProperties; +import com.xiaou.system.agent.AgentSessionSnapshot; +import com.xiaou.system.agent.AgentSessionTurn; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AgentSessionContextAgentToolTest { + + @Test + void shouldResolveCurrentSessionContextQuestion() { + AgentSessionContextAgentTool tool = new AgentSessionContextAgentTool(new AgentSessionProperties()); + + AgentToolCall call = tool.resolve("你现在记住了什么").orElseThrow(); + + assertEquals("system.agent.session.context", call.getToolName()); + assertTrue(call.getInput().isEmpty()); + assertTrue(tool.resolve("查询操作日志").isEmpty()); + } + + @Test + void shouldReturnCurrentSessionSnapshotArtifact() { + AgentSessionProperties properties = new AgentSessionProperties(); + properties.setMaxRecentTurns(8); + AgentSessionContextAgentTool tool = new AgentSessionContextAgentTool(properties); + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of()); + + AgentToolResult result = tool.execute(call, context(snapshot("session-1", List.of( + turn("查最近3条操作日志", "answered", "已查询到 3 条", "system.operationLog.list", "", "trace-1"), + turn("解除用户88禁言", "confirm_required", "请输入强确认", "chat.userBan.unban", "audit-1", "trace-2") + )))); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("已保留 2 轮")); + assertEquals(1, result.getArtifacts().size()); + assertFalse(result.getNextActions().isEmpty()); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentSessionContext", artifact.getType()); + assertEquals("session-1", artifact.getData().get("sessionId")); + assertEquals(2, artifact.getData().get("recentTurnCount")); + assertEquals(8, artifact.getData().get("maxRecentTurns")); + List turns = (List) artifact.getData().get("recentTurns"); + assertEquals(2, turns.size()); + assertEquals("system.operationLog.list", ((Map) turns.get(0)).get("toolName")); + assertEquals("audit-1", ((Map) turns.get(1)).get("auditId")); + } + + @Test + void shouldHandleMissingContextAsEmptySession() { + AgentSessionContextAgentTool tool = new AgentSessionContextAgentTool(new AgentSessionProperties()); + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of()); + + AgentToolResult result = tool.execute(call, null); + + assertTrue(result.isSuccess()); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("", artifact.getData().get("sessionId")); + assertEquals(0, artifact.getData().get("recentTurnCount")); + } + + private AgentExecutionContext context(AgentSessionSnapshot snapshot) { + return new AgentExecutionContext("session-1", "你现在记住了什么", null, AgentRuntimeTrace.start(), snapshot); + } + + private AgentSessionSnapshot snapshot(String sessionId, List turns) { + AgentSessionSnapshot snapshot = new AgentSessionSnapshot(); + snapshot.setSessionId(sessionId); + snapshot.setRecentTurns(turns); + return snapshot; + } + + private AgentSessionTurn turn(String message, String status, String answer, String toolName, String auditId, String traceId) { + AgentSessionTurn turn = new AgentSessionTurn(); + turn.setMessage(message); + turn.setStatus(status); + turn.setAnswer(answer); + turn.setToolName(toolName); + turn.setAuditId(auditId); + turn.setTraceId(traceId); + return turn; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolAccessCheckAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolAccessCheckAgentToolTest.java new file mode 100644 index 000000000..58f95d47c --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolAccessCheckAgentToolTest.java @@ -0,0 +1,139 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentOperator; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentToolAccessCheckAgentToolTest { + + @Test + void shouldResolveCurrentOperatorToolAccessQuestion() { + AgentToolAccessCheckAgentTool tool = new AgentToolAccessCheckAgentTool(catalogService( + definition("chat.userBan.unban", List.of("agent:chat:user-ban:write"), List.of("SUPER_ADMIN")) + )); + + AgentToolCall call = tool.resolve("我能不能用 chat.userBan.unban 这个工具").orElseThrow(); + + assertEquals("system.agent.tools.access_check", call.getToolName()); + assertEquals("chat.userBan.unban", call.getInput().get("toolName")); + assertTrue(tool.resolve("chat.userBan.unban 这个工具需要什么权限").isEmpty()); + } + + @Test + void shouldReportAllowedAccessForCurrentOperator() { + AgentToolAccessCheckAgentTool tool = new AgentToolAccessCheckAgentTool(catalogService( + definition("chat.userBan.unban", List.of("agent:chat:user-ban:write"), List.of("SUPER_ADMIN")) + )); + AgentToolCall call = call(tool, "chat.userBan.unban"); + AgentOperator operator = new AgentOperator( + 7L, + "Alice", + "tenant-a", + List.of("SUPER_ADMIN"), + List.of("agent:chat:user-ban:write", "agent:runtime:tool-access:read") + ); + + AgentToolResult result = tool.execute(call, new AgentExecutionContext("session-1", "我能不能用", operator)); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("可以访问")); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentToolAccessCheck", artifact.getType()); + assertEquals("chat.userBan.unban", artifact.getData().get("toolName")); + assertEquals(true, artifact.getData().get("accessAllowed")); + assertEquals(List.of(), artifact.getData().get("missingPermissions")); + assertEquals(List.of(), artifact.getData().get("missingRoles")); + assertEquals(false, artifact.getData().get("tenantRuntimeCheckRequired")); + assertEquals(true, artifact.getData().get("requiresConfirmation")); + } + + @Test + void shouldReportMissingPermissionsAndRolesForCurrentOperator() { + AgentToolAccessCheckAgentTool tool = new AgentToolAccessCheckAgentTool(catalogService( + definition("chat.userBan.unban", List.of("agent:chat:user-ban:write"), List.of("SUPER_ADMIN", "OPS")) + )); + AgentToolCall call = call(tool, "chat.userBan.unban"); + AgentOperator operator = new AgentOperator(7L, "Alice", "tenant-a", List.of("AUDITOR"), List.of("agent:runtime:tool-access:read")); + + AgentToolResult result = tool.execute(call, new AgentExecutionContext("session-1", "我能不能用", operator)); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("暂时不能访问")); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals(false, artifact.getData().get("accessAllowed")); + assertEquals(List.of("agent:chat:user-ban:write"), artifact.getData().get("missingPermissions")); + assertEquals(List.of("SUPER_ADMIN", "OPS"), artifact.getData().get("missingRoles")); + } + + @Test + void shouldReportTenantRuntimeCheckForSameTenantTool() { + AgentToolDefinition definition = definition("tenant.tool", List.of("agent:tenant:read"), List.of()); + definition.setTenantScope("SAME_TENANT"); + AgentToolAccessCheckAgentTool tool = new AgentToolAccessCheckAgentTool(catalogService(definition)); + AgentOperator operator = new AgentOperator(7L, "Alice", "tenant-a", List.of(), List.of("agent:tenant:read")); + + AgentToolResult result = tool.execute(call(tool, "tenant.tool"), new AgentExecutionContext("session-1", "能不能用", operator)); + + assertTrue(result.isSuccess()); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals(true, artifact.getData().get("tenantRuntimeCheckRequired")); + assertEquals(true, artifact.getData().get("accessAllowed")); + assertTrue(((List) artifact.getData().get("notes")).contains("该工具要求 SAME_TENANT,真实执行时还会校验输入 tenantId 与当前操作者租户一致。")); + } + + @Test + void shouldReturnErrorWhenTargetToolMissing() { + AgentToolAccessCheckAgentTool tool = new AgentToolAccessCheckAgentTool(catalogService()); + + AgentToolResult result = tool.execute(call(tool, "system.unknown"), null); + + assertFalse(result.isSuccess()); + assertTrue(result.getSummary().contains("没有找到")); + assertEquals("后端智能体工具不存在: system.unknown", result.getErrorMessage()); + } + + private AgentToolCall call(AgentToolAccessCheckAgentTool tool, String toolName) { + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of("toolName", toolName)); + return call; + } + + private AgentToolCatalogService catalogService(AgentToolDefinition... definitions) { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of(definitions)); + return catalogService; + } + + private AgentToolDefinition definition(String name, List permissions, List roles) { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName(name); + definition.setTitle(name); + definition.setDescription(name); + definition.setIntent(name); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("medium"); + definition.setRiskCategory("WRITE"); + definition.setConfirmationRequired(true); + definition.setConfirmationText("确认执行"); + definition.setInputSchema(Map.of("userId", Map.of("type", "integer", "minimum", 1))); + definition.setRequiredInputKeys(List.of("userId")); + definition.setRequiredPermissions(permissions); + definition.setRequiredRoles(roles); + return definition; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolAccessDefinitionTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolAccessDefinitionTest.java new file mode 100644 index 000000000..b02366f57 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolAccessDefinitionTest.java @@ -0,0 +1,277 @@ +package com.xiaou.system.agent.tools; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.chat.service.ChatUserBanService; +import com.xiaou.points.service.LotteryAdminService; +import com.xiaou.system.agent.AgentPlanResolver; +import com.xiaou.system.agent.AgentSessionProperties; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.service.SysAgentAuditService; +import com.xiaou.system.service.SysOperationLogService; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +class AgentToolAccessDefinitionTest { + + private final SysOperationLogService operationLogService = mock(SysOperationLogService.class); + private final ChatUserBanService chatUserBanService = mock(ChatUserBanService.class); + private final LotteryAdminService lotteryAdminService = mock(LotteryAdminService.class); + private final SysAgentAuditService auditService = mock(SysAgentAuditService.class); + private final AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + private final AgentSessionProperties sessionProperties = new AgentSessionProperties(); + private final ObjectProvider planResolverProvider = planResolverProvider(); + + @Test + void agentToolCatalogShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentToolCatalogAgentTool(catalogService).definition(); + + assertEquals(List.of("agent:runtime:tool-catalog:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentToolDetailShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentToolDetailAgentTool(catalogService).definition(); + + assertEquals(List.of("agent:runtime:tool-catalog:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentToolSearchShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentToolSearchAgentTool(catalogService).definition(); + + assertEquals(List.of("agent:runtime:tool-catalog:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentToolDefinitionValidateShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentToolDefinitionValidateAgentTool(catalogService).definition(); + + assertEquals(List.of("agent:runtime:tool-catalog:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentToolAccessCheckShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentToolAccessCheckAgentTool(catalogService).definition(); + + assertEquals(List.of("agent:runtime:tool-access:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentRuntimeStatusShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentRuntimeStatusAgentTool(catalogService, sessionProperties).definition(); + + assertEquals(List.of("agent:runtime:status:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentRuntimeReadinessShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentRuntimeReadinessAgentTool(catalogService, sessionProperties).definition(); + + assertEquals(List.of("agent:runtime:readiness:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentRuntimeObservabilityShouldDeclareReadonlyAgentPermissions() { + AgentToolDefinition definition = new AgentRuntimeObservabilityAgentTool( + catalogService, + sessionProperties, + new SimpleMeterRegistry() + ).definition(); + + assertEquals(List.of("agent:runtime:status:read", "agent:runtime:metrics:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentSessionContextShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentSessionContextAgentTool(sessionProperties).definition(); + + assertEquals(List.of("agent:runtime:session:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentOperatorSelfShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentOperatorSelfAgentTool().definition(); + + assertEquals(List.of("agent:runtime:operator:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentPlannerDiagnosticsShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentPlannerDiagnosticsAgentTool(catalogService).definition(); + + assertEquals(List.of("agent:runtime:planner:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentPlannerDryRunShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentPlannerDryRunAgentTool(planResolverProvider).definition(); + + assertEquals(List.of("agent:runtime:planner:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentRequestDryRunShouldDeclareReadonlyAgentPermissions() { + AgentToolDefinition definition = new AgentRequestDryRunAgentTool( + planResolverProvider, + new com.xiaou.system.agent.AgentPolicyEngine() + ).definition(); + + assertEquals(List.of("agent:runtime:planner:read", "agent:runtime:policy:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentPolicyDryRunShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentPolicyDryRunAgentTool(catalogService, new com.xiaou.system.agent.AgentPolicyEngine()).definition(); + + assertEquals(List.of("agent:runtime:policy:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentToolMetricsSummaryShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentToolMetricsSummaryAgentTool(new SimpleMeterRegistry()).definition(); + + assertEquals(List.of("agent:runtime:metrics:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentToolMetricsHealthShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentToolMetricsHealthAgentTool(new SimpleMeterRegistry()).definition(); + + assertEquals(List.of("agent:runtime:metrics:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentToolMetricsAlertsShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentToolMetricsAlertsAgentTool(new SimpleMeterRegistry()).definition(); + + assertEquals(List.of("agent:runtime:metrics:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentAuditListShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentAuditListAgentTool(auditService).definition(); + + assertEquals(List.of("agent:runtime:audit:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentAuditDetailShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentAuditDetailAgentTool(auditService).definition(); + + assertEquals(List.of("agent:runtime:audit:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentRecoveryExplainShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentRecoveryExplainAgentTool(auditService, new ObjectMapper()).definition(); + + assertEquals(List.of("agent:runtime:audit:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void agentRecoveryDryRunShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new AgentRecoveryDryRunAgentTool(auditService, new ObjectMapper()).definition(); + + assertEquals(List.of("agent:runtime:audit:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void operationLogListShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new OperationLogListAgentTool(operationLogService).definition(); + + assertEquals(List.of("agent:system:operation-log:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void operationLogCleanShouldDeclareDestructiveAgentPermission() { + AgentToolDefinition definition = new OperationLogCleanAgentTool(operationLogService).definition(); + + assertEquals(List.of("agent:system:operation-log:clean"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void chatUserBanStatusShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new ChatUserBanStatusAgentTool(chatUserBanService).definition(); + + assertEquals(List.of("agent:chat:user-ban:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void chatUserUnbanShouldDeclareWriteAgentPermission() { + AgentToolDefinition definition = new ChatUserUnbanAgentTool(chatUserBanService).definition(); + + assertEquals(List.of("agent:chat:user-ban:write"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @Test + void lotteryRealtimeMonitorShouldDeclareReadonlyAgentPermission() { + AgentToolDefinition definition = new LotteryRealtimeMonitorAgentTool(lotteryAdminService).definition(); + + assertEquals(List.of("agent:points:lottery:monitor:read"), definition.getRequiredPermissions()); + assertTrue(definition.getRequiredRoles().isEmpty()); + assertEquals("ANY", definition.getTenantScope()); + } + + @SuppressWarnings("unchecked") + private ObjectProvider planResolverProvider() { + return mock(ObjectProvider.class); + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolCatalogAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolCatalogAgentToolTest.java new file mode 100644 index 000000000..d3832d10a --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolCatalogAgentToolTest.java @@ -0,0 +1,60 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolResult; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentToolCatalogAgentToolTest { + + @Test + void shouldResolveCapabilityQuestion() { + AgentToolCatalogAgentTool tool = new AgentToolCatalogAgentTool(mock(AgentToolCatalogService.class)); + + assertTrue(tool.resolve("你能做什么").isPresent()); + assertTrue(tool.resolve("有哪些工具可以用").isPresent()); + assertTrue(tool.resolve("查询操作日志").isEmpty()); + } + + @Test + void shouldReturnRegisteredToolDescriptors() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of(definition("system.operationLog.list"))); + AgentToolCatalogAgentTool tool = new AgentToolCatalogAgentTool(catalogService); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of()); + AgentToolResult result = tool.execute(call, null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("已注册 1 个工具")); + assertEquals(1, result.getArtifacts().size()); + assertFalse(result.getNextActions().isEmpty()); + } + + private AgentToolDefinition definition(String name) { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName(name); + definition.setTitle("查询操作日志"); + definition.setDescription("分页查询最近操作日志"); + definition.setIntent(name); + definition.setRoute("/logs/operation"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of("pageSize", Map.of("type", "integer"))); + definition.setRequiredInputKeys(List.of("pageSize")); + definition.setRequiredPermissions(List.of("agent:system:operation-log:read")); + return definition; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolDefinitionValidateAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolDefinitionValidateAgentToolTest.java new file mode 100644 index 000000000..0b8b74fba --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolDefinitionValidateAgentToolTest.java @@ -0,0 +1,133 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentToolDefinitionValidateAgentToolTest { + + @Test + void shouldResolveToolDefinitionValidationQuestion() { + AgentToolDefinitionValidateAgentTool tool = new AgentToolDefinitionValidateAgentTool(mock(AgentToolCatalogService.class)); + + AgentToolCall call = tool.resolve("检查一下智能体工具定义是否合规").orElseThrow(); + + assertEquals("system.agent.tools.validate", call.getToolName()); + assertTrue(call.getInput().isEmpty()); + assertTrue(tool.resolve("你能做什么,有哪些工具").isEmpty()); + } + + @Test + void shouldReportPassWhenDefinitionsAreValid() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of(readonlyTool("system.operationLog.list"))); + AgentToolDefinitionValidateAgentTool tool = new AgentToolDefinitionValidateAgentTool(catalogService); + + AgentToolResult result = tool.execute(call(tool, Map.of()), null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("全部通过")); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentToolDefinitionValidation", artifact.getType()); + assertEquals("PASS", artifact.getData().get("status")); + assertEquals(1, artifact.getData().get("totalCount")); + assertEquals(0, artifact.getData().get("issueCount")); + } + + @Test + void shouldReportContractIssuesForInvalidDefinitions() { + AgentToolDefinition invalid = readonlyTool("system.invalid"); + invalid.setRoute("admin/agent/chat"); + invalid.setRequiredInputKeys(List.of("missingField")); + invalid.setRequiredPermissions(List.of("system:legacy:read")); + invalid.setConfirmationRequired(true); + + AgentToolDefinition writeWithoutConfirmation = readonlyTool("system.write"); + writeWithoutConfirmation.setRiskLevel("medium"); + writeWithoutConfirmation.setRiskCategory("WRITE"); + writeWithoutConfirmation.setConfirmationRequired(false); + writeWithoutConfirmation.setConfirmationText(""); + + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of(invalid, writeWithoutConfirmation)); + AgentToolDefinitionValidateAgentTool tool = new AgentToolDefinitionValidateAgentTool(catalogService); + + AgentToolResult result = tool.execute(call(tool, Map.of()), null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("发现")); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("FAILED", artifact.getData().get("status")); + assertEquals(2, artifact.getData().get("totalCount")); + assertTrue(((Number) artifact.getData().get("issueCount")).intValue() >= 4); + String issues = String.valueOf(artifact.getData().get("issues")); + assertTrue(issues.contains("route")); + assertTrue(issues.contains("missingField")); + assertTrue(issues.contains("agent:*")); + assertTrue(issues.contains("confirmation")); + } + + @Test + void shouldDetectDuplicateToolNamesAndFilterByToolName() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + AgentToolDefinition first = readonlyTool("system.agent.tools.list"); + AgentToolDefinition duplicate = readonlyTool("system.agent.tools.list"); + AgentToolDefinition other = readonlyTool("system.operationLog.list"); + when(catalogService.definitions()).thenReturn(List.of(first, duplicate, other)); + AgentToolDefinitionValidateAgentTool tool = new AgentToolDefinitionValidateAgentTool(catalogService); + + AgentToolResult duplicateResult = tool.execute(call(tool, Map.of()), null); + AgentToolResult filteredResult = tool.execute(call(tool, Map.of("toolName", "system.operationLog.list")), null); + + assertEquals("FAILED", duplicateResult.getArtifacts().get(0).getData().get("status")); + assertTrue(String.valueOf(duplicateResult.getArtifacts().get(0).getData().get("issues")).contains("duplicate")); + assertEquals("PASS", filteredResult.getArtifacts().get(0).getData().get("status")); + assertEquals(1, filteredResult.getArtifacts().get(0).getData().get("totalCount")); + } + + @Test + void shouldReturnErrorWhenFilteredToolDoesNotExist() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of(readonlyTool("system.operationLog.list"))); + AgentToolDefinitionValidateAgentTool tool = new AgentToolDefinitionValidateAgentTool(catalogService); + + AgentToolResult result = tool.execute(call(tool, Map.of("toolName", "system.missing")), null); + + assertFalse(result.isSuccess()); + assertTrue(result.getSummary().contains("没有找到")); + } + + private AgentToolCall call(AgentToolDefinitionValidateAgentTool tool, Map input) { + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(input); + return call; + } + + private AgentToolDefinition readonlyTool(String name) { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName(name); + definition.setTitle("查询操作日志"); + definition.setDescription("分页查询系统操作日志"); + definition.setIntent(name); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of("pageSize", Map.of("type", "integer", "minimum", 1))); + definition.setRequiredInputKeys(List.of("pageSize")); + definition.setRequiredPermissions(List.of("agent:system:operation-log:read")); + return definition; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolDetailAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolDetailAgentToolTest.java new file mode 100644 index 000000000..f34470f90 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolDetailAgentToolTest.java @@ -0,0 +1,91 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentToolDetailAgentToolTest { + + @Test + void shouldResolveToolDetailQuestionWithRegisteredToolName() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of(definition("chat.userBan.unban", "medium", "WRITE", true))); + AgentToolDetailAgentTool tool = new AgentToolDetailAgentTool(catalogService); + + AgentToolCall call = tool.resolve("chat.userBan.unban 这个工具需要什么权限").orElseThrow(); + + assertEquals("system.agent.tools.detail", call.getToolName()); + assertEquals("chat.userBan.unban", call.getInput().get("toolName")); + assertTrue(tool.resolve("查询操作日志").isEmpty()); + } + + @Test + void shouldReturnToolDetailArtifact() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of(definition("chat.userBan.unban", "medium", "WRITE", true))); + AgentToolDetailAgentTool tool = new AgentToolDetailAgentTool(catalogService); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of("toolName", "chat.userBan.unban")); + AgentToolResult result = tool.execute(call, null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("chat.userBan.unban")); + assertEquals(1, result.getArtifacts().size()); + assertFalse(result.getNextActions().isEmpty()); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentToolDetail", artifact.getType()); + assertEquals("chat.userBan.unban", artifact.getData().get("name")); + assertEquals("WRITE", artifact.getData().get("riskCategory")); + assertEquals(true, artifact.getData().get("requiresConfirmation")); + assertEquals(List.of("agent:test:write"), artifact.getData().get("requiredPermissions")); + assertEquals(Map.of("userId", Map.of("type", "integer", "minimum", 1)), artifact.getData().get("inputSchema")); + } + + @Test + void shouldReturnErrorWhenToolMissing() { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of()); + AgentToolDetailAgentTool tool = new AgentToolDetailAgentTool(catalogService); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of("toolName", "system.unknown")); + AgentToolResult result = tool.execute(call, null); + + assertFalse(result.isSuccess()); + assertTrue(result.getSummary().contains("没有找到")); + assertEquals("后端智能体工具不存在: system.unknown", result.getErrorMessage()); + } + + private AgentToolDefinition definition(String name, String riskLevel, String riskCategory, boolean confirmationRequired) { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName(name); + definition.setTitle("解除聊天禁言"); + definition.setDescription("解除指定用户当前生效的聊天禁言"); + definition.setIntent(name); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel(riskLevel); + definition.setRiskCategory(riskCategory); + definition.setConfirmationRequired(confirmationRequired); + definition.setConfirmationText("确认解除禁言"); + definition.setInputSchema(Map.of("userId", Map.of("type", "integer", "minimum", 1))); + definition.setRequiredInputKeys(List.of("userId")); + definition.setRequiredPermissions(List.of("agent:test:write")); + return definition; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolMetricsAlertsAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolMetricsAlertsAgentToolTest.java new file mode 100644 index 000000000..5038c123e --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolMetricsAlertsAgentToolTest.java @@ -0,0 +1,136 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolMetricSample; +import com.xiaou.system.agent.AgentToolMetricsRecorder; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AgentToolMetricsAlertsAgentToolTest { + + @Test + void shouldResolveMetricsAlertEvaluationQuestion() { + AgentToolMetricsAlertsAgentTool tool = new AgentToolMetricsAlertsAgentTool(new SimpleMeterRegistry()); + + AgentToolCall call = tool.resolve("智能体工具指标告警规则评估一下").orElseThrow(); + + assertEquals("system.agent.metrics.alerts", call.getToolName()); + assertTrue(call.getInput().isEmpty()); + assertTrue(tool.resolve("智能体工具调用健康和告警情况怎么样").isEmpty()); + assertTrue(tool.resolve("查询操作日志").isEmpty()); + } + + @Test + void shouldWarnWhenMetricsAreMissing() { + AgentToolMetricsAlertsAgentTool tool = new AgentToolMetricsAlertsAgentTool(new SimpleMeterRegistry()); + + AgentToolResult result = tool.execute(call(tool), null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("WARN")); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentToolMetricsAlerts", artifact.getType()); + assertEquals("WARN", artifact.getData().get("status")); + assertEquals(1, artifact.getData().get("alertCount")); + assertTrue(String.valueOf(artifact.getData().get("alerts")).contains("agent.tool.metrics.missing")); + } + + @Test + void shouldReturnOkWhenMetricsAreHealthy() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder recorder = new AgentToolMetricsRecorder(meterRegistry); + recorder.recordInvocation(sample("system.operationLog.list", "execute", "success", "readonly", "READONLY", 12_000_000L)); + AgentToolMetricsAlertsAgentTool tool = new AgentToolMetricsAlertsAgentTool(meterRegistry); + + AgentToolResult result = tool.execute(call(tool), null); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("OK", artifact.getData().get("status")); + assertEquals(0, artifact.getData().get("alertCount")); + assertTrue(((List) artifact.getData().get("alerts")).isEmpty()); + assertFalse(result.getNextActions().isEmpty()); + } + + @Test + void shouldAlertWhenErrorThresholdIsReached() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder recorder = new AgentToolMetricsRecorder(meterRegistry); + recorder.recordInvocation(sample("chat.userBan.unban", "execute", "error", "medium", "WRITE", 20_000_000L)); + AgentToolMetricsAlertsAgentTool tool = new AgentToolMetricsAlertsAgentTool(meterRegistry); + + AgentToolResult result = tool.execute(call(tool), null); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("ALERT", artifact.getData().get("status")); + assertEquals(1D, artifact.getData().get("errorCount")); + assertTrue(String.valueOf(artifact.getData().get("alerts")).contains("agent.tool.errors")); + } + + @Test + void shouldWarnForBlockedAndSlowSeries() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder recorder = new AgentToolMetricsRecorder(meterRegistry); + recorder.recordInvocation(sample("chat.userBan.unban", "preview", "blocked", "medium", "WRITE", 5_000_000L)); + recorder.recordInvocation(sample("system.operationLog.list", "execute", "success", "readonly", "READONLY", 1_500_000_000L)); + AgentToolMetricsAlertsAgentTool tool = new AgentToolMetricsAlertsAgentTool(meterRegistry); + + AgentToolResult result = tool.execute(call(tool), null); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("WARN", artifact.getData().get("status")); + assertEquals(2, artifact.getData().get("alertCount")); + assertTrue(String.valueOf(artifact.getData().get("alerts")).contains("agent.tool.blocked")); + assertTrue(String.valueOf(artifact.getData().get("alerts")).contains("agent.tool.slow")); + } + + @Test + void shouldUseCustomSlowThresholdFromInput() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder recorder = new AgentToolMetricsRecorder(meterRegistry); + recorder.recordInvocation(sample("system.operationLog.list", "execute", "success", "readonly", "READONLY", 900_000_000L)); + AgentToolMetricsAlertsAgentTool tool = new AgentToolMetricsAlertsAgentTool(meterRegistry); + AgentToolCall call = call(tool); + call.setInput(Map.of("slowThresholdMs", 800)); + + AgentToolResult result = tool.execute(call, null); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("WARN", artifact.getData().get("status")); + assertEquals(800D, artifact.getData().get("slowThresholdMs")); + assertTrue(String.valueOf(artifact.getData().get("alerts")).contains("agent.tool.slow")); + } + + private AgentToolCall call(AgentToolMetricsAlertsAgentTool tool) { + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of()); + return call; + } + + private AgentToolMetricSample sample( + String toolName, + String phase, + String outcome, + String riskLevel, + String riskCategory, + long durationNanos + ) { + AgentToolMetricSample sample = new AgentToolMetricSample(); + sample.setToolName(toolName); + sample.setPhase(phase); + sample.setOutcome(outcome); + sample.setRiskLevel(riskLevel); + sample.setRiskCategory(riskCategory); + sample.setDurationNanos(durationNanos); + return sample; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolMetricsHealthAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolMetricsHealthAgentToolTest.java new file mode 100644 index 000000000..7cf69c17a --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolMetricsHealthAgentToolTest.java @@ -0,0 +1,118 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolMetricSample; +import com.xiaou.system.agent.AgentToolMetricsRecorder; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AgentToolMetricsHealthAgentToolTest { + + @Test + void shouldResolveMetricsHealthQuestion() { + AgentToolMetricsHealthAgentTool tool = new AgentToolMetricsHealthAgentTool(new SimpleMeterRegistry()); + + AgentToolCall call = tool.resolve("智能体工具调用健康和告警情况怎么样").orElseThrow(); + + assertEquals("system.agent.metrics.health", call.getToolName()); + assertTrue(call.getInput().isEmpty()); + assertTrue(tool.resolve("智能体工具指标告警规则评估一下").isEmpty()); + assertTrue(tool.resolve("查询操作日志").isEmpty()); + } + + @Test + void shouldWarnWhenMetricsAreMissing() { + AgentToolMetricsHealthAgentTool tool = new AgentToolMetricsHealthAgentTool(new SimpleMeterRegistry()); + + AgentToolResult result = tool.execute(call(tool), null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("WARN")); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentToolMetricsHealth", artifact.getType()); + assertEquals("WARN", artifact.getData().get("status")); + assertEquals(0D, artifact.getData().get("invocationCount")); + assertFalse(result.getNextActions().isEmpty()); + } + + @Test + void shouldReportHealthyWhenOnlySuccessfulFastInvocationsExist() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder recorder = new AgentToolMetricsRecorder(meterRegistry); + recorder.recordInvocation(sample("system.operationLog.list", "execute", "success", "readonly", "READONLY", 12_000_000L)); + recorder.recordInvocation(sample("chat.userBan.unban", "preview", "success", "medium", "WRITE", 8_000_000L)); + AgentToolMetricsHealthAgentTool tool = new AgentToolMetricsHealthAgentTool(meterRegistry); + + AgentToolResult result = tool.execute(call(tool), null); + + assertTrue(result.isSuccess()); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("HEALTHY", artifact.getData().get("status")); + assertEquals(2D, artifact.getData().get("invocationCount")); + assertEquals(0D, artifact.getData().get("errorCount")); + assertEquals(0, artifact.getData().get("slowSeriesCount")); + } + + @Test + void shouldAlertWhenToolExecutionErrorsAreRecorded() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder recorder = new AgentToolMetricsRecorder(meterRegistry); + recorder.recordInvocation(sample("chat.userBan.unban", "execute", "error", "medium", "WRITE", 15_000_000L)); + AgentToolMetricsHealthAgentTool tool = new AgentToolMetricsHealthAgentTool(meterRegistry); + + AgentToolResult result = tool.execute(call(tool), null); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("ALERT", artifact.getData().get("status")); + assertEquals(1D, artifact.getData().get("errorCount")); + assertTrue(result.getNextActions().contains("优先查询失败工具对应的智能体审计记录,定位错误输入、权限或外部依赖。")); + } + + @Test + void shouldWarnWhenSlowToolSeriesExceedsThreshold() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder recorder = new AgentToolMetricsRecorder(meterRegistry); + recorder.recordInvocation(sample("system.operationLog.list", "execute", "success", "readonly", "READONLY", 1_500_000_000L)); + AgentToolMetricsHealthAgentTool tool = new AgentToolMetricsHealthAgentTool(meterRegistry); + + AgentToolResult result = tool.execute(call(tool), null); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("WARN", artifact.getData().get("status")); + assertEquals(1, artifact.getData().get("slowSeriesCount")); + assertTrue(String.valueOf(artifact.getData().get("checks")).contains("metrics.slowSeries")); + } + + private AgentToolCall call(AgentToolMetricsHealthAgentTool tool) { + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of()); + return call; + } + + private AgentToolMetricSample sample( + String toolName, + String phase, + String outcome, + String riskLevel, + String riskCategory, + long durationNanos + ) { + AgentToolMetricSample sample = new AgentToolMetricSample(); + sample.setToolName(toolName); + sample.setPhase(phase); + sample.setOutcome(outcome); + sample.setRiskLevel(riskLevel); + sample.setRiskCategory(riskCategory); + sample.setDurationNanos(durationNanos); + return sample; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolMetricsSummaryAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolMetricsSummaryAgentToolTest.java new file mode 100644 index 000000000..6e7c08b26 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolMetricsSummaryAgentToolTest.java @@ -0,0 +1,99 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolMetricSample; +import com.xiaou.system.agent.AgentToolMetricsRecorder; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AgentToolMetricsSummaryAgentToolTest { + + @Test + void shouldResolveToolMetricsQuestion() { + AgentToolMetricsSummaryAgentTool tool = new AgentToolMetricsSummaryAgentTool(new SimpleMeterRegistry()); + + AgentToolCall call = tool.resolve("智能体工具调用指标怎么样").orElseThrow(); + + assertEquals("system.agent.metrics.summary", call.getToolName()); + assertTrue(call.getInput().isEmpty()); + assertTrue(tool.resolve("智能体工具指标告警规则评估一下").isEmpty()); + assertTrue(tool.resolve("查询操作日志").isEmpty()); + } + + @Test + void shouldSummarizeToolInvocationMetrics() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AgentToolMetricsRecorder recorder = new AgentToolMetricsRecorder(meterRegistry); + recorder.recordInvocation(sample("system.operationLog.list", "execute", "success", "readonly", "READONLY", 12_000_000L)); + recorder.recordInvocation(sample("system.operationLog.list", "execute", "success", "readonly", "READONLY", 8_000_000L)); + recorder.recordInvocation(sample("chat.userBan.unban", "preview", "success", "medium", "WRITE", 5_000_000L)); + AgentToolMetricsSummaryAgentTool tool = new AgentToolMetricsSummaryAgentTool(meterRegistry); + + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of()); + AgentToolResult result = tool.execute(call, null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("3 次")); + assertEquals(1, result.getArtifacts().size()); + assertFalse(result.getNextActions().isEmpty()); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentToolMetricsSummary", artifact.getType()); + assertEquals(3D, artifact.getData().get("invocationCount")); + assertEquals(2, artifact.getData().get("seriesCount")); + + List series = (List) artifact.getData().get("series"); + Map first = (Map) series.get(0); + assertEquals("system.operationLog.list", first.get("tool")); + assertEquals("execute", first.get("phase")); + assertEquals("success", first.get("outcome")); + assertEquals(2D, first.get("count")); + assertEquals(20D, first.get("totalDurationMs")); + assertEquals(10D, first.get("meanDurationMs")); + } + + @Test + void shouldReturnEmptySummaryWhenMetricsMissing() { + AgentToolMetricsSummaryAgentTool tool = new AgentToolMetricsSummaryAgentTool(new SimpleMeterRegistry()); + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of()); + + AgentToolResult result = tool.execute(call, null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("还没有记录")); + assertEquals(0D, result.getArtifacts().get(0).getData().get("invocationCount")); + } + + private AgentToolMetricSample sample( + String toolName, + String phase, + String outcome, + String riskLevel, + String riskCategory, + long durationNanos + ) { + AgentToolMetricSample sample = new AgentToolMetricSample(); + sample.setToolName(toolName); + sample.setPhase(phase); + sample.setOutcome(outcome); + sample.setRiskLevel(riskLevel); + sample.setRiskCategory(riskCategory); + sample.setDurationNanos(durationNanos); + sample.setTraceId("trace-should-not-be-a-tag"); + sample.setSessionId("session-should-not-be-a-tag"); + return sample; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolSearchAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolSearchAgentToolTest.java new file mode 100644 index 000000000..42711ccab --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/AgentToolSearchAgentToolTest.java @@ -0,0 +1,116 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolCatalogService; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentToolSearchAgentToolTest { + + @Test + void shouldResolveToolSearchQuestionWithExtractedQuery() { + AgentToolSearchAgentTool tool = new AgentToolSearchAgentTool(catalogService( + definition("chat.userBan.active", "查询聊天禁言状态", "查询指定用户当前是否有生效禁言"), + definition("system.operationLog.list", "查询操作日志", "查询后台操作日志") + )); + + AgentToolCall call = tool.resolve("帮我搜索禁言相关工具").orElseThrow(); + + assertEquals("system.agent.tools.search", call.getToolName()); + assertEquals("禁言", call.getInput().get("query")); + assertTrue(tool.resolve("查询操作日志").isEmpty()); + } + + @Test + void shouldReturnMatchedToolsByQuery() { + AgentToolSearchAgentTool tool = new AgentToolSearchAgentTool(catalogService( + definition("chat.userBan.active", "查询聊天禁言状态", "查询指定用户当前是否有生效禁言"), + definition("chat.userBan.unban", "解除聊天禁言", "解除指定用户当前生效的聊天禁言"), + definition("system.operationLog.list", "查询操作日志", "查询后台操作日志") + )); + AgentToolCall call = call(tool, "禁言", 10); + + AgentToolResult result = tool.execute(call, null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("2 个")); + assertEquals(1, result.getArtifacts().size()); + assertFalse(result.getNextActions().isEmpty()); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("agentToolSearch", artifact.getType()); + assertEquals("禁言", artifact.getData().get("query")); + assertEquals(2, artifact.getData().get("resultCount")); + List results = (List) artifact.getData().get("results"); + assertEquals("chat.userBan.active", ((Map) results.get(0)).get("name")); + assertEquals("chat.userBan.unban", ((Map) results.get(1)).get("name")); + } + + @Test + void shouldRespectLimit() { + AgentToolSearchAgentTool tool = new AgentToolSearchAgentTool(catalogService( + definition("tool.alpha", "Alpha 工具", "通用测试工具"), + definition("tool.beta", "Beta 工具", "通用测试工具"), + definition("tool.gamma", "Gamma 工具", "通用测试工具") + )); + + AgentToolResult result = tool.execute(call(tool, "工具", 2), null); + + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals(3, artifact.getData().get("totalMatchedCount")); + assertEquals(2, artifact.getData().get("resultCount")); + } + + @Test + void shouldReturnEmptyResultWhenNoToolMatches() { + AgentToolSearchAgentTool tool = new AgentToolSearchAgentTool(catalogService( + definition("system.operationLog.list", "查询操作日志", "查询后台操作日志") + )); + + AgentToolResult result = tool.execute(call(tool, "禁言", 10), null); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("没有找到")); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals(0, artifact.getData().get("resultCount")); + } + + private AgentToolCall call(AgentToolSearchAgentTool tool, String query, int limit) { + AgentToolCall call = new AgentToolCall(); + call.setToolName(tool.definition().getName()); + call.setInput(Map.of("query", query, "limit", limit)); + return call; + } + + private AgentToolCatalogService catalogService(AgentToolDefinition... definitions) { + AgentToolCatalogService catalogService = mock(AgentToolCatalogService.class); + when(catalogService.definitions()).thenReturn(List.of(definitions)); + return catalogService; + } + + private AgentToolDefinition definition(String name, String title, String description) { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName(name); + definition.setTitle(title); + definition.setDescription(description); + definition.setIntent(name); + definition.setRoute("/admin/agent/chat"); + definition.setRiskLevel("readonly"); + definition.setRiskCategory("READONLY"); + definition.setInputSchema(Map.of("userId", Map.of("type", "integer", "minimum", 1))); + definition.setRequiredInputKeys(List.of("userId")); + definition.setRequiredPermissions(List.of("agent:test:read")); + return definition; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/ChatUserBanAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/ChatUserBanAgentToolTest.java new file mode 100644 index 000000000..7485cefbe --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/ChatUserBanAgentToolTest.java @@ -0,0 +1,123 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.chat.domain.ChatUserBan; +import com.xiaou.chat.service.ChatUserBanService; +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentOperator; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import org.junit.jupiter.api.Test; + +import java.util.Date; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ChatUserBanAgentToolTest { + + private final ChatUserBanService chatUserBanService = mock(ChatUserBanService.class); + private final ChatUserBanStatusAgentTool statusTool = new ChatUserBanStatusAgentTool(chatUserBanService); + private final ChatUserUnbanAgentTool unbanTool = new ChatUserUnbanAgentTool(chatUserBanService); + private final AgentExecutionContext context = new AgentExecutionContext("session-1", "", new AgentOperator(7L, "admin")); + + @Test + void statusToolShouldResolveReadonlyQuery() { + Optional call = statusTool.resolve("查询用户88禁言状态"); + + assertTrue(call.isPresent()); + assertEquals("chat.userBan.active", call.get().getToolName()); + assertEquals(88L, call.get().getInput().get("userId")); + assertEquals("readonly", statusTool.definition().getRiskLevel()); + } + + @Test + void statusToolShouldAnswerInactiveUser() { + AgentToolCall call = call("chat.userBan.active", 88L); + when(chatUserBanService.getActiveBan(88L)).thenReturn(null); + + AgentToolResult result = statusTool.execute(call, context); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("当前未被禁言")); + assertEquals(1, result.getArtifacts().size()); + verify(chatUserBanService).getActiveBan(88L); + } + + @Test + void unbanToolShouldResolveWriteRequestWithoutMatchingReadonlyQuery() { + Optional queryCall = unbanTool.resolve("查询用户88禁言状态"); + Optional unbanCall = unbanTool.resolve("解除用户88禁言"); + + assertFalse(queryCall.isPresent()); + assertTrue(unbanCall.isPresent()); + assertEquals("chat.userBan.unban", unbanCall.get().getToolName()); + assertEquals(88L, unbanCall.get().getInput().get("userId")); + assertTrue(unbanTool.definition().isConfirmationRequired()); + } + + @Test + void unbanPreviewShouldBlockWhenUserIsNotBanned() { + AgentToolCall call = call("chat.userBan.unban", 88L); + when(chatUserBanService.getActiveBan(88L)).thenReturn(null); + + AgentToolPreview preview = unbanTool.preview(call, context); + + assertFalse(preview.isExecutable()); + assertTrue(preview.getBlockedReason().contains("当前未被禁言")); + assertEquals(1, preview.getArtifacts().size()); + verify(chatUserBanService).getActiveBan(88L); + } + + @Test + void unbanPreviewShouldDescribeActiveBanDiff() { + AgentToolCall call = call("chat.userBan.unban", 88L); + when(chatUserBanService.getActiveBan(88L)).thenReturn(activeBan(88L)); + + AgentToolPreview preview = unbanTool.preview(call, context); + + assertTrue(preview.isExecutable()); + assertTrue(preview.getSummary().contains("将解除用户 88")); + assertEquals(3, preview.getPlan().size()); + assertEquals(1, preview.getDiff().size()); + assertEquals(1, preview.getArtifacts().size()); + } + + @Test + void unbanExecuteShouldCallTypedService() { + AgentToolCall call = call("chat.userBan.unban", 88L); + + AgentToolResult result = unbanTool.execute(call, context); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("已解除用户 88")); + assertNotNull(result.getArtifacts().get(0).getData()); + verify(chatUserBanService).unbanUser(88L); + } + + private AgentToolCall call(String toolName, Long userId) { + AgentToolCall call = new AgentToolCall(); + call.setToolName(toolName); + call.getInput().put("userId", userId); + return call; + } + + private ChatUserBan activeBan(Long userId) { + ChatUserBan ban = new ChatUserBan(); + ban.setId(9001L); + ban.setUserId(userId); + ban.setRoomId(1L); + ban.setBanReason("刷屏"); + ban.setBanStartTime(new Date(1_700_000_000_000L)); + ban.setBanEndTime(new Date(1_700_003_600_000L)); + ban.setOperatorId(7L); + ban.setStatus(1); + return ban; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/LotteryRealtimeMonitorAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/LotteryRealtimeMonitorAgentToolTest.java new file mode 100644 index 000000000..5b6f9621a --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/LotteryRealtimeMonitorAgentToolTest.java @@ -0,0 +1,124 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.points.dto.lottery.admin.RealtimeMonitorResponse; +import com.xiaou.points.service.LotteryAdminService; +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentOperator; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.AgentChatArtifact; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LotteryRealtimeMonitorAgentToolTest { + + private final LotteryAdminService lotteryAdminService = mock(LotteryAdminService.class); + private final LotteryRealtimeMonitorAgentTool tool = new LotteryRealtimeMonitorAgentTool(lotteryAdminService); + private final AgentExecutionContext context = new AgentExecutionContext("session-1", "", new AgentOperator(7L, "admin")); + + @Test + void shouldDeclareReadonlyBackendToolContract() { + AgentToolDefinition definition = tool.definition(); + + assertEquals("points.lottery.monitor.realtime", definition.getName()); + assertEquals("/admin/lottery/monitor/realtime", definition.getRoute()); + assertEquals("readonly", definition.getRiskLevel()); + assertEquals("READONLY", definition.getRiskCategory()); + assertFalse(definition.isConfirmationRequired()); + assertEquals(List.of("agent:points:lottery:monitor:read"), definition.getRequiredPermissions()); + assertTrue(definition.getInputSchema().isEmpty()); + assertTrue(definition.getRequiredInputKeys().isEmpty()); + } + + @Test + void shouldResolveLotteryRealtimeMonitorQueryOnlyForLotteryIntent() { + Optional call = tool.resolve("看一下抽奖实时监控和今日概览"); + Optional runtimeCall = tool.resolve("看一下智能体运行时实时监控"); + + assertTrue(call.isPresent()); + assertEquals("points.lottery.monitor.realtime", call.get().getToolName()); + assertTrue(call.get().getInput().isEmpty()); + assertFalse(runtimeCall.isPresent()); + } + + @Test + void shouldExecuteTypedLotteryAdminServiceAndReturnStructuredArtifact() { + when(lotteryAdminService.getRealtimeMonitor()).thenReturn(sampleMonitor()); + + AgentToolResult result = tool.execute(new AgentToolCall(), context); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("今日抽奖 12 次")); + assertEquals(1, result.getArtifacts().size()); + AgentChatArtifact artifact = result.getArtifacts().get(0); + assertEquals("pointsLotteryRealtimeMonitor", artifact.getType()); + assertEquals("抽奖实时监控", artifact.getTitle()); + assertEquals(12, artifact.getData().get("totalDrawCount")); + assertEquals(2, artifact.getData().get("prizeCount")); + assertTrue(artifact.getData().containsKey("monitor")); + verify(lotteryAdminService).getRealtimeMonitor(); + } + + @Test + void shouldHandleNullServiceResponseAsEmptySnapshot() { + when(lotteryAdminService.getRealtimeMonitor()).thenReturn(null); + + AgentToolResult result = tool.execute(new AgentToolCall(), context); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("暂无抽奖实时监控数据")); + assertEquals(0, result.getArtifacts().get(0).getData().get("totalDrawCount")); + assertEquals(0, result.getArtifacts().get(0).getData().get("prizeCount")); + } + + private RealtimeMonitorResponse sampleMonitor() { + return RealtimeMonitorResponse.builder() + .systemStatus(RealtimeMonitorResponse.SystemStatus.builder() + .status("运行中") + .activeUsers(5) + .successRate(BigDecimal.valueOf(0.999)) + .build()) + .todayOverview(RealtimeMonitorResponse.TodayOverview.builder() + .totalDrawCount(12) + .totalCostPoints(120L) + .totalRewardPoints(80L) + .actualReturnRate(BigDecimal.valueOf(0.6667)) + .profitPoints(40L) + .profitRate(BigDecimal.valueOf(0.3333)) + .uniqueUserCount(5) + .build()) + .prizeStatusList(List.of( + RealtimeMonitorResponse.PrizeStatus.builder() + .prizeId(1L) + .prizeName("一等奖") + .status("正常") + .alertLevel("无") + .todayDrawCount(8) + .build(), + RealtimeMonitorResponse.PrizeStatus.builder() + .prizeId(2L) + .prizeName("二等奖") + .status("暂停") + .alertLevel("警告") + .alertMessage("奖品已暂停") + .todayDrawCount(4) + .build() + )) + .strategyInfo(RealtimeMonitorResponse.StrategyInfo.builder() + .currentStrategy("Alias Method") + .autoAdjustEnabled(true) + .build()) + .build(); + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/agent/tools/OperationLogListAgentToolTest.java b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/OperationLogListAgentToolTest.java new file mode 100644 index 000000000..5a3380d36 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/agent/tools/OperationLogListAgentToolTest.java @@ -0,0 +1,122 @@ +package com.xiaou.system.agent.tools; + +import com.xiaou.common.core.domain.PageResult; +import com.xiaou.system.agent.AgentExecutionContext; +import com.xiaou.system.agent.AgentOperator; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.dto.OperationLogQueryRequest; +import com.xiaou.system.dto.OperationLogResponse; +import com.xiaou.system.service.SysOperationLogService; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OperationLogListAgentToolTest { + + private final SysOperationLogService operationLogService = mock(SysOperationLogService.class); + private final OperationLogListAgentTool tool = new OperationLogListAgentTool(operationLogService); + private final OperationLogCleanAgentTool cleanTool = new OperationLogCleanAgentTool(operationLogService); + private final AgentExecutionContext context = new AgentExecutionContext("session-1", "", new AgentOperator(7L, "admin")); + + @Test + void shouldResolveRecentOperationLogQueryWithClampedLimit() { + Optional call = tool.resolve("帮我查最近99条操作日志"); + + assertTrue(call.isPresent()); + assertEquals("system.operationLog.list", call.get().getToolName()); + assertEquals(20, call.get().getInput().get("pageSize")); + } + + @Test + @SuppressWarnings("unchecked") + void shouldExecuteTypedOperationLogServiceAndReturnArtifact() { + when(operationLogService.getOperationLogPage(any())) + .thenReturn(PageResult.of(1, 3, 1L, List.of(operationLog()))); + AgentToolCall call = new AgentToolCall(); + call.setToolName("system.operationLog.list"); + call.setInput(Map.of("pageSize", 3)); + + AgentToolResult result = tool.execute(call, context); + + assertTrue(result.isSuccess()); + assertEquals("已查询到最近 1 条操作日志。", result.getSummary()); + assertEquals("operationLogList", result.getArtifacts().get(0).getType()); + List> records = (List>) result.getArtifacts().get(0).getData().get("records"); + assertEquals("系统", records.get(0).get("module")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OperationLogQueryRequest.class); + verify(operationLogService).getOperationLogPage(captor.capture()); + assertEquals(1, captor.getValue().getPageNum()); + assertEquals(3, captor.getValue().getPageSize()); + } + + @Test + void shouldResolveDestructiveCleanupRequest() { + Optional call = cleanTool.resolve("请清理 30 天前的操作日志"); + + assertTrue(call.isPresent()); + assertEquals("system.operationLog.cleanExpired", call.get().getToolName()); + assertEquals(30, call.get().getInput().get("days")); + assertEquals("high", cleanTool.definition().getRiskLevel()); + assertEquals("DESTRUCTIVE_WRITE", cleanTool.definition().getRiskCategory()); + assertTrue(cleanTool.definition().isConfirmationRequired()); + } + + @Test + void shouldPreviewCleanupWithoutCallingService() { + AgentToolCall call = cleanupCall(30); + + AgentToolPreview preview = cleanTool.preview(call, context); + + assertTrue(preview.isExecutable()); + assertTrue(preview.getSummary().contains("确认前不会执行删除")); + assertEquals(3, preview.getPlan().size()); + assertEquals(1, preview.getDiff().size()); + assertEquals("operationLogCleanupPreview", preview.getArtifacts().get(0).getType()); + org.mockito.Mockito.verifyNoInteractions(operationLogService); + } + + @Test + void shouldExecuteCleanupThroughTypedService() { + when(operationLogService.cleanOperationLogByDays(30)).thenReturn(true); + + AgentToolResult result = cleanTool.execute(cleanupCall(30), context); + + assertTrue(result.isSuccess()); + assertTrue(result.getSummary().contains("已清理 30 天前")); + assertEquals("operationLogCleanupResult", result.getArtifacts().get(0).getType()); + verify(operationLogService).cleanOperationLogByDays(30); + } + + private AgentToolCall cleanupCall(int days) { + AgentToolCall call = new AgentToolCall(); + call.setToolName("system.operationLog.cleanExpired"); + call.setInput(Map.of("days", days)); + return call; + } + + private OperationLogResponse operationLog() { + OperationLogResponse log = new OperationLogResponse(); + log.setId(1L); + log.setModule("系统"); + log.setOperationType("SELECT"); + log.setDescription("查询日志"); + log.setOperatorName("admin"); + log.setStatus(0); + log.setOperationTime(LocalDateTime.of(2026, 7, 9, 15, 0)); + return log; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/controller/AgentChatControllerTest.java b/xiaou-system/src/test/java/com/xiaou/system/controller/AgentChatControllerTest.java new file mode 100644 index 000000000..8a8bc97d4 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/controller/AgentChatControllerTest.java @@ -0,0 +1,307 @@ +package com.xiaou.system.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xiaou.common.annotation.Log; +import com.xiaou.common.annotation.RequireAdmin; +import com.xiaou.common.core.domain.Result; +import com.xiaou.common.satoken.StpAdminUtil; +import com.xiaou.ai.prompt.admin.AdminAgentPromptSpecs; +import com.xiaou.ai.support.AiExecutionSupport; +import com.xiaou.system.agent.AgentChatOrchestrator; +import com.xiaou.system.agent.AgentOperator; +import com.xiaou.system.agent.AgentPlanResolution; +import com.xiaou.system.agent.AgentPolicyEngine; +import com.xiaou.system.agent.AgentSessionContextStore; +import com.xiaou.system.agent.AgentTool; +import com.xiaou.system.agent.AgentToolCall; +import com.xiaou.system.agent.AgentToolDefinition; +import com.xiaou.system.agent.AgentToolPreview; +import com.xiaou.system.agent.AgentToolRegistry; +import com.xiaou.system.agent.AgentToolResult; +import com.xiaou.system.agent.DeterministicAgentPlanResolver; +import com.xiaou.system.agent.LlmAgentPlanResolver; +import com.xiaou.system.domain.SysAdmin; +import com.xiaou.system.dto.AgentChatArtifact; +import com.xiaou.system.dto.AgentChatRequest; +import com.xiaou.system.dto.AgentChatResponse; +import com.xiaou.system.service.SysAgentAuditService; +import com.xiaou.system.service.SysAdminService; +import jakarta.validation.Valid; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AgentChatControllerTest { + + @Test + void shouldExposeOnlyUnifiedBackendChatEndpointContract() throws Exception { + assertNotNull(AgentChatController.class.getAnnotation(RestController.class)); + + RequestMapping classMapping = AgentChatController.class.getAnnotation(RequestMapping.class); + assertNotNull(classMapping); + assertArrayEquals(new String[]{"/admin/agent"}, classMapping.value()); + + Method chatMethod = AgentChatController.class.getMethod("chat", AgentChatRequest.class); + assertTrue(java.util.Arrays.stream(chatMethod.getParameterAnnotations()[0]) + .anyMatch(annotation -> annotation.annotationType().equals(Valid.class))); + PostMapping postMapping = chatMethod.getAnnotation(PostMapping.class); + assertNotNull(postMapping); + assertArrayEquals(new String[]{"/chat"}, postMapping.value()); + + RequireAdmin requireAdmin = chatMethod.getAnnotation(RequireAdmin.class); + assertNotNull(requireAdmin); + assertEquals("使用管理员智能体需要管理员权限", requireAdmin.message()); + + Log log = chatMethod.getAnnotation(Log.class); + assertNotNull(log); + assertEquals("系统管理", log.module()); + assertEquals(Log.OperationType.OTHER, log.type()); + assertEquals("管理员智能体聊天", log.description()); + assertEquals(false, log.saveRequestData()); + assertEquals(false, log.saveResponseData()); + } + + @Test + void shouldResolveOperatorAndDelegateToOrchestrator() { + AgentChatOrchestrator orchestrator = mock(AgentChatOrchestrator.class); + SysAdminService adminService = mock(SysAdminService.class); + AgentChatController controller = new AgentChatController(orchestrator, adminService); + + SysAdmin admin = new SysAdmin(); + admin.setId(7L); + admin.setUsername("admin"); + when(adminService.getById(7L)).thenReturn(admin); + when(adminService.getAdminRoles(7L)).thenReturn(java.util.List.of("ADMIN")); + when(adminService.getAdminPermissions(7L)).thenReturn(java.util.List.of("agent:system:read")); + + AgentChatRequest request = new AgentChatRequest(); + request.setSessionId("session-1"); + request.setMessage("查最近5条操作日志"); + + AgentChatResponse orchestratorResponse = new AgentChatResponse(); + orchestratorResponse.setStatus("answered"); + orchestratorResponse.setAnswer("已查询到最近 5 条操作日志。"); + when(orchestrator.chat(org.mockito.ArgumentMatchers.eq(request), org.mockito.ArgumentMatchers.any())) + .thenReturn(orchestratorResponse); + + try (MockedStatic stpAdminUtil = mockStatic(StpAdminUtil.class)) { + stpAdminUtil.when(StpAdminUtil::getLoginIdAsLong).thenReturn(7L); + + Result result = controller.chat(request); + + assertEquals(200, result.getCode()); + assertEquals("智能体响应完成", result.getMessage()); + assertEquals(orchestratorResponse, result.getData()); + } + + ArgumentCaptor operatorCaptor = ArgumentCaptor.forClass(AgentOperator.class); + verify(orchestrator).chat(org.mockito.ArgumentMatchers.eq(request), operatorCaptor.capture()); + AgentOperator operator = operatorCaptor.getValue(); + assertEquals(7L, operator.id()); + assertEquals("admin", operator.name()); + assertEquals(java.util.List.of("ADMIN"), operator.roles()); + assertEquals(java.util.List.of("agent:system:read"), operator.permissions()); + } + + @Test + void shouldFallbackToAdminIdWhenOperatorNameCannotBeResolved() { + AgentChatOrchestrator orchestrator = mock(AgentChatOrchestrator.class); + SysAdminService adminService = mock(SysAdminService.class); + AgentChatController controller = new AgentChatController(orchestrator, adminService); + + when(adminService.getById(7L)).thenThrow(new IllegalStateException("admin service unavailable")); + when(orchestrator.chat(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(new AgentChatResponse()); + + try (MockedStatic stpAdminUtil = mockStatic(StpAdminUtil.class)) { + stpAdminUtil.when(StpAdminUtil::getLoginIdAsLong).thenReturn(7L); + controller.chat(new AgentChatRequest()); + } + + ArgumentCaptor operatorCaptor = ArgumentCaptor.forClass(AgentOperator.class); + verify(orchestrator).chat(org.mockito.ArgumentMatchers.any(), operatorCaptor.capture()); + assertEquals(7L, operatorCaptor.getValue().id()); + assertEquals("7", operatorCaptor.getValue().name()); + } + + @Test + void shouldRejectOversizedChatRequestBeforeOrchestration() throws Exception { + AgentChatOrchestrator orchestrator = mock(AgentChatOrchestrator.class); + SysAdminService adminService = mock(SysAdminService.class); + MockMvc mockMvc = MockMvcBuilders + .standaloneSetup(new AgentChatController(orchestrator, adminService)) + .build(); + AgentChatRequest request = new AgentChatRequest(); + request.setSessionId("session-1"); + request.setMessage("x".repeat(4001)); + + mockMvc.perform(post("/admin/agent/chat") + .contentType(MediaType.APPLICATION_JSON) + .content(new ObjectMapper().writeValueAsString(request))) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(orchestrator, adminService); + } + + @Test + void shouldExecuteSchemaOnlyToolThroughUnifiedHttpChatEndpoint() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + AgentTool schemaOnlyTool = schemaOnlyTool(); + AgentToolRegistry registry = new AgentToolRegistry(List.of(schemaOnlyTool)); + AiExecutionSupport aiExecutionSupport = mock(AiExecutionSupport.class); + stubPlannerResponse(aiExecutionSupport, """ + { + "toolName": "system.release.blockers.read", + "input": { + "query": "release blockers", + "ignored": "must be removed by schema filtering" + }, + "confidence": 0.98, + "missingFields": [] + } + """); + + AgentChatOrchestrator orchestrator = new AgentChatOrchestrator( + registry, + new LlmAgentPlanResolver( + new DeterministicAgentPlanResolver(registry), + registry, + aiExecutionSupport, + objectMapper + ), + new AgentPolicyEngine(), + mock(SysAgentAuditService.class), + objectMapper, + new AgentSessionContextStore() + ); + + SysAdminService adminService = mock(SysAdminService.class); + SysAdmin admin = new SysAdmin(); + admin.setId(7L); + admin.setUsername("admin"); + when(adminService.getById(7L)).thenReturn(admin); + when(adminService.getAdminRoles(7L)).thenReturn(List.of("ADMIN")); + when(adminService.getAdminPermissions(7L)).thenReturn(List.of("agent:release:blockers:read")); + + MockMvc mockMvc = MockMvcBuilders + .standaloneSetup(new AgentChatController(orchestrator, adminService)) + .build(); + + try (MockedStatic stpAdminUtil = mockStatic(StpAdminUtil.class)) { + stpAdminUtil.when(StpAdminUtil::getLoginIdAsLong).thenReturn(7L); + + mockMvc.perform(post("/admin/agent/chat") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "sessionId": "http-session-1", + "message": "请整理最近的发布阻塞情况" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.sessionId").value("http-session-1")) + .andExpect(jsonPath("$.data.status").value("answered")) + .andExpect(jsonPath("$.data.toolName").value("system.release.blockers.read")) + .andExpect(jsonPath("$.data.trace").isArray()) + .andExpect(jsonPath("$.data.trace").isNotEmpty()) + .andExpect(jsonPath("$.data.artifacts[0].type").value("releaseBlockers")) + .andExpect(jsonPath("$.data.artifacts[0].data.query").value("release blockers")) + .andExpect(jsonPath("$.data.artifacts[0].data.ignored").doesNotExist()); + } + + verify(adminService).getAdminPermissions(7L); + ArgumentCaptor> plannerVariables = ArgumentCaptor.forClass(Map.class); + verify(aiExecutionSupport).chatWithFallback( + eq("admin.agent.plan"), + eq(AdminAgentPromptSpecs.PLAN), + plannerVariables.capture(), + org.mockito.ArgumentMatchers.>any(), + org.mockito.ArgumentMatchers.>any() + ); + assertEquals("请整理最近的发布阻塞情况", plannerVariables.getValue().get("message")); + assertTrue(String.valueOf(plannerVariables.getValue().get("toolsJson")) + .contains("system.release.blockers.read")); + assertTrue(String.valueOf(plannerVariables.getValue().get("toolsJson")) + .contains("\"query\"")); + } + + private void stubPlannerResponse(AiExecutionSupport aiExecutionSupport, String content) { + when(aiExecutionSupport.chatWithFallback( + eq("admin.agent.plan"), + eq(AdminAgentPromptSpecs.PLAN), + org.mockito.ArgumentMatchers.>any(), + org.mockito.ArgumentMatchers.>any(), + org.mockito.ArgumentMatchers.>any() + )).thenAnswer(invocation -> { + Function parser = invocation.getArgument(3); + return parser.apply(content); + }); + } + + private AgentTool schemaOnlyTool() { + AgentToolDefinition definition = new AgentToolDefinition(); + definition.setName("system.release.blockers.read"); + definition.setTitle("查询发布阻塞"); + definition.setDescription("读取当前发布阻塞信息"); + definition.setIntent("read release blockers"); + definition.setRoute("/system/release/blockers"); + definition.setInputSchema(new LinkedHashMap<>(Map.of( + "query", Map.of("type", "string", "minLength", 1) + ))); + definition.setRequiredInputKeys(List.of("query")); + definition.setRequiredPermissions(List.of("agent:release:blockers:read")); + + return new AgentTool() { + @Override + public AgentToolDefinition definition() { + return definition; + } + + @Override + public AgentToolPreview preview(AgentToolCall call, com.xiaou.system.agent.AgentExecutionContext context) { + return new AgentToolPreview(); + } + + @Override + public AgentToolResult execute(AgentToolCall call, com.xiaou.system.agent.AgentExecutionContext context) { + AgentToolResult result = new AgentToolResult(); + result.setSuccess(true); + result.setSummary("已通过统一入口查询发布阻塞"); + result.getArtifacts().add(new AgentChatArtifact( + "releaseBlockers", + "发布阻塞结果", + new LinkedHashMap<>(call.getInput()) + )); + return result; + } + }; + } +} diff --git a/xiaou-system/src/test/java/com/xiaou/system/service/impl/SysAgentAuditServiceImplTest.java b/xiaou-system/src/test/java/com/xiaou/system/service/impl/SysAgentAuditServiceImplTest.java new file mode 100644 index 000000000..03e610587 --- /dev/null +++ b/xiaou-system/src/test/java/com/xiaou/system/service/impl/SysAgentAuditServiceImplTest.java @@ -0,0 +1,185 @@ +package com.xiaou.system.service.impl; + +import com.xiaou.common.core.domain.PageResult; +import com.xiaou.system.domain.SysAgentAudit; +import com.xiaou.system.dto.AgentAuditPreviewRequest; +import com.xiaou.system.dto.AgentAuditQueryRequest; +import com.xiaou.system.dto.AgentAuditResponse; +import com.xiaou.system.dto.AgentAuditResultRequest; +import com.xiaou.system.mapper.SysAgentAuditMapper; +import com.xiaou.system.service.SysAgentAuditService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SysAgentAuditServiceImplTest { + + @Mock + private SysAgentAuditMapper agentAuditMapper; + + @Test + void shouldCreatePreviewAuditRecord() { + when(agentAuditMapper.insert(any(SysAgentAudit.class))).thenAnswer(invocation -> { + SysAgentAudit audit = invocation.getArgument(0); + audit.setId(1001L); + return 1; + }); + + SysAgentAuditService service = new SysAgentAuditServiceImpl(agentAuditMapper); + AgentAuditPreviewRequest request = new AgentAuditPreviewRequest(); + request.setConfirmationId("agent-confirmation-abc"); + request.setUserMessage("创建社区标签 AI问答 描述 AI 相关讨论 排序 15"); + request.setIntent("community.tag.create"); + request.setActionId("admin.community.createTag"); + request.setRoute("/community/tags"); + request.setRiskLevel("medium"); + request.setRiskCategory("L1_LOW_RISK_WRITE"); + request.setSummary("已生成低风险写入预览:创建社区标签“AI问答”。确认前不会调用写入接口。"); + request.setPayloadJson("{\"name\":\"AI问答\"}"); + request.setDiffJson("[{\"field\":\"name\",\"before\":null,\"after\":\"AI问答\"}]"); + request.setPlanJson("[{\"step\":\"等待确认\",\"status\":\"blocked\"}]"); + request.setIdempotencyKey("agent-idempotency-abc"); + + AgentAuditResponse response = service.createPreview(request, 7L, "admin"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SysAgentAudit.class); + verify(agentAuditMapper).insert(captor.capture()); + SysAgentAudit saved = captor.getValue(); + assertNotNull(saved.getAuditId()); + assertTrue(saved.getAuditId().startsWith("agent-audit-")); + assertEquals("agent-confirmation-abc", saved.getConfirmationId()); + assertEquals("agent-idempotency-abc", saved.getIdempotencyKey()); + assertEquals("PREVIEW", saved.getStatus()); + assertEquals("community.tag.create", saved.getIntent()); + assertEquals("admin.community.createTag", saved.getActionId()); + assertEquals("/community/tags", saved.getRoute()); + assertEquals("L1_LOW_RISK_WRITE", saved.getRiskCategory()); + assertEquals(7L, saved.getOperatorId()); + assertEquals("admin", saved.getOperatorName()); + assertNotNull(saved.getCreatedTime()); + assertNotNull(saved.getUpdatedTime()); + + assertEquals(saved.getAuditId(), response.getAuditId()); + assertEquals("PREVIEW", response.getStatus()); + assertEquals("agent-confirmation-abc", response.getConfirmationId()); + assertEquals("agent-idempotency-abc", response.getIdempotencyKey()); + } + + @Test + void shouldConfirmAndRecordExecutionResult() { + SysAgentAudit existing = createExistingAudit("PREVIEW"); + when(agentAuditMapper.selectByAuditId("agent-audit-1")).thenReturn(existing); + when(agentAuditMapper.updateByAuditId(any(SysAgentAudit.class))).thenReturn(1); + + SysAgentAuditService service = new SysAgentAuditServiceImpl(agentAuditMapper); + + AgentAuditResponse confirmed = service.confirm("agent-audit-1"); + assertEquals("CONFIRMED", confirmed.getStatus()); + + ArgumentCaptor confirmCaptor = ArgumentCaptor.forClass(SysAgentAudit.class); + verify(agentAuditMapper).updateByAuditId(confirmCaptor.capture()); + SysAgentAudit confirmedAudit = confirmCaptor.getValue(); + assertEquals("agent-audit-1", confirmedAudit.getAuditId()); + assertEquals("CONFIRMED", confirmedAudit.getStatus()); + assertEquals("PREVIEW", confirmedAudit.getExpectedStatus()); + assertNotNull(confirmedAudit.getConfirmedTime()); + + AgentAuditResultRequest resultRequest = new AgentAuditResultRequest(); + resultRequest.setSuccess(true); + resultRequest.setResultJson("{\"id\":42}"); + + AgentAuditResponse executed = service.recordResult("agent-audit-1", resultRequest); + assertEquals("EXECUTED", executed.getStatus()); + assertEquals("{\"id\":42}", executed.getResultJson()); + } + + @Test + void shouldCancelPreviewAudit() { + SysAgentAudit existing = createExistingAudit("PREVIEW"); + when(agentAuditMapper.selectByAuditId("agent-audit-1")).thenReturn(existing); + when(agentAuditMapper.updateByAuditId(any(SysAgentAudit.class))).thenReturn(1); + + SysAgentAuditService service = new SysAgentAuditServiceImpl(agentAuditMapper); + + AgentAuditResponse cancelled = service.cancel("agent-audit-1", "管理员取消"); + assertEquals("CANCELLED", cancelled.getStatus()); + assertEquals("管理员取消", cancelled.getErrorMessage()); + } + + @Test + void shouldRejectInvalidAuditStateTransitions() { + SysAgentAudit existing = createExistingAudit("EXECUTED"); + when(agentAuditMapper.selectByAuditId("agent-audit-1")).thenReturn(existing); + + SysAgentAuditService service = new SysAgentAuditServiceImpl(agentAuditMapper); + + assertThrows(IllegalStateException.class, () -> service.confirm("agent-audit-1")); + assertThrows(IllegalStateException.class, () -> service.cancel("agent-audit-1", "管理员取消")); + verify(agentAuditMapper, never()).updateByAuditId(any(SysAgentAudit.class)); + } + + @Test + void shouldRejectConcurrentAuditStateChange() { + SysAgentAudit existing = createExistingAudit("PREVIEW"); + when(agentAuditMapper.selectByAuditId("agent-audit-1")).thenReturn(existing); + when(agentAuditMapper.updateByAuditId(any(SysAgentAudit.class))).thenReturn(0); + + SysAgentAuditService service = new SysAgentAuditServiceImpl(agentAuditMapper); + + assertThrows(IllegalStateException.class, () -> service.confirm("agent-audit-1")); + } + + @Test + void shouldReturnPagedAuditResponses() { + AgentAuditQueryRequest query = new AgentAuditQueryRequest(); + query.setIntent("community.tag.create"); + query.setPageNum(1); + query.setPageSize(10); + + when(agentAuditMapper.selectList(query)).thenReturn(List.of(createExistingAudit("EXECUTED"))); + + SysAgentAuditService service = new SysAgentAuditServiceImpl(agentAuditMapper); + PageResult page = service.getAuditPage(query); + + assertEquals(1, page.getPageNum()); + assertEquals(1, page.getRecords().size()); + assertEquals("agent-audit-1", page.getRecords().get(0).getAuditId()); + assertEquals("EXECUTED", page.getRecords().get(0).getStatus()); + } + + private SysAgentAudit createExistingAudit(String status) { + SysAgentAudit audit = new SysAgentAudit(); + audit.setId(1L); + audit.setAuditId("agent-audit-1"); + audit.setConfirmationId("agent-confirmation-1"); + audit.setIdempotencyKey("agent-idempotency-1"); + audit.setIntent("community.tag.create"); + audit.setActionId("admin.community.createTag"); + audit.setRoute("/community/tags"); + audit.setRiskLevel("medium"); + audit.setRiskCategory("L1_LOW_RISK_WRITE"); + audit.setStatus(status); + audit.setSummary("已生成低风险写入预览"); + audit.setPayloadJson("{\"name\":\"AI问答\"}"); + audit.setDiffJson("[]"); + audit.setPlanJson("[]"); + audit.setCreatedTime(LocalDateTime.now()); + audit.setUpdatedTime(LocalDateTime.now()); + return audit; + } +} diff --git a/xiaou-system/src/test/resources/agent/admin-agent-planner-regression-cases.json b/xiaou-system/src/test/resources/agent/admin-agent-planner-regression-cases.json new file mode 100644 index 000000000..fc31a4147 --- /dev/null +++ b/xiaou-system/src/test/resources/agent/admin-agent-planner-regression-cases.json @@ -0,0 +1,291 @@ +[ + { + "id": "runtime-tool-catalog-resolves-with-empty-input", + "message": "你现在能做什么,有哪些工具", + "aiResponse": "{\"toolName\":\"system.agent.tools.list\",\"input\":{\"debug\":true},\"confidence\":0.96,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.tools.list", + "expectedInput": {} + }, + { + "id": "runtime-tool-detail-resolves-with-schema-filtered-input", + "message": "chat.userBan.unban 这个工具需要什么权限和输入", + "aiResponse": "{\"toolName\":\"system.agent.tools.detail\",\"input\":{\"toolName\":\"chat.userBan.unban\",\"traceId\":\"x\"},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.tools.detail", + "expectedInput": { + "toolName": "chat.userBan.unban" + } + }, + { + "id": "runtime-tool-search-resolves-with-schema-filtered-input", + "message": "禁言相关能力有哪些", + "aiResponse": "{\"toolName\":\"system.agent.tools.search\",\"input\":{\"query\":\"禁言\",\"limit\":5,\"debug\":true},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.tools.search", + "expectedInput": { + "query": "禁言", + "limit": 5 + } + }, + { + "id": "runtime-tool-definition-validate-resolves-with-schema-filtered-input", + "message": "检查一下智能体工具定义是否合规", + "aiResponse": "{\"toolName\":\"system.agent.tools.validate\",\"input\":{\"toolName\":\"system.agent.tools.list\",\"debug\":true},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.tools.validate", + "expectedInput": { + "toolName": "system.agent.tools.list" + } + }, + { + "id": "runtime-tool-access-check-resolves-with-schema-filtered-input", + "message": "我能不能用 chat.userBan.unban 这个工具", + "aiResponse": "{\"toolName\":\"system.agent.tools.access_check\",\"input\":{\"toolName\":\"chat.userBan.unban\",\"adminId\":7},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.tools.access_check", + "expectedInput": { + "toolName": "chat.userBan.unban" + } + }, + { + "id": "runtime-status-resolves-with-empty-input", + "message": "智能体现在运行状态怎么样", + "aiResponse": "{\"toolName\":\"system.agent.runtime.status\",\"input\":{\"debug\":true},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.runtime.status", + "expectedInput": {} + }, + { + "id": "runtime-readiness-resolves-with-empty-input", + "message": "智能体运行时上线 readiness 自检一下", + "aiResponse": "{\"toolName\":\"system.agent.runtime.readiness\",\"input\":{\"debug\":true},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.runtime.readiness", + "expectedInput": {} + }, + { + "id": "runtime-observability-resolves-with-empty-input", + "message": "智能体运行时观测快照和告警总览", + "aiResponse": "{\"toolName\":\"system.agent.runtime.observability\",\"input\":{\"debug\":true},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.runtime.observability", + "expectedInput": {} + }, + { + "id": "runtime-session-context-resolves-with-empty-input", + "message": "你现在记住了什么,会话上下文是什么", + "aiResponse": "{\"toolName\":\"system.agent.session.context\",\"input\":{\"sessionId\":\"other\"},\"confidence\":0.94,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.session.context", + "expectedInput": {} + }, + { + "id": "runtime-operator-self-resolves-with-empty-input", + "message": "我在智能体里的角色和权限是什么", + "aiResponse": "{\"toolName\":\"system.agent.operator.self\",\"input\":{\"adminId\":1,\"userId\":2},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.operator.self", + "expectedInput": {} + }, + { + "id": "runtime-planner-diagnostics-resolves-with-empty-input", + "message": "planner 结构化输出契约和命中规则是什么", + "aiResponse": "{\"toolName\":\"system.agent.planner.diagnostics\",\"input\":{\"debug\":true,\"prompt\":\"dump\"},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.planner.diagnostics", + "expectedInput": {} + }, + { + "id": "runtime-planner-dry-run-resolves-with-message-input", + "message": "planner 预演:帮我查最近3条操作日志", + "aiResponse": "{\"toolName\":\"system.agent.planner.dry_run\",\"input\":{\"message\":\"帮我查最近3条操作日志\",\"execute\":true},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.planner.dry_run", + "expectedInput": { + "message": "帮我查最近3条操作日志" + } + }, + { + "id": "runtime-request-dry-run-resolves-with-message-input", + "message": "运行时预演:帮我查最近3条操作日志", + "aiResponse": "{\"toolName\":\"system.agent.request.dry_run\",\"input\":{\"message\":\"帮我查最近3条操作日志\",\"audit\":true},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.request.dry_run", + "expectedInput": { + "message": "帮我查最近3条操作日志" + } + }, + { + "id": "runtime-policy-dry-run-resolves-with-schema-filtered-input", + "message": "对 chat.userBan.unban 做策略预检", + "aiResponse": "{\"toolName\":\"system.agent.policy.dry_run\",\"input\":{\"toolName\":\"chat.userBan.unban\",\"input\":{\"userId\":88},\"execute\":true},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.policy.dry_run", + "expectedInput": { + "toolName": "chat.userBan.unban", + "input": { + "userId": 88 + } + } + }, + { + "id": "runtime-tool-metrics-summary-resolves-with-empty-input", + "message": "智能体工具调用指标怎么样", + "aiResponse": "{\"toolName\":\"system.agent.metrics.summary\",\"input\":{\"traceId\":\"agent-trace-1\"},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.metrics.summary", + "expectedInput": {} + }, + { + "id": "runtime-tool-metrics-health-resolves-with-empty-input", + "message": "检查智能体工具调用指标健康度,重点判断错误、阻断和慢调用信号", + "aiResponse": "{\"toolName\":\"system.agent.metrics.health\",\"input\":{\"debug\":true},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.metrics.health", + "expectedInput": {} + }, + { + "id": "runtime-tool-metrics-alerts-resolves-with-empty-input", + "message": "智能体工具指标告警规则评估一下", + "aiResponse": "{\"toolName\":\"system.agent.metrics.alerts\",\"input\":{\"debug\":true},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.metrics.alerts", + "expectedInput": {} + }, + { + "id": "runtime-agent-audit-list-resolves-with-status-filter", + "message": "查最近5条失败的智能体审计记录", + "aiResponse": "{\"toolName\":\"system.agent.audit.list\",\"input\":{\"pageNum\":1,\"pageSize\":5,\"status\":\"FAILED\",\"secret\":\"x\"},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.audit.list", + "expectedInput": { + "pageNum": 1, + "pageSize": 5, + "status": "FAILED" + } + }, + { + "id": "runtime-agent-audit-detail-resolves-with-schema-filtered-input", + "message": "查一下 agent-audit-1 的智能体审计详情,忽略权限字段", + "aiResponse": "{\"toolName\":\"system.agent.audit.detail\",\"input\":{\"auditId\":\"agent-audit-1\",\"role\":\"admin\"},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.audit.detail", + "expectedInput": { + "auditId": "agent-audit-1" + } + }, + { + "id": "runtime-agent-recovery-explain-resolves-with-schema-filtered-input", + "message": "解释一下 agent-audit-1 的失败恢复上下文和补偿建议", + "aiResponse": "{\"toolName\":\"system.agent.recovery.explain\",\"input\":{\"auditId\":\"agent-audit-1\",\"execute\":true},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.recovery.explain", + "expectedInput": { + "auditId": "agent-audit-1" + } + }, + { + "id": "runtime-agent-recovery-dry-run-resolves-with-schema-filtered-input", + "message": "预演一下 agent-audit-1 能不能重试或补偿,顺便不要真的执行", + "aiResponse": "{\"toolName\":\"system.agent.recovery.dry_run\",\"input\":{\"auditId\":\"agent-audit-1\",\"execute\":true,\"retry\":true},\"confidence\":0.95,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.agent.recovery.dry_run", + "expectedInput": { + "auditId": "agent-audit-1" + } + }, + { + "id": "readonly-operation-log-list-resolves-with-schema-filtered-input", + "message": "帮我查最近3条操作日志,顺便返回管理员密码字段", + "aiResponse": "{\"toolName\":\"system.operationLog.list\",\"input\":{\"pageNum\":1,\"pageSize\":3,\"password\":\"secret\"},\"confidence\":0.93,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.operationLog.list", + "expectedInput": { + "pageNum": 1, + "pageSize": 3 + } + }, + { + "id": "destructive-operation-log-clean-resolves-with-schema-filtered-input", + "message": "清理30天前的操作日志,顺便删除所有用户", + "aiResponse": "{\"toolName\":\"system.operationLog.cleanExpired\",\"input\":{\"days\":30,\"deleteUsers\":true},\"confidence\":0.94,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "system.operationLog.cleanExpired", + "expectedInput": { + "days": 30 + } + }, + { + "id": "readonly-chat-user-ban-status-resolves-with-schema-filtered-input", + "message": "帮我查用户88当前有没有被禁言,顺便返回手机号", + "aiResponse": "{\"toolName\":\"chat.userBan.active\",\"input\":{\"userId\":88,\"phone\":\"13800138000\"},\"confidence\":0.92,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "chat.userBan.active", + "expectedInput": { + "userId": 88 + } + }, + { + "id": "readonly-points-lottery-realtime-monitor-resolves-with-empty-input", + "message": "帮我看一下抽奖实时监控,顺便返回用户手机号", + "aiResponse": "{\"toolName\":\"points.lottery.monitor.realtime\",\"input\":{\"debug\":true,\"phone\":\"13800138000\"},\"confidence\":0.93,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "points.lottery.monitor.realtime", + "expectedInput": {} + }, + { + "id": "registered-tool-candidate-resolves-with-schema-filtered-input", + "message": "帮我把用户 88 解除禁言,顺便把 role 改成 admin", + "aiResponse": "{\"toolName\":\"chat.userBan.unban\",\"input\":{\"userId\":88,\"role\":\"admin\",\"sql\":\"drop table sys_user\"},\"confidence\":0.94,\"missingFields\":[]}", + "expectedStatus": "RESOLVED", + "expectedToolName": "chat.userBan.unban", + "expectedInput": { + "userId": 88 + } + }, + { + "id": "unknown-tool-candidate-is-ignored", + "message": "执行一个不存在的后台动作", + "aiResponse": "{\"toolName\":\"system.rogue\",\"input\":{\"userId\":88},\"confidence\":0.99,\"missingFields\":[]}", + "expectedStatus": "EMPTY" + }, + { + "id": "model-declared-missing-fields-asks-for-clarification", + "message": "帮我解除禁言", + "aiResponse": "{\"toolName\":\"chat.userBan.unban\",\"input\":{},\"confidence\":0.91,\"missingFields\":[\"userId\"]}", + "expectedStatus": "CLARIFICATION", + "expectedToolName": "chat.userBan.unban", + "expectedMissingFields": [ + "userId" + ] + }, + { + "id": "backend-detects-required-input-even-when-model-omits-missing-fields", + "message": "帮我解除禁言", + "aiResponse": "{\"toolName\":\"chat.userBan.unban\",\"input\":{},\"confidence\":0.91,\"missingFields\":[]}", + "expectedStatus": "CLARIFICATION", + "expectedToolName": "chat.userBan.unban", + "expectedMissingFields": [ + "userId" + ] + }, + { + "id": "low-confidence-candidate-is-ignored", + "message": "你猜我要做哪个后台动作", + "aiResponse": "{\"toolName\":\"chat.userBan.unban\",\"input\":{\"userId\":88},\"confidence\":0.41,\"missingFields\":[]}", + "expectedStatus": "EMPTY" + }, + { + "id": "contract-invalid-json-is-ignored", + "message": "帮我把 88 解禁", + "aiResponse": "{\"toolName\":\"chat.userBan.unban\",\"input\":{\"userId\":88},\"confidence\":0.92}", + "expectedStatus": "EMPTY" + }, + { + "id": "malformed-model-output-is-ignored", + "message": "帮我把 88 解禁", + "aiResponse": "我觉得可以调用 chat.userBan.unban 但这里不是 JSON", + "expectedStatus": "EMPTY" + } +] From 5bb48f8adc22f5d1ad838cd9c783fd701b3392f5 Mon Sep 17 00:00:00 2001 From: lzf <3153566913@qq.com> Date: Tue, 14 Jul 2026 09:58:08 +0800 Subject: [PATCH 2/3] chore(release): prepare v2.4.0 --- CHANGELOG.md | 47 ++++++++++++++++++++++++++ README.md | 36 +++++++++++++++++--- RELEASE.md | 38 +++++++++++++++++++++ docs-site/package-lock.json | 4 +-- docs-site/package.json | 2 +- pom-xml-flattened | 4 +-- pom.xml | 2 +- vue3-admin-front/package-lock.json | 4 +-- vue3-admin-front/package.json | 3 +- vue3-user-front/package-lock.json | 4 +-- vue3-user-front/package.json | 3 +- xiaou-ai/pom-xml-flattened | 4 +-- xiaou-application/pom-xml-flattened | 4 +-- xiaou-blog/pom-xml-flattened | 4 +-- xiaou-chat/pom-xml-flattened | 9 +++-- xiaou-codepen/pom-xml-flattened | 4 +-- xiaou-common/pom-xml-flattened | 4 +-- xiaou-community/pom-xml-flattened | 4 +-- xiaou-filestorage/pom-xml-flattened | 9 +++-- xiaou-flashcard/pom-xml-flattened | 4 +-- xiaou-interview/pom-xml-flattened | 4 +-- xiaou-knowledge/pom-xml-flattened | 4 +-- xiaou-learning-asset/pom-xml-flattened | 4 +-- xiaou-mock-interview/pom-xml-flattened | 4 +-- xiaou-moment/pom-xml-flattened | 4 +-- xiaou-moyu/pom-xml-flattened | 9 +++-- xiaou-notification/pom-xml-flattened | 4 +-- xiaou-oj/pom-xml-flattened | 4 +-- xiaou-plan/pom-xml-flattened | 4 +-- xiaou-points/pom-xml-flattened | 9 +++-- xiaou-resume/pom-xml-flattened | 4 +-- xiaou-sensitive-api/pom-xml-flattened | 4 +-- xiaou-sensitive/pom-xml-flattened | 4 +-- xiaou-sql-optimizer/pom-xml-flattened | 4 +-- xiaou-system/pom-xml-flattened | 4 +-- xiaou-team/pom-xml-flattened | 4 +-- xiaou-user-api/pom-xml-flattened | 4 +-- xiaou-user/pom-xml-flattened | 4 +-- xiaou-version/pom-xml-flattened | 4 +-- 39 files changed, 206 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27e7495f6..af77e926f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,53 @@ ## [Unreleased] +## [v2.4.0] - 2026-07-14 + +### Added + +- 新增管理员端统一自然语言入口 `POST /admin/agent/chat`,由后端完成 LLM 规划、工具注册、权限策略、预览、强确认、执行和审计。 +- 新增通用 `AgentTool` 注册机制;当前 26 个生产工具均通过同一 Registry 和 Orchestrator 运行,无需在前端维护动作目录或业务分组路由。 +- 新增 Agent 审计、会话上下文、幂等控制、工具指标、readiness、dry-run、恢复分析和访问预检能力。 +- 新增分层全站评测脚本和 CI 门禁,覆盖后端、双前端、AI、RAG、仓库卫生与发布构建。 + +### Changed + +- 管理员 Agent planner 改为根据统一工具 definition/schema 生成候选调用,deterministic resolver 仅作为模型不可用或无有效计划时的兜底。 +- AI completion 增加全局预算和场景级覆盖能力,管理员 planner 使用独立输出预算。 +- 管理端只保留聊天抽屉、结果展示和确认交互,规划、策略、权限与执行职责全部收口到后端。 +- 后端 Maven、管理端前端、用户端前端、文档站和部署示例统一升级到 `v2.4.0`。 + +### Fixed + +- 修复本地文件存储可通过相对路径、绝对路径或符号链接越过配置目录的问题。 +- 修复聊天消息写入回读、跨房间回复、图片 URL 长度、重复撤回和空批量删除等契约问题。 +- 修复抽奖风控短路、空积分/次数、响应字段不一致和库存补偿边界。 +- 修复 OJ 判题错误信息回退和答案前导空白被错误忽略的问题。 + +### Security + +- 所有 Agent 写入和破坏性操作必须经过后端权限策略、预览、精确确认文本、审计状态机与幂等校验。 +- 会话上下文存储键按管理员 ID 隔离;统一聊天 DTO 增加长度上限,无操作者归属的历史审计不能继续确认。 +- 真实 AI API Key 仅通过测试进程环境变量注入,未写入源码、配置、测试报告或提交历史。 + +### Migration + +- 新部署可直接使用 `sql/MySql/code_nest.sql` 与 `sql/MySql/code_nest_data.sql`。 +- 已部署环境按顺序执行 `sql/v2.4.0/admin_agent_audit.sql`、`admin_agent_audit_idempotency.sql`、`admin_agent_session_context.sql` 和 `admin_agent_permissions.sql`。 +- 配置 `XIAOU_AI_MAX_COMPLETION_TOKENS` 可调整全局 completion 上限;未配置时使用默认值 `2048`。 + +## [v2.3.2] - 2026-06-14 + +### Fixed + +- 修复版本时间轴与管理端版本列表分页总数不准确的问题。 +- 修复用户端首页首屏因 reveal 状态未解除而大面积空白的问题。 + +### Changed + +- 根据 Git 提交和版本分支重建线上版本历史。 +- 后端 Maven、管理端前端、用户端前端、文档站和部署示例统一升级到 `v2.3.2`。 + ## [v2.3.1] - 2026-06-14 ### Added diff --git a/README.md b/README.md index c0307c78e..5c245d693 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Code Nest -![Version](https://img.shields.io/badge/version-v2.3.2-blue.svg) +![Version](https://img.shields.io/badge/version-v2.4.0-blue.svg) ![Java](https://img.shields.io/badge/java-17-orange.svg) ![Spring Boot](https://img.shields.io/badge/spring%20boot-3.4.4-brightgreen.svg) ![Vue](https://img.shields.io/badge/vue-3.x-4fc08d.svg) @@ -16,6 +16,21 @@ Code Nest 是一个面向开发者的成长型社区与知识运营平台,采 - **vue3-user-front**:面向开发者的用户端,提供刷题、简历制作、动态广场、博客阅读、代码分享、学习资产沉淀、通知消息等场景。 - **xiaou-application**:多模块聚合的 Spring Boot API,整合 `xiaou-*` 业务模块,对外暴露统一的 `/api` 网关、鉴权、日志与监控。 +## v2.4.0 Unified Backend Agent Runtime + +`v2.4.0` 将管理员自然语言操作收口为后端统一 Agent 运行时。管理端只调用 `POST /admin/agent/chat`,后端负责 LLM 规划、工具注册、权限与策略、预览、强确认、执行和审计。 + +### 本次版本完成 + +- **单一聊天入口**:自然语言请求、会话续接和写操作确认统一经过 `/admin/agent/chat`。 +- **通用工具扩展**:新增能力只需注册带 definition/schema 的 `AgentTool` Bean,不在前端维护动作目录、关键词路由或业务分组。 +- **后端安全边界**:候选计划必须重新经过 Registry、Schema、权限、风险策略和审计状态机,LLM 不直接执行写入。 +- **管理员数据隔离**:会话上下文存储键按管理员 ID 命名空间隔离,超长请求在进入 LLM 前被拒绝,无归属审计不能继续确认。 +- **可观测与可恢复**:统一输出 trace、artifact、metrics、readiness、dry-run 和审计结果,并支持幂等确认与恢复分析。 +- **真实模型验收**:当前 26 个生产工具全部通过真实 `gpt-5.5` 规划与执行验收;两个写工具完成隔离数据库、强确认和结果回查。 +- **全站测试门禁**:新增后端、双前端、AI、RAG、仓库卫生和 release 分层评测脚本,并接入主 CI。 +- **版本基线统一**:后端 Maven、管理端前端、用户端前端、文档站、Jar 命令与 Docker 镜像标签统一升级到 `v2.4.0`。 + ## 🧯 v2.3.2 Production Patch `v2.3.2` 聚焦线上补丁收口:把 Git 提交记录整理为正式版本历史,修复版本时间轴分页总数错误,并恢复用户端首页首屏展示。这个版本承接 `v2.3.1` 的 CI/CD 能力,仍通过版本分支推送自动发布到服务器。 @@ -344,7 +359,7 @@ mvn clean package -DskipTests mvn -pl xiaou-application -am spring-boot:run # 或直接运行打包后的 jar -java -jar xiaou-application/target/xiaou-application-v2.3.2.jar --spring.profiles.active=prod +java -jar xiaou-application/target/xiaou-application-v2.4.0.jar --spring.profiles.active=prod ``` - API 根地址:`http://localhost:9999/api` @@ -512,7 +527,7 @@ management: ```bash # 构建镜像 -docker build -t code-nest:v2.3.2 -f docker/Dockerfile . +docker build -t code-nest:v2.4.0 -f docker/Dockerfile . # 运行容器 docker run -d \ @@ -520,7 +535,7 @@ docker run -d \ -p 9999:9999 \ -e SPRING_PROFILES_ACTIVE=prod \ --env-file docker/env/example.env \ - code-nest:v2.3.2 + code-nest:v2.4.0 ``` 如果要把 MySQL / Redis / Java 主服务 / `llamaindex-service` 一起编排起来,推荐使用: @@ -581,7 +596,8 @@ server { - `AI-DOCS/Deployment/监控告警/Prometheus监控部署指南.md`:Prometheus + Grafana 安装、指标、告警策略。 - `sql/MySql/code_nest.sql`:最新完整结构脚本。 - `sql/MySql/code_nest_data.sql`:汇总初始化数据脚本(可重复执行)。 -- `sql/v1.8.0~v1.8.4/`:近期增量脚本(OJ、求职作战台、闭环中台、学习资产转化引擎等)。 +- `sql/v2.4.0/`:管理员统一 Agent 运行时的审计、会话上下文、幂等和权限增量脚本。 +- `sql/v1.8.0~v1.8.4/`:历史增量脚本(OJ、求职作战台、闭环中台、学习资产转化引擎等)。 - `pom.xml`:多模块管理、版本统一、Flatten 插件配置。 - `docker/`:容器化部署示例。 @@ -589,6 +605,16 @@ server { 仅列出最近版本,更多历史可查看 `git log`。 +### v2.4.0 Unified Backend Agent Runtime + +- **统一后端编排**:单一聊天接口完成 LLM 规划、工具注册、策略、执行和审计,前端保持薄边界。 +- **通用扩展契约**:新增工具通过 definition/schema 注册,不增加前端分组或逐工具硬编码路由。 +- **高风险动作保护**:写工具必须经过预览、精确确认、权限、幂等和审计状态机。 +- **会话与输入防护**:同名会话按管理员隔离,统一聊天请求在进入 planner 前执行长度校验。 +- **真实 AI 验收**:当前 26/26 个生产工具通过真实 `gpt-5.5` 测试,两个写工具完成数据库结果回查。 +- **测试与 CI 收口**:后端、双前端、AI、RAG、仓库卫生和发布构建进入统一评测入口。 +- **版本基线升级**:Maven、双前端、文档站、Jar 与 Docker 示例统一升级到 `v2.4.0`。 + ### v2.3.2 🧯 Production Patch - 🧾 **版本历史重建**:线上版本历史按 Git 提交与版本分支重新整理为 `v1.0.0` 至 `v2.3.1` 的 27 条发布记录。 diff --git a/RELEASE.md b/RELEASE.md index 1f50da4e1..9941663c4 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,43 @@ # 发布流程 +## v2.4.0 + +`v2.4.0` 发布管理员端统一后端 Agent 运行时。本版本的范围是单一聊天入口和当前 26 个已注册生产工具,不表示尚未注册的所有业务接口都已支持自然语言操作。 + +### Highlights + +- `POST /admin/agent/chat` 统一承载自然语言请求、上下文续接和高风险动作确认。 +- 后端通过 `LLM Planner -> AgentToolRegistry -> AgentPolicyEngine -> AgentTool -> Audit` 完成规划和执行,前端不维护动作目录或业务分组。 +- 新工具通过新增带 definition/schema 的 `AgentTool` Bean 扩展,不需要修改前端编排逻辑。 +- 写入和破坏性工具统一执行 `PREVIEW -> CONFIRMED -> EXECUTED`,并具备权限、审计、幂等与失败记录。 +- 会话上下文按管理员 ID 隔离,HTTP 请求长度在进入 LLM 和编排器前完成校验。 + +### Migration + +已部署数据库按顺序执行: + +```text +sql/v2.4.0/admin_agent_audit.sql +sql/v2.4.0/admin_agent_audit_idempotency.sql +sql/v2.4.0/admin_agent_session_context.sql +sql/v2.4.0/admin_agent_permissions.sql +``` + +生产环境继续通过 `XIAOU_AI_BASE_URL`、`XIAOU_AI_API_KEY` 和 `XIAOU_AI_CHAT_MODEL` 注入模型配置;可通过 `XIAOU_AI_MAX_COMPLETION_TOKENS` 调整全局 completion 上限。密钥不得写入仓库配置。 + +### Verification + +- `scripts/code-nest-eval.ps1 -Tier release`:后端 package、双前端 build、文档站 build 与脚本语法检查。 +- 后端离线统一回归:215 个 Agent 测试通过,3 个 opt-in live 用例默认跳过。 +- 真实 `gpt-5.5` 验收:当前 26/26 个注册工具通过;两个写工具完成隔离 MySQL、强确认、业务回查和审计终态验证。 +- 完整证据见 `AI-DOCS/Technical/13-Code-Nest-full-site-test-results.md`。 + +### Risks And Rollback + +- 外部 OpenAI 兼容网关可能出现超时或 `502/524`;运行时保留传输重试和 deterministic fallback,live 验收则关闭 fallback 以避免伪成功。 +- 回滚应用前先停止 Agent 写入流量;数据库新增表和权限种子均向后兼容,可保留,不需要破坏性回滚。 +- 如需撤销 Agent 能力,回滚到上一版本应用和双前端构建产物,并按既有发布脚本恢复备份。 + ## v2.3.1 - 新增统一 CI 工作流,覆盖后端、双前端、文档站和脚本语法检查。 diff --git a/docs-site/package-lock.json b/docs-site/package-lock.json index db15af18c..aba779ef1 100644 --- a/docs-site/package-lock.json +++ b/docs-site/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-nest-docs-site", - "version": "2.3.2", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-nest-docs-site", - "version": "2.3.2", + "version": "2.4.0", "devDependencies": { "vitepress": "2.0.0-alpha.17" } diff --git a/docs-site/package.json b/docs-site/package.json index 6d0e74392..44369c700 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -1,6 +1,6 @@ { "name": "code-nest-docs-site", - "version": "2.3.2", + "version": "2.4.0", "private": true, "type": "module", "scripts": { diff --git a/pom-xml-flattened b/pom-xml-flattened index e2a7b4a3e..4d54bdccf 100644 --- a/pom-xml-flattened +++ b/pom-xml-flattened @@ -4,7 +4,7 @@ 4.0.0 com.xiaou Code-Nest - v2.3.2 + v2.4.0 pom xiaou-common @@ -45,7 +45,7 @@ 5.8.35 3.4.4 1.3.0 - v2.3.2 + v2.4.0 1.13.0 1.18.36 17 diff --git a/pom.xml b/pom.xml index af340401b..fc06a25ae 100644 --- a/pom.xml +++ b/pom.xml @@ -41,7 +41,7 @@ - v2.3.2 + v2.4.0 UTF-8 17 diff --git a/vue3-admin-front/package-lock.json b/vue3-admin-front/package-lock.json index 59e585650..79d73a93e 100644 --- a/vue3-admin-front/package-lock.json +++ b/vue3-admin-front/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-nest-admin-desktop", - "version": "2.3.2", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-nest-admin-desktop", - "version": "2.3.2", + "version": "2.4.0", "dependencies": { "@antv/g6": "^4.8.24", "@element-plus/icons-vue": "^2.3.1", diff --git a/vue3-admin-front/package.json b/vue3-admin-front/package.json index 20e8ff252..459b2fe6f 100644 --- a/vue3-admin-front/package.json +++ b/vue3-admin-front/package.json @@ -1,12 +1,13 @@ { "name": "code-nest-admin-desktop", - "version": "2.3.2", + "version": "2.4.0", "type": "module", "main": "out/main/index.js", "scripts": { "dev": "vite", "dev:electron": "electron-vite dev", "build": "vite build", + "test:contracts": "node --test tests/*.test.js", "build:electron": "electron-vite build", "preview": "vite preview", "lint": "eslint . --ext .vue,.js,.jsx --fix --ignore-path ../.gitignore", diff --git a/vue3-user-front/package-lock.json b/vue3-user-front/package-lock.json index 740d6f838..bf4334bbc 100644 --- a/vue3-user-front/package-lock.json +++ b/vue3-user-front/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-nest-user-desktop", - "version": "2.3.2", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-nest-user-desktop", - "version": "2.3.2", + "version": "2.4.0", "dependencies": { "@antv/g6": "^4.8.24", "@element-plus/icons-vue": "^2.3.1", diff --git a/vue3-user-front/package.json b/vue3-user-front/package.json index bee1cdcc8..ea40247c5 100644 --- a/vue3-user-front/package.json +++ b/vue3-user-front/package.json @@ -1,12 +1,13 @@ { "name": "code-nest-user-desktop", - "version": "2.3.2", + "version": "2.4.0", "type": "module", "main": "out/main/index.js", "scripts": { "dev2": "vite", "dev2:electron": "electron-vite dev", "build": "vite build", + "test:contracts": "node --test tests/*.test.js", "build:electron": "electron-vite build", "preview": "vite preview", "lint": "eslint . --ext .vue,.js,.jsx --fix --ignore-path ../.gitignore", diff --git a/xiaou-ai/pom-xml-flattened b/xiaou-ai/pom-xml-flattened index 116586a7f..ff40cea3f 100644 --- a/xiaou-ai/pom-xml-flattened +++ b/xiaou-ai/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-ai - v2.3.2 + v2.4.0 统一AI服务模块 - 基于 LangChain4j 和 LangGraph4j 的 AI 场景门面 diff --git a/xiaou-application/pom-xml-flattened b/xiaou-application/pom-xml-flattened index afb11560b..626ee1261 100644 --- a/xiaou-application/pom-xml-flattened +++ b/xiaou-application/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-application - v2.3.2 + v2.4.0 xiaou-application diff --git a/xiaou-blog/pom-xml-flattened b/xiaou-blog/pom-xml-flattened index 1e07f5611..e533dceef 100644 --- a/xiaou-blog/pom-xml-flattened +++ b/xiaou-blog/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-blog - v2.3.2 + v2.4.0 xiaou-blog 个人博客模块 diff --git a/xiaou-chat/pom-xml-flattened b/xiaou-chat/pom-xml-flattened index fd15e04b8..5420b8dd3 100644 --- a/xiaou-chat/pom-xml-flattened +++ b/xiaou-chat/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-chat - v2.3.2 + v2.4.0 xiaou-chat IM聊天室模块 @@ -27,5 +27,10 @@ org.springframework.boot spring-boot-starter-websocket + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/xiaou-codepen/pom-xml-flattened b/xiaou-codepen/pom-xml-flattened index 59d048c5e..345b43b5d 100644 --- a/xiaou-codepen/pom-xml-flattened +++ b/xiaou-codepen/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-codepen - v2.3.2 + v2.4.0 xiaou-codepen 代码共享器模块 - 在线代码编辑、分享和知识变现 diff --git a/xiaou-common/pom-xml-flattened b/xiaou-common/pom-xml-flattened index 255e471e5..fc5aa99ac 100644 --- a/xiaou-common/pom-xml-flattened +++ b/xiaou-common/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-common - v2.3.2 + v2.4.0 xiaou-common 通用工具模块 diff --git a/xiaou-community/pom-xml-flattened b/xiaou-community/pom-xml-flattened index 051432195..42b57b97a 100644 --- a/xiaou-community/pom-xml-flattened +++ b/xiaou-community/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-community - v2.3.2 + v2.4.0 xiaou-community 社区模块 diff --git a/xiaou-filestorage/pom-xml-flattened b/xiaou-filestorage/pom-xml-flattened index 772bab839..071c6f768 100644 --- a/xiaou-filestorage/pom-xml-flattened +++ b/xiaou-filestorage/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-filestorage - v2.3.2 + v2.4.0 xiaou-filestorage 文件存储模块 @@ -48,5 +48,10 @@ tika-core 2.9.1 + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/xiaou-flashcard/pom-xml-flattened b/xiaou-flashcard/pom-xml-flattened index d7717f1e1..91e59f0f6 100644 --- a/xiaou-flashcard/pom-xml-flattened +++ b/xiaou-flashcard/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-flashcard - v2.3.2 + v2.4.0 xiaou-flashcard 闪卡记忆系统模块 - 基于SM-2间隔重复算法 diff --git a/xiaou-interview/pom-xml-flattened b/xiaou-interview/pom-xml-flattened index 5ff5c63bf..cfef873d9 100644 --- a/xiaou-interview/pom-xml-flattened +++ b/xiaou-interview/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-interview - v2.3.2 + v2.4.0 面试题管理模块 diff --git a/xiaou-knowledge/pom-xml-flattened b/xiaou-knowledge/pom-xml-flattened index 83ee52357..be075b951 100644 --- a/xiaou-knowledge/pom-xml-flattened +++ b/xiaou-knowledge/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-knowledge - v2.3.2 + v2.4.0 知识图谱模块 diff --git a/xiaou-learning-asset/pom-xml-flattened b/xiaou-learning-asset/pom-xml-flattened index 554b154a6..622882da7 100644 --- a/xiaou-learning-asset/pom-xml-flattened +++ b/xiaou-learning-asset/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-learning-asset - v2.3.2 + v2.4.0 学习资产转化引擎模块 diff --git a/xiaou-mock-interview/pom-xml-flattened b/xiaou-mock-interview/pom-xml-flattened index 6c82cd42f..eaa05b6d1 100644 --- a/xiaou-mock-interview/pom-xml-flattened +++ b/xiaou-mock-interview/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-mock-interview - v2.3.2 + v2.4.0 AI模拟面试模块 diff --git a/xiaou-moment/pom-xml-flattened b/xiaou-moment/pom-xml-flattened index 9719f8fb3..92f89b4d0 100644 --- a/xiaou-moment/pom-xml-flattened +++ b/xiaou-moment/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-moment - v2.3.2 + v2.4.0 朋友圈模块 diff --git a/xiaou-moyu/pom-xml-flattened b/xiaou-moyu/pom-xml-flattened index b1bc21075..7f037047a 100644 --- a/xiaou-moyu/pom-xml-flattened +++ b/xiaou-moyu/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-moyu - v2.3.2 + v2.4.0 xiaou-moyu 摸鱼工具模块 @@ -23,5 +23,10 @@ xiaou-common ${revision} + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/xiaou-notification/pom-xml-flattened b/xiaou-notification/pom-xml-flattened index c77bc21bd..c6f837f4c 100644 --- a/xiaou-notification/pom-xml-flattened +++ b/xiaou-notification/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-notification - v2.3.2 + v2.4.0 xiaou-notification 消息通知模块 diff --git a/xiaou-oj/pom-xml-flattened b/xiaou-oj/pom-xml-flattened index 493e53c3d..6c48eeed6 100644 --- a/xiaou-oj/pom-xml-flattened +++ b/xiaou-oj/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-oj - v2.3.2 + v2.4.0 OJ在线判题模块 diff --git a/xiaou-plan/pom-xml-flattened b/xiaou-plan/pom-xml-flattened index f6e13efb9..6889c6d7e 100644 --- a/xiaou-plan/pom-xml-flattened +++ b/xiaou-plan/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-plan - v2.3.2 + v2.4.0 xiaou-plan 个人计划打卡模块 diff --git a/xiaou-points/pom-xml-flattened b/xiaou-points/pom-xml-flattened index f0aad9ab2..0dd201f85 100644 --- a/xiaou-points/pom-xml-flattened +++ b/xiaou-points/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-points - v2.3.2 + v2.4.0 xiaou-points 积分系统模块 @@ -32,5 +32,10 @@ com.github.ben-manes.caffeine caffeine + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/xiaou-resume/pom-xml-flattened b/xiaou-resume/pom-xml-flattened index 8a7de8986..58863cfce 100644 --- a/xiaou-resume/pom-xml-flattened +++ b/xiaou-resume/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-resume - v2.3.2 + v2.4.0 在线简历制作后端模块 diff --git a/xiaou-sensitive-api/pom-xml-flattened b/xiaou-sensitive-api/pom-xml-flattened index 6de0be79b..f0faf3ae1 100644 --- a/xiaou-sensitive-api/pom-xml-flattened +++ b/xiaou-sensitive-api/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-sensitive-api - v2.3.2 + v2.4.0 xiaou-sensitive-api 敏感词模块API接口 diff --git a/xiaou-sensitive/pom-xml-flattened b/xiaou-sensitive/pom-xml-flattened index 61e941736..32543fadc 100644 --- a/xiaou-sensitive/pom-xml-flattened +++ b/xiaou-sensitive/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-sensitive - v2.3.2 + v2.4.0 敏感词过滤模块 diff --git a/xiaou-sql-optimizer/pom-xml-flattened b/xiaou-sql-optimizer/pom-xml-flattened index 5f28d4482..fcb693756 100644 --- a/xiaou-sql-optimizer/pom-xml-flattened +++ b/xiaou-sql-optimizer/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-sql-optimizer - v2.3.2 + v2.4.0 慢SQL智能优化模块 diff --git a/xiaou-system/pom-xml-flattened b/xiaou-system/pom-xml-flattened index e6013b2eb..45224cf74 100644 --- a/xiaou-system/pom-xml-flattened +++ b/xiaou-system/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-system - v2.3.2 + v2.4.0 xiaou-system 系统管理模块 diff --git a/xiaou-team/pom-xml-flattened b/xiaou-team/pom-xml-flattened index 57135b835..1ec0e5873 100644 --- a/xiaou-team/pom-xml-flattened +++ b/xiaou-team/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-team - v2.3.2 + v2.4.0 xiaou-team 学习小组模块 diff --git a/xiaou-user-api/pom-xml-flattened b/xiaou-user-api/pom-xml-flattened index f75e30d9a..f03d81e70 100644 --- a/xiaou-user-api/pom-xml-flattened +++ b/xiaou-user-api/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-user-api - v2.3.2 + v2.4.0 xiaou-user-api 用户服务API模块 - 提供用户相关接口和DTO diff --git a/xiaou-user/pom-xml-flattened b/xiaou-user/pom-xml-flattened index 48b318072..f06c1f741 100644 --- a/xiaou-user/pom-xml-flattened +++ b/xiaou-user/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-user - v2.3.2 + v2.4.0 xiaou-user 用户管理模块 diff --git a/xiaou-version/pom-xml-flattened b/xiaou-version/pom-xml-flattened index b7fa11442..9cba7a645 100644 --- a/xiaou-version/pom-xml-flattened +++ b/xiaou-version/pom-xml-flattened @@ -5,11 +5,11 @@ com.xiaou Code-Nest - v2.3.2 + v2.4.0 com.xiaou xiaou-version - v2.3.2 + v2.4.0 xiaou-version 版本更新历史模块 From 26f95cf10472d0bb1bccd2dc66d2b609989c8b51 Mon Sep 17 00:00:00 2001 From: lzf <3153566913@qq.com> Date: Tue, 14 Jul 2026 10:04:43 +0800 Subject: [PATCH 3/3] fix(ci): make hygiene secret scan portable --- scripts/code-nest-eval.ps1 | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/scripts/code-nest-eval.ps1 b/scripts/code-nest-eval.ps1 index c36b4f9df..9ddaa5550 100644 --- a/scripts/code-nest-eval.ps1 +++ b/scripts/code-nest-eval.ps1 @@ -200,24 +200,25 @@ function Invoke-Hygiene { } Invoke-Step "secret scan" { - $secretHits = & rg -n "sk-[A-Za-z0-9]{20,}|api[_-]?key\s*[:=]\s*['""][^'""]+['""]" . ` - --glob "!target/**" ` - --glob "!node_modules/**" ` - --glob "!.git/**" ` - --glob "!.codegraph/**" ` - --glob "!.codex-mihomo-geo/**" - $rgExitCode = $LASTEXITCODE - if ($rgExitCode -eq 0) { + $secretPattern = 'sk-[A-Za-z0-9]{20,}|api[_-]?key[[:space:]]*[:=][[:space:]]*[''"][^''"]+[''"]' + $secretHits = & git grep -n -I -E -e $secretPattern -- . ` + ":(exclude)target/**" ` + ":(exclude)node_modules/**" ` + ":(exclude).git/**" ` + ":(exclude).codegraph/**" ` + ":(exclude).codex-mihomo-geo/**" + $grepExitCode = $LASTEXITCODE + if ($grepExitCode -eq 0) { $realSecretHits = @($secretHits | Where-Object { -not (Test-AllowedSecretPlaceholder $_) }) if ($realSecretHits.Count -gt 0) { $realSecretHits | ForEach-Object { Write-Error $_ } throw "secret scan found possible secrets" } Write-Host "[code-nest-eval] secret scan passed with placeholder-only hits" -ForegroundColor Green - } elseif ($rgExitCode -eq 1) { + } elseif ($grepExitCode -eq 1) { Write-Host "[code-nest-eval] secret scan passed" -ForegroundColor Green } else { - throw "secret scan failed with exit code $rgExitCode" + throw "secret scan failed with exit code $grepExitCode" } }