From 2a9e882ac670d7b0a930d500ecb29799259ed1a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Mon, 27 Jul 2026 18:54:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20Skill=20=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E5=8F=91=E5=B8=83=E6=B2=BB=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现标准资源存储、能力绑定及双格式导入导出 - 接入分类、可见范围、审批发布与资源权限校验 - 补充并发、租户隔离、安全边界和迁移契约测试 --- .../skill/SkillCategoryController.java | 203 +++- .../controller/skill/SkillController.java | 697 +++++++++--- .../vo/SkillCapabilityBindingRequest.java | 59 + .../skill/vo/SkillCapabilityReplaceView.java | 13 + .../controller/skill/vo/SkillCopyRequest.java | 17 + .../skill/vo/SkillDraftRequest.java | 50 + .../skill/vo/SkillPublishStatusView.java | 21 + .../admin/controller/skill/vo/SkillView.java | 184 +++ .../SkillCategoryControllerContractTest.java | 38 + .../skill/SkillControllerContractTest.java | 288 +++++ .../SkillControllerProjectionTenantTest.java | 51 + ...pplicationClassLoaderJavaValueDecoder.java | 82 ++ .../easyflow/common/cache/CacheConfig.java | 23 + ...cationClassLoaderJavaValueDecoderTest.java | 60 + .../easyflow-common-file-storage/pom.xml | 7 + .../filestorage/FileStorageManager.java | 213 +++- .../filestorage/FileStorageService.java | 105 +- .../filestorage/FileStorageWriteHandle.java | 431 +++++++ .../filestorage/FileStorageWriteResult.java | 90 ++ .../impl/LocalFileStorageServiceImpl.java | 315 +++++- .../impl/XFIleStorageServiceImpl.java | 352 +++++- .../filestorage/FileStorageManagerTest.java | 115 ++ .../filestorage/FileStorageServiceTest.java | 48 + .../FileStorageWriteHandleTest.java | 87 ++ .../impl/LocalFileStorageServiceImplTest.java | 163 +++ .../impl/XFIleStorageServiceImplTest.java | 571 ++++++++++ .../McpAccessPermissionChecker.java | 41 + .../AbstractAiResourceLifecycleHandler.java | 38 +- .../McpAccessPermissionCheckerTest.java | 69 ++ .../easyflow-module-approval/pom.xml | 6 + .../entity/base/ApprovalInstanceBase.java | 11 + .../entity/vo/ApprovalInstancePageVo.java | 46 + .../service/ApprovalActionFacade.java | 9 + .../service/ApprovalSubjectHandler.java | 11 + .../impl/ApprovalActionFacadeImpl.java | 9 + .../impl/ApprovalInstanceServiceImpl.java | 92 +- .../impl/ApprovalQueryServiceImpl.java | 96 +- ...ApprovalInstanceServiceImplAccessTest.java | 158 +++ ...valInstanceServiceImplConcurrencyTest.java | 72 ++ ...alInstanceTenantMigrationContractTest.java | 55 + .../ApprovalQueryServiceImplAccessTest.java | 283 +++++ .../easyflow-module-skill/pom.xml | 19 + .../SkillCapabilityBindingService.java | 124 ++ .../SkillCapabilityBindingServiceImpl.java | 990 ++++++++++++++++ .../capability/SkillCapabilityCandidate.java | 37 + .../capability/SkillCapabilityTarget.java | 30 + .../SkillCapabilityTargetAccessService.java | 48 + ...killCapabilityTargetAccessServiceImpl.java | 536 +++++++++ .../tech/easyflow/skill/entity/Skill.java | 20 + .../skill/entity/SkillAssetContent.java | 43 - .../skill/entity/SkillCapabilityBinding.java | 102 ++ .../easyflow/skill/entity/SkillCategory.java | 6 + .../easyflow/skill/entity/SkillContent.java | 198 ++++ .../skill/entity/SkillContentWriteIntent.java | 200 ++++ .../skill/entity/SkillImportStage.java | 49 + .../easyflow/skill/entity/SkillResource.java | 85 ++ .../enums/SkillCapabilityExecutionMode.java | 30 + .../enums/SkillCapabilitySelectionMode.java | 30 + .../skill/enums/SkillCapabilityType.java | 31 + .../easyflow/skill/file/SkillFileContent.java | 10 +- .../easyflow/skill/file/SkillFileNode.java | 7 +- .../skill/file/SkillFileRenameRequest.java | 23 + .../skill/file/SkillFileSaveRequest.java | 4 +- .../easyflow/skill/file/SkillFileService.java | 59 +- .../skill/file/SkillFileServiceImpl.java | 969 +++++++++++----- .../skill/imports/EasyFlowBundleReader.java | 492 ++++++++ .../imports/EasyFlowSkillManifestCodec.java | 573 ++++++++++ .../skill/imports/SkillExportArtifact.java | 80 ++ .../skill/imports/SkillExportRequest.java | 19 + .../skill/imports/SkillExportService.java | 9 +- .../skill/imports/SkillExportServiceImpl.java | 341 +++++- .../imports/SkillImportCapabilityMapping.java | 35 + .../SkillImportCapabilityOverride.java | 15 + .../imports/SkillImportConfirmRequest.java | 30 + .../imports/SkillImportConflictStrategy.java | 31 + .../skill/imports/SkillImportFormat.java | 30 + .../skill/imports/SkillImportPreview.java | 19 +- .../skill/imports/SkillImportPreviewFile.java | 103 ++ .../skill/imports/SkillImportPreviewItem.java | 62 +- .../skill/imports/SkillImportService.java | 30 +- .../skill/imports/SkillImportServiceImpl.java | 1001 ++++++++++++++++- .../skill/imports/SkillImportStageStore.java | 263 +++++ .../SkillManifestValidationException.java | 45 + .../skill/mapper/SkillAssetContentMapper.java | 10 - .../skill/mapper/SkillAssetMapper.java | 10 - .../mapper/SkillCapabilityBindingMapper.java | 10 + .../skill/mapper/SkillCategoryMapper.java | 17 + .../skill/mapper/SkillContentMapper.java | 238 ++++ .../mapper/SkillContentWriteIntentMapper.java | 147 +++ .../skill/mapper/SkillImportStageMapper.java | 65 ++ .../easyflow/skill/mapper/SkillMapper.java | 96 ++ .../skill/mapper/SkillReferenceMapper.java | 10 - .../skill/mapper/SkillResourceMapper.java | 10 + .../skill/mapper/SkillScriptMapper.java | 10 - .../publish/SkillApprovalSubjectHandler.java | 136 ++- .../skill/publish/SkillPublishAppService.java | 7 +- .../skill/repository/DBSkillRepository.java | 125 +- .../security/SkillCredentialValueGuard.java | 447 ++++++++ .../SkillPortableTargetSanitizer.java | 169 +++ .../SkillSensitiveConfigSanitizer.java | 68 ++ .../security/SkillVisibilityQueryHelper.java | 83 ++ .../service/SkillAssetContentService.java | 10 - .../skill/service/SkillAssetService.java | 10 - .../skill/service/SkillCategoryService.java | 11 + .../skill/service/SkillReferenceService.java | 10 - .../skill/service/SkillResourceService.java | 22 + .../skill/service/SkillScriptService.java | 10 - .../easyflow/skill/service/SkillService.java | 99 +- .../impl/SkillApprovalStateServiceImpl.java | 10 +- .../impl/SkillAssetContentServiceImpl.java | 14 - .../service/impl/SkillAssetServiceImpl.java | 14 - .../impl/SkillCategoryServiceImpl.java | 332 +++++- .../impl/SkillReferenceServiceImpl.java | 14 - .../impl/SkillResourceServiceImpl.java | 43 + .../service/impl/SkillScriptServiceImpl.java | 14 - .../skill/service/impl/SkillServiceImpl.java | 818 ++++++++++++-- .../skill/store/DBSkillContentStore.java | 974 +++++++++++++++- .../skill/support/SkillModelConverter.java | 106 +- .../support/SkillResourceModelAdapter.java | 162 +++ .../validation/SkillValidationIssue.java | 48 + .../validation/SkillValidationResult.java | 18 + ...SkillCapabilityBindingServiceImplTest.java | 730 ++++++++++++ .../SkillCapabilityMalformedInputTest.java | 204 ++++ ...killCapabilityTenantAndValidationTest.java | 242 ++++ .../SkillFileServiceImplTransactionTest.java | 442 ++++++++ .../EasyFlowBundleReaderEntryLimitTest.java | 174 +++ .../EasyFlowManifestStrictInputTest.java | 84 ++ .../EasyFlowSkillManifestCodecTest.java | 406 +++++++ .../SkillExportFormatIsolationTest.java | 192 ++++ .../imports/SkillExportRoundTripTest.java | 227 ++++ .../SkillImportConflictPrivacyTest.java | 258 +++++ .../SkillImportServiceImplPreviewTest.java | 430 +++++++ ...lImportServiceImplQueryEfficiencyTest.java | 389 +++++++ .../imports/SkillImportStageStoreTest.java | 235 ++++ .../SkillCategoryMapperLockContractTest.java | 57 + .../mapper/SkillContentMapperSqlTest.java | 178 +++ .../SkillContentWriteIntentMapperSqlTest.java | 148 +++ ...ntentWriteIntentMigrationContractTest.java | 74 ++ .../SkillMigrationGuardContractTest.java | 143 +++ .../SkillPermissionMigrationContractTest.java | 94 ++ .../mapper/SkillSummaryBackfillSqlTest.java | 62 + ...valSubjectHandlerContentReferenceTest.java | 230 ++++ ...DBSkillRepositoryContentOwnershipTest.java | 218 ++++ .../SkillCredentialValueGuardTest.java | 89 ++ .../SkillSensitiveConfigSanitizerTest.java | 75 ++ .../SkillVisibilityQueryHelperTenantTest.java | 84 ++ .../impl/SkillCategoryServiceImplTest.java | 132 +++ .../SkillCategoryTenantConstraintTest.java | 145 +++ .../impl/SkillLegacySummaryBackfillTest.java | 163 +++ ...killResourceServiceImplProjectionTest.java | 33 + .../SkillServiceImplContentReferenceTest.java | 155 +++ .../SkillServiceImplDeletionGuardTest.java | 154 +++ .../impl/SkillServiceImplManagementTest.java | 305 +++++ ...SkillContentStoreMySqlConcurrencyTest.java | 306 +++++ .../skill/store/DBSkillContentStoreTest.java | 567 ++++++++++ .../resource/VisibilityResource.java | 30 + .../impl/CategoryPermissionServiceImpl.java | 2 +- .../impl/ResourceAccessServiceImpl.java | 8 + .../impl/ResourceAccessServiceImplTest.java | 202 ++++ .../src/main/resources/application.yml | 5 +- .../V27__mysql_skill_resource_capability.sql | 520 +++++++++ ...V28__mysql_skill_operation_permissions.sql | 37 + ..._mysql_skill_delete_permission_cleanup.sql | 29 + .../V31__mysql_skill_content_write_intent.sql | 31 + .../V32__mysql_approval_instance_tenant.sql | 32 + 165 files changed, 23737 insertions(+), 1088 deletions(-) create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCapabilityBindingRequest.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCapabilityReplaceView.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCopyRequest.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillDraftRequest.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillPublishStatusView.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillView.java create mode 100644 easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillCategoryControllerContractTest.java create mode 100644 easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerContractTest.java create mode 100644 easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerProjectionTenantTest.java create mode 100644 easyflow-commons/easyflow-common-cache/src/main/java/tech/easyflow/common/cache/ApplicationClassLoaderJavaValueDecoder.java create mode 100644 easyflow-commons/easyflow-common-cache/src/test/java/tech/easyflow/common/cache/ApplicationClassLoaderJavaValueDecoderTest.java create mode 100644 easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageWriteHandle.java create mode 100644 easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageWriteResult.java create mode 100644 easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageManagerTest.java create mode 100644 easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageServiceTest.java create mode 100644 easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageWriteHandleTest.java create mode 100644 easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImplTest.java create mode 100644 easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/permission/McpAccessPermissionChecker.java create mode 100644 easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/permission/McpAccessPermissionCheckerTest.java create mode 100644 easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImplAccessTest.java create mode 100644 easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImplConcurrencyTest.java create mode 100644 easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceTenantMigrationContractTest.java create mode 100644 easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImplAccessTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityBindingService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityBindingServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityCandidate.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTarget.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTargetAccessService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTargetAccessServiceImpl.java delete mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillAssetContent.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillCapabilityBinding.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillContent.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillContentWriteIntent.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillImportStage.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillResource.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilityExecutionMode.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilitySelectionMode.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilityType.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileRenameRequest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/EasyFlowBundleReader.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/EasyFlowSkillManifestCodec.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportArtifact.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportRequest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportCapabilityMapping.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportCapabilityOverride.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportConfirmRequest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportConflictStrategy.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportFormat.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreviewFile.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportStageStore.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillManifestValidationException.java delete mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetContentMapper.java delete mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetMapper.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillCapabilityBindingMapper.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillContentMapper.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillContentWriteIntentMapper.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillImportStageMapper.java delete mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillReferenceMapper.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillResourceMapper.java delete mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillScriptMapper.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillCredentialValueGuard.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillPortableTargetSanitizer.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillSensitiveConfigSanitizer.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillVisibilityQueryHelper.java delete mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetContentService.java delete mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetService.java delete mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillReferenceService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillResourceService.java delete mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillScriptService.java delete mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetContentServiceImpl.java delete mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetServiceImpl.java delete mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillReferenceServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillResourceServiceImpl.java delete mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillScriptServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillResourceModelAdapter.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/validation/SkillValidationIssue.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/validation/SkillValidationResult.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityBindingServiceImplTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityMalformedInputTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityTenantAndValidationTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/file/SkillFileServiceImplTransactionTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowBundleReaderEntryLimitTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowManifestStrictInputTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowSkillManifestCodecTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillExportFormatIsolationTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillExportRoundTripTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportConflictPrivacyTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportServiceImplPreviewTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportServiceImplQueryEfficiencyTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportStageStoreTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillCategoryMapperLockContractTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillContentMapperSqlTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillContentWriteIntentMapperSqlTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillContentWriteIntentMigrationContractTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillMigrationGuardContractTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillPermissionMigrationContractTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillSummaryBackfillSqlTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandlerContentReferenceTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/repository/DBSkillRepositoryContentOwnershipTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillCredentialValueGuardTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillSensitiveConfigSanitizerTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillVisibilityQueryHelperTenantTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillCategoryServiceImplTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillCategoryTenantConstraintTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillLegacySummaryBackfillTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillResourceServiceImplProjectionTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplContentReferenceTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplDeletionGuardTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplManagementTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/store/DBSkillContentStoreMySqlConcurrencyTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/store/DBSkillContentStoreTest.java create mode 100644 easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/ResourceAccessServiceImplTest.java create mode 100644 easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V27__mysql_skill_resource_capability.sql create mode 100644 easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V28__mysql_skill_operation_permissions.sql create mode 100644 easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V29__mysql_skill_delete_permission_cleanup.sql create mode 100644 easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V31__mysql_skill_content_write_intent.sql create mode 100644 easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V32__mysql_approval_instance_tenant.sql diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillCategoryController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillCategoryController.java index 2ab438fa..d6a3873d 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillCategoryController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillCategoryController.java @@ -1,38 +1,45 @@ package tech.easyflow.admin.controller.skill; +import cn.dev33.satoken.annotation.SaCheckPermission; import com.mybatisflex.core.query.QueryWrapper; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.domain.Result; -import tech.easyflow.common.web.controller.BaseCurdController; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.exceptions.BusinessException; -import tech.easyflow.skill.entity.Skill; +import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.skill.entity.SkillCategory; -import tech.easyflow.skill.mapper.SkillMapper; import tech.easyflow.skill.service.SkillCategoryService; import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.service.CategoryPermissionService; -import javax.annotation.Resource; import java.io.Serializable; -import java.util.Collection; +import java.math.BigInteger; import java.util.Collections; import java.util.List; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; /** * Skill 分类管理控制器。 */ @RestController -@RequestMapping("/api/v1/skillCategory") +@RequestMapping("/api/v1/skill/category") @UsePermission(moduleName = "/api/v1/skill") -public class SkillCategoryController extends BaseCurdController { +public class SkillCategoryController { - @Resource - private SkillMapper skillMapper; - @Resource + private static final Set SORT_COLUMNS = Set.of( + "id", "category_name", "parent_id", "level_no", "sort_no", "status", "created", "modified"); + + private final SkillCategoryService service; + @javax.annotation.Resource private CategoryPermissionService categoryPermissionService; /** @@ -41,7 +48,7 @@ public class SkillCategoryController extends BaseCurdController> visibleList(SkillCategory entity, Boolean asTree, String sortKey, String sortType) { - QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity)); + QueryWrapper queryWrapper = QueryWrapper.create() + .eq(SkillCategory::getTenantId, currentAccount().getTenantId()); + if (entity != null) { + queryWrapper.eq(SkillCategory::getId, entity.getId(), entity.getId() != null) + .eq(SkillCategory::getParentId, entity.getParentId(), entity.getParentId() != null) + .eq(SkillCategory::getLevelNo, entity.getLevelNo(), entity.getLevelNo() != null) + .eq(SkillCategory::getStatus, entity.getStatus(), entity.getStatus() != null) + .like(SkillCategory::getCategoryName, entity.getCategoryName(), + entity.getCategoryName() != null && !entity.getCategoryName().isBlank()); + } RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode()); if (access.isRestricted()) { if (access.getCategoryIds().isEmpty()) { @@ -63,29 +80,157 @@ public class SkillCategoryController extends BaseCurdController categories = service.list(queryWrapper); + return Result.ok(Boolean.FALSE.equals(asTree) ? categories : toTree(categories)); } /** - * 删除分类前校验是否仍被 Skill 使用。 + * 查询当前租户完整分类管理树,包含停用分类。 * - * @param ids 分类 ID 集合 - * @return 校验结果 + * @return 分类树 */ - @Override - protected Result onRemoveBefore(Collection ids) { - for (Serializable id : ids) { - List skills = skillMapper.selectListByQuery(QueryWrapper.create().eq(Skill::getCategoryId, id)); - if (skills != null && !skills.isEmpty()) { - throw new BusinessException("请先迁移或删除该分类下的 Skill"); - } - List children = service.list(QueryWrapper.create().eq(SkillCategory::getParentId, id)); - if (children != null && !children.isEmpty()) { - throw new BusinessException("请先删除子分类"); + @GetMapping("tree") + @SaCheckPermission("/api/v1/skill/category") + public Result> tree() { + List categories = service.list(QueryWrapper.create() + .eq(SkillCategory::getTenantId, currentAccount().getTenantId()) + .orderBy("sort_no asc, id asc")); + return Result.ok(toTree(categories)); + } + + /** + * 移动 Skill 分类到新的父级。 + * + * @param id 分类 ID + * @param parentId 新父级 ID,根分类为空 + * @return 更新结果 + */ + @PostMapping("move") + @SaCheckPermission("/api/v1/skill/category") + public Result move( + @JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id, + @JsonBody(value = "parentId", skipConvertError = false) BigInteger parentId) { + SkillCategory category = service.getOne(QueryWrapper.create() + .eq(SkillCategory::getId, id) + .eq(SkillCategory::getTenantId, currentAccount().getTenantId())); + if (category == null) { + throw new BusinessException(404, 404, "Skill 分类不存在"); + } + category.setParentId(parentId); + if (!service.updateById(category)) { + throw new BusinessException(500, 500, "移动 Skill 分类失败,请稍后重试"); + } + return Result.ok(); + } + + /** + * 创建 Skill 分类。 + * + * @param entity 分类 + * @return 保存结果 + */ + @PostMapping("save") + @SaCheckPermission("/api/v1/skill/category") + public Result save(@JsonBody(required = true, skipConvertError = false) SkillCategory entity) { + if (entity != null) { + entity.setId(null); + entity.setTenantId(null); + entity.setAncestors(null); + entity.setLevelNo(null); + entity.setCreated(null); + entity.setCreatedBy(null); + entity.setModified(null); + entity.setModifiedBy(null); + } + if (!service.save(entity)) { + throw new BusinessException(500, 500, "创建 Skill 分类失败,请稍后重试"); + } + return Result.ok(entity); + } + + /** + * 更新 Skill 分类。 + * + * @param entity 分类 + * @return 更新结果 + */ + @PostMapping("update") + @SaCheckPermission("/api/v1/skill/category") + public Result update(@JsonBody(required = true, skipConvertError = false) SkillCategory entity) { + if (entity != null) { + entity.setTenantId(null); + entity.setAncestors(null); + entity.setLevelNo(null); + entity.setCreated(null); + entity.setCreatedBy(null); + entity.setModified(null); + entity.setModifiedBy(null); + } + if (entity == null || entity.getId() == null) { + throw new BusinessException("Skill 分类 ID 不能为空"); + } + if (!service.updateById(entity)) { + throw new BusinessException(500, 500, "更新 Skill 分类失败,请稍后重试"); + } + return Result.ok(entity); + } + + /** + * 删除 Skill 分类。 + * + * @param id 分类 ID + * @return 删除结果 + */ + @PostMapping("remove") + @SaCheckPermission("/api/v1/skill/category") + public Result remove( + @JsonBody(value = "id", required = true, skipConvertError = false) Serializable id) { + if (!service.removeById(id)) { + throw new BusinessException(500, 500, "删除 Skill 分类失败,请稍后重试"); + } + return Result.ok(); + } + + private LoginAccount currentAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException(401, 401, "未登录或登录态无效"); + } + return account; + } + + /** + * 将分类排序参数收敛到固定字段白名单,禁止原始 SQL 片段进入查询。 + * + * @param sortKey 排序字段 + * @param sortType 排序方向 + * @return 安全排序表达式 + */ + String resolveOrderBy(String sortKey, String sortType) { + String snake = sortKey == null ? "" : sortKey + .replaceAll("([a-z0-9])([A-Z])", "$1_$2") + .toLowerCase(Locale.ROOT); + String column = SORT_COLUMNS.contains(snake) ? snake : "sort_no"; + String direction = "desc".equalsIgnoreCase(sortType) ? "desc" : "asc"; + return column + " " + direction + ("id".equals(column) ? "" : ", id asc"); + } + + private List toTree(List categories) { + Map byId = new LinkedHashMap<>(); + categories.forEach(category -> { + category.setChildren(null); + byId.put(category.getId(), category); + }); + List roots = new java.util.ArrayList<>(); + for (SkillCategory category : categories) { + SkillCategory parent = category.getParentId() == null ? null : byId.get(category.getParentId()); + if (parent == null) { + roots.add(category); + } else { + parent.getChildren().add(category); } } - return super.onRemoveBefore(ids); + return roots; } } - diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java index 56a225fd..d7049fd7 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java @@ -1,9 +1,10 @@ package tech.easyflow.admin.controller.skill; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaMode; +import cn.dev33.satoken.stp.StpUtil; import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.query.QueryWrapper; -import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.MediaType; import org.springframework.util.StreamUtils; @@ -11,151 +12,247 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.multipart.MultipartFile; import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; -import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.admin.controller.skill.vo.SkillCapabilityBindingRequest; +import tech.easyflow.admin.controller.skill.vo.SkillCapabilityReplaceView; +import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest; +import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest; +import tech.easyflow.admin.controller.skill.vo.SkillView; +import tech.easyflow.admin.controller.skill.vo.SkillPublishStatusView; import tech.easyflow.approval.entity.vo.ApprovalActionResult; +import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.domain.Result; -import tech.easyflow.common.web.controller.BaseCurdController; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.jsonbody.JsonBody; +import tech.easyflow.skill.capability.SkillCapabilityBindingService; +import tech.easyflow.skill.capability.SkillCapabilityCandidate; import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.enums.SkillCapabilityType; import tech.easyflow.skill.file.SkillFileContent; import tech.easyflow.skill.file.SkillFileNode; +import tech.easyflow.skill.file.SkillFileRenameRequest; import tech.easyflow.skill.file.SkillFileSaveRequest; import tech.easyflow.skill.file.SkillFileService; +import tech.easyflow.skill.imports.SkillExportRequest; +import tech.easyflow.skill.imports.SkillExportArtifact; import tech.easyflow.skill.imports.SkillExportService; +import tech.easyflow.skill.imports.SkillImportConfirmRequest; +import tech.easyflow.skill.imports.SkillImportFormat; import tech.easyflow.skill.imports.SkillImportPreview; import tech.easyflow.skill.imports.SkillImportService; import tech.easyflow.skill.publish.SkillPublishAppService; +import tech.easyflow.skill.security.SkillVisibilityQueryHelper; import tech.easyflow.skill.service.SkillApprovalStateService; import tech.easyflow.skill.service.SkillService; -import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; +import tech.easyflow.skill.validation.SkillValidationResult; import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.enums.ResourceAction; import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.ResourceAccessService; -import javax.annotation.Resource; +import java.io.IOException; import java.io.InputStream; -import java.io.Serializable; import java.math.BigInteger; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.util.Collection; -import java.util.Collections; import java.util.List; - -import static tech.easyflow.skill.entity.table.SkillTableDef.SKILL; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; /** - * Skill 管理端控制器。 + * Skill 管理端 API,统一负责轻量查询、白名单写入、文件工作台、能力绑定和导入导出。 */ @RestController @RequestMapping("/api/v1/skill") -public class SkillController extends BaseCurdController { +public class SkillController { - @Resource - private SkillApprovalStateService skillApprovalStateService; - @Resource - private SkillPublishAppService skillPublishAppService; - @Resource - private SkillImportService skillImportService; - @Resource - private SkillExportService skillExportService; - @Resource - private SkillFileService skillFileService; - @Resource - private ResourceAccessService resourceAccessService; - @Resource - private CategoryPermissionService categoryPermissionService; - @Resource - private AiResourceCreatorNameSupport aiResourceCreatorNameSupport; + private static final Set PAGE_SORT_COLUMNS = Set.of( + "id", "name", "display_name", "created", "modified", "publish_status", "resource_count", "capability_count"); + + private final SkillService skillService; + private final SkillApprovalStateService skillApprovalStateService; + private final SkillPublishAppService skillPublishAppService; + private final SkillImportService skillImportService; + private final SkillExportService skillExportService; + private final SkillFileService skillFileService; + private final SkillCapabilityBindingService capabilityBindingService; + private final ResourceAccessService resourceAccessService; + private final CategoryPermissionService categoryPermissionService; + private final SkillVisibilityQueryHelper visibilityQueryHelper; + private final AiResourceCreatorNameSupport creatorNameSupport; /** - * 创建 Skill 控制器。 + * 创建 Skill 管理控制器。 * - * @param service Skill 服务 + * @param skillService Skill 服务 + * @param skillApprovalStateService 审批状态服务 + * @param skillPublishAppService 发布服务 + * @param skillImportService 导入服务 + * @param skillExportService 导出服务 + * @param skillFileService 文件服务 + * @param capabilityBindingService 能力绑定服务 + * @param resourceAccessService 资源权限服务 + * @param categoryPermissionService 分类权限服务 + * @param visibilityQueryHelper 可见性查询助手 + * @param creatorNameSupport 创建人名称助手 */ - public SkillController(SkillService service) { - super(service); + public SkillController(SkillService skillService, + SkillApprovalStateService skillApprovalStateService, + SkillPublishAppService skillPublishAppService, + SkillImportService skillImportService, + SkillExportService skillExportService, + SkillFileService skillFileService, + SkillCapabilityBindingService capabilityBindingService, + ResourceAccessService resourceAccessService, + CategoryPermissionService categoryPermissionService, + SkillVisibilityQueryHelper visibilityQueryHelper, + AiResourceCreatorNameSupport creatorNameSupport) { + this.skillService = skillService; + this.skillApprovalStateService = skillApprovalStateService; + this.skillPublishAppService = skillPublishAppService; + this.skillImportService = skillImportService; + this.skillExportService = skillExportService; + this.skillFileService = skillFileService; + this.capabilityBindingService = capabilityBindingService; + this.resourceAccessService = resourceAccessService; + this.categoryPermissionService = categoryPermissionService; + this.visibilityQueryHelper = visibilityQueryHelper; + this.creatorNameSupport = creatorNameSupport; } /** - * 获取 Skill 详情。 + * 分页查询当前用户可读的 Skill 描述信息。 + * + * @param pageNumber 页码 + * @param pageSize 每页数量 + * @param categoryId 分类 ID + * @param categoryScope 分类范围,UNCATEGORIZED 表示未分类 + * @param name 名称关键词 + * @param displayName 展示名称关键词 + * @param publishStatus 发布状态 + * @param sourceType 来源类型 + * @param capabilityType 能力类型 + * @param sortKey 排序字段 + * @param sortType 排序方向 + * @return 轻量分页结果 + */ + @GetMapping("/page") + @SaCheckPermission("/api/v1/skill/query") + public Result> page(Long pageNumber, Long pageSize, BigInteger categoryId, String categoryScope, + String name, String displayName, String publishStatus, String sourceType, + String capabilityType, String sortKey, String sortType) { + long normalizedPage = pageNumber == null || pageNumber < 1 ? 1 : pageNumber; + long normalizedSize = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100); + QueryWrapper query = descriptorQuery(); + visibilityQueryHelper.applyReadableAccess(query); + if ("UNCATEGORIZED".equalsIgnoreCase(categoryScope)) { + query.isNull("category_id"); + } else { + query.eq("category_id", categoryId, categoryId != null); + } + query + .eq("publish_status", publishStatus, hasText(publishStatus)) + .eq("source_type", sourceType, hasText(sourceType)); + String keyword = hasText(displayName) ? displayName : name; + if (hasText(keyword)) { + String pattern = "%" + keyword + "%"; + query.and("(name LIKE ? OR display_name LIKE ? OR description LIKE ?)", pattern, pattern, pattern); + } + if (hasText(capabilityType)) { + SkillCapabilityType normalizedCapabilityType = SkillCapabilityType.from(capabilityType); + query.and("EXISTS (SELECT 1 FROM tb_skill_capability_binding b " + + "WHERE b.skill_id = tb_skill.id AND b.tenant_id = tb_skill.tenant_id " + + "AND b.capability_type = ?)", normalizedCapabilityType.name()); + } + query.orderBy(resolveSortColumn(sortKey) + ("asc".equalsIgnoreCase(sortType) ? " asc" : " desc")); + Page source = skillService.page(new Page<>(normalizedPage, normalizedSize), query); + fillListState(source.getRecords()); + LoginAccount account = SaTokenUtil.getLoginAccount(); + boolean superAdmin = account != null && categoryPermissionService.isSuperAdmin(account); + List records = source.getRecords().stream() + .map(skill -> toPageView(skill, account, superAdmin)).toList(); + return Result.ok(new Page<>(records, source.getPageNumber(), source.getPageSize(), source.getTotalRow())); + } + + /** + * 获取 Skill 完整管理详情。 * * @param id Skill ID * @return Skill 详情 */ - @GetMapping("/getDetail") - public Result getDetail(BigInteger id) { - Skill skill = service.getDetail(id); - skillApprovalStateService.fillSkillApprovalState(skill); - return Result.ok(skill); + @GetMapping("/detail") + @SaCheckPermission("/api/v1/skill/getDetail") + public Result detail(BigInteger id) { + Skill skill = skillService.getManagementDetail(id); + if (!StpUtil.hasPermission("/api/v1/skill/capability")) { + skill.setCapabilityBindings(null); + skill.setCapabilityHash(null); + } + fillListState(List.of(skill)); + return Result.ok(toView(skill)); } /** - * 保存 Skill 草稿。 + * 创建 Skill 草稿。 * - * @param skill Skill 草稿 - * @return 保存后的 Skill + * @param request 草稿白名单请求 + * @return 创建后的 Skill */ - @Override - @PostMapping("save") - public Result save(@JsonBody Skill skill) { - return Result.ok(service.saveDraft(skill)); + @PostMapping("/save") + @SaCheckPermission("/api/v1/skill/save") + public Result save(@JsonBody(required = true, skipConvertError = false) SkillDraftRequest request) { + if (request == null || request.id() != null) { + throw new BusinessException("创建 Skill 时不能指定 ID"); + } + return Result.ok(toView(skillService.saveDraft(request.toEntity()))); } /** * 更新 Skill 草稿。 * - * @param skill Skill 草稿 - * @return 保存后的 Skill + * @param request 草稿白名单请求 + * @return 更新后的 Skill */ - @Override - @PostMapping("update") - public Result update(@JsonBody Skill skill) { - return Result.ok(service.updateDraft(skill)); + @PostMapping("/update") + @SaCheckPermission("/api/v1/skill/update") + public Result update(@JsonBody(required = true, skipConvertError = false) SkillDraftRequest request) { + if (request == null || request.id() == null) { + throw new BusinessException("Skill ID 不能为空"); + } + return Result.ok(toView(skillService.updateDraft(request.toUpdateEntity()))); } /** - * 预览 zip 导入结果。 + * 复制已有 Skill 为当前用户拥有的新草稿。 * - * @param file zip 文件 - * @return 导入预览 - */ - @PostMapping(value = "/import/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @SaCheckPermission("/api/v1/skill/save") - public Result importPreview(MultipartFile file) throws Exception { - return Result.ok(skillImportService.preview(file.getInputStream())); + * @param request 复制请求 + * @return 新建的 Skill 草稿 + */ + @PostMapping("/copy") + @SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/capability"}) + public Result copy(@JsonBody(required = true, skipConvertError = false) SkillCopyRequest request) { + if (request == null) { + throw new BusinessException("复制参数不能为空"); + } + return Result.ok(toView(skillService.copyDraft(request.sourceId(), request.name(), + request.displayName(), request.categoryId()))); } /** - * 确认导入 zip。 + * 在展示发布确认前执行发布级全量校验。 * - * @param file zip 文件 - * @param categoryId 分类 ID - * @param overwriteDraft 是否覆盖草稿 - * @return 导入后的 Skill 列表 + * @param id Skill ID + * @return 包含实时能力解析的结构化校验结果 */ - @PostMapping(value = "/import/confirm", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @SaCheckPermission("/api/v1/skill/save") - public Result> importConfirm(MultipartFile file, BigInteger categoryId, Boolean overwriteDraft) throws Exception { - return Result.ok(skillImportService.importZip(file.getInputStream(), categoryId, Boolean.TRUE.equals(overwriteDraft))); - } - - /** - * 导出 Skill zip。 - * - * @param ids Skill ID 集合 - * @param response HTTP 响应 - */ - @PostMapping("/export") - public void export(@JsonBody(value = "ids", required = true) List ids, HttpServletResponse response) throws Exception { - response.setContentType("application/zip"); - response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode("skills.zip", StandardCharsets.UTF_8) + "\""); - skillExportService.exportZip(ids, response.getOutputStream()); + @PostMapping("/validatePublish") + @SaCheckPermission("/api/v1/skill/submitPublishApproval") + public Result validatePublish( + @JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) { + return Result.ok(skillService.validateSkill(id, true)); } /** @@ -165,78 +262,266 @@ public class SkillController extends BaseCurdController { * @return 文件树 */ @GetMapping("/file/tree") + @SaCheckPermission("/api/v1/skill/getDetail") public Result> fileTree(BigInteger skillId) { return Result.ok(skillFileService.tree(skillId)); } /** - * 获取 Skill 文件内容。 + * 获取 Skill 文本文件内容或二进制摘要。 * * @param skillId Skill ID - * @param path 逻辑路径 + * @param path 包内路径 * @return 文件内容 */ @GetMapping("/file/content") + @SaCheckPermission("/api/v1/skill/getDetail") public Result fileContent(BigInteger skillId, String path) { return Result.ok(skillFileService.getContent(skillId, path)); } /** - * 保存 Skill 文本文件。 + * 保存已有文本文件。 * * @param request 保存请求 - * @return 保存后的文件内容 + * @return 最新文件内容 */ @PostMapping("/file/save") - @SaCheckPermission("/api/v1/skill/save") - public Result saveFile(@JsonBody SkillFileSaveRequest request) { + @SaCheckPermission("/api/v1/skill/file") + public Result saveFile( + @JsonBody(required = true, skipConvertError = false) SkillFileSaveRequest request) { return Result.ok(skillFileService.saveContent(request)); } /** - * 删除 Skill 逻辑文件。 + * 创建文本文件。 + * + * @param request 创建请求 + * @return 文件内容 + */ + @PostMapping("/file/create") + @SaCheckPermission("/api/v1/skill/file") + public Result createFile( + @JsonBody(required = true, skipConvertError = false) SkillFileSaveRequest request) { + return Result.ok(skillFileService.createTextFile(request)); + } + + /** + * 重命名文件。 + * + * @param request 重命名请求 + * @return 最新文件内容 + */ + @PostMapping("/file/rename") + @SaCheckPermission("/api/v1/skill/file") + public Result renameFile( + @JsonBody(required = true, skipConvertError = false) SkillFileRenameRequest request) { + return Result.ok(skillFileService.renameFile(request)); + } + + /** + * 删除包内文件。 * * @param skillId Skill ID - * @param path 逻辑路径 - * @return 操作结果 + * @param path 文件路径 + * @return 空结果 */ @PostMapping("/file/delete") - @SaCheckPermission("/api/v1/skill/save") - public Result deleteFile(@JsonBody(value = "skillId", required = true) BigInteger skillId, - @JsonBody(value = "path", required = true) String path) { - skillFileService.deleteFile(skillId, path); + @SaCheckPermission("/api/v1/skill/file") + public Result deleteFile( + @JsonBody(value = "skillId", required = true, skipConvertError = false) BigInteger skillId, + @JsonBody(value = "path", required = true, skipConvertError = false) String path, + @JsonBody(value = "expectedContentHash", required = true, skipConvertError = false) + String expectedContentHash) { + skillFileService.deleteFile(skillId, path, expectedContentHash); return Result.ok(); } /** - * 上传 Skill asset。 + * 上传任意包内二进制资源。 * * @param skillId Skill ID - * @param path 逻辑路径 + * @param path 文件路径 * @param file 上传文件 - * @return asset 内容 + * @return 文件摘要 */ - @PostMapping(value = "/file/asset/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @SaCheckPermission("/api/v1/skill/save") - public Result uploadAsset(BigInteger skillId, String path, MultipartFile file) { - return Result.ok(skillFileService.uploadAsset(skillId, path, file)); + @PostMapping(value = "/file/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @SaCheckPermission("/api/v1/skill/file") + public Result uploadFile(BigInteger skillId, + String path, + String expectedContentHash, + MultipartFile file) { + return Result.ok(skillFileService.uploadResource(skillId, path, file, expectedContentHash)); } /** - * 下载或预览 Skill asset。 + * 下载包内文件。 * * @param skillId Skill ID - * @param path asset 逻辑路径 + * @param path 文件路径 + * @param response HTTP 响应 + * @throws IOException 响应写入失败 + */ + @GetMapping("/file/download") + @SaCheckPermission("/api/v1/skill/getDetail") + public void downloadFile(BigInteger skillId, String path, HttpServletResponse response) throws IOException { + transferFile(skillId, path, response, false); + } + + /** + * 安全预览包内文件;主动内容强制下载。 + * + * @param skillId Skill ID + * @param path 文件路径 + * @param response HTTP 响应 + * @throws IOException 响应写入失败 + */ + @GetMapping("/file/preview") + @SaCheckPermission("/api/v1/skill/getDetail") + public void previewFile(BigInteger skillId, String path, HttpServletResponse response) throws IOException { + transferFile(skillId, path, response, true); + } + + /** + * 查询 Skill 能力绑定。 + * + * @param skillId Skill ID + * @return 绑定列表 + */ + @GetMapping("/capability/list") + @SaCheckPermission(value = {"/api/v1/skill/getDetail", "/api/v1/skill/capability"}) + public Result> capabilityList(BigInteger skillId) { + return Result.ok(capabilityBindingService.listVisibleBindings(skillId).stream() + .map(SkillView.CapabilityView::from).toList()); + } + + /** + * 查询当前用户可绑定的能力候选。 + * + * @param type 能力类型 + * @param keyword 关键词 + * @return 候选列表 + */ + @GetMapping("/capability/candidates") + @SaCheckPermission(value = {"/api/v1/skill/capability", "/api/v1/skill/import"}, mode = SaMode.OR) + public Result> capabilityCandidates(String type, String keyword) { + return Result.ok(capabilityBindingService.listCandidates(SkillCapabilityType.from(type), keyword)); + } + + /** + * 获取 MCP 工具名清单。 + * + * @param targetId MCP ID + * @return MCP 候选详情 + */ + @GetMapping("/capability/tools") + @SaCheckPermission(value = {"/api/v1/skill/capability", "/api/v1/skill/import"}, mode = SaMode.OR) + public Result capabilityTools(BigInteger targetId) { + return Result.ok(capabilityBindingService.getMcpTools(targetId)); + } + + /** + * 原子替换 Skill 能力绑定。 + * + * @param skillId Skill ID + * @param requests 绑定白名单请求 + * @return 保存后的绑定 + */ + @PostMapping("/capability/replace") + @SaCheckPermission("/api/v1/skill/capability") + public Result replaceCapabilities( + @JsonBody(value = "skillId", required = true, skipConvertError = false) BigInteger skillId, + @JsonBody(value = "expectedCapabilityHash", required = true, skipConvertError = false) + String expectedCapabilityHash, + @JsonBody(value = "bindings", required = true, skipConvertError = false) + List requests) { + List bindings = requests == null ? List.of() + : requests.stream().map(SkillCapabilityBindingRequest::toEntity).toList(); + List saved = capabilityBindingService.replaceBindings( + skillId, bindings, expectedCapabilityHash); + return Result.ok(new SkillCapabilityReplaceView( + saved.stream().map(SkillView.CapabilityView::from).toList(), + capabilityBindingService.calculateHash(saved))); + } + + /** + * 预览标准 ZIP 或 EasyFlow Bundle 导入内容。 + * + * @param file 导入文件 + * @return token 化预览 + */ + @PostMapping(value = "/import/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @SaCheckPermission("/api/v1/skill/import") + public Result importPreview(MultipartFile file) { + return Result.ok(skillImportService.preview(file)); + } + + /** + * 使用一次性 token 确认导入。 + * + * @param request 导入确认请求 + * @return 导入后的 Skill + */ + @PostMapping("/import/confirm") + @SaCheckPermission("/api/v1/skill/import") + public Result> importConfirm( + @JsonBody(required = true, skipConvertError = false) SkillImportConfirmRequest request) { + return Result.ok(skillImportService.confirm(request).stream().map(this::toView).toList()); + } + + /** + * 取消导入并清理临时包。 + * + * @param importToken 导入 token + * @return 空结果 + */ + @PostMapping("/import/cancel") + @SaCheckPermission("/api/v1/skill/import") + public Result importCancel( + @JsonBody(value = "importToken", required = true, skipConvertError = false) String importToken) { + skillImportService.cancel(importToken); + return Result.ok(); + } + + /** + * 导出标准 Skill ZIP 或 EasyFlow 增强包。 + * + * @param request 导出请求 * @param response HTTP 响应 */ - @GetMapping("/file/asset") - public void asset(BigInteger skillId, String path, HttpServletResponse response) throws Exception { - SkillFileContent content = skillFileService.getContent(skillId, path); - response.setContentType(content.getMediaType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : content.getMediaType()); - response.setHeader("Content-Disposition", "inline; filename=\"" + URLEncoder.encode(fileName(path), StandardCharsets.UTF_8) + "\""); - try (InputStream inputStream = skillFileService.openAsset(skillId, path)) { - StreamUtils.copy(inputStream, response.getOutputStream()); + @PostMapping("/export") + @SaCheckPermission("/api/v1/skill/export") + public void export(@JsonBody(required = true, skipConvertError = false) SkillExportRequest request, + HttpServletResponse response) { + if (request == null || request.getIds().isEmpty()) { + throw new BusinessException("请选择要导出的 Skill"); } + if (request.getIds().size() > 100) { + throw new BusinessException("单次最多导出 100 个 Skill"); + } + SkillImportFormat format = SkillImportFormat.from(request.getFormat()); + assertEnhancedExportPermission(format); + try (SkillExportArtifact artifact = skillExportService.prepare(request.getIds(), format)) { + response.setContentType(artifact.getMediaType()); + response.setHeader("Content-Disposition", attachment(artifact.getFileName())); + artifact.transferTo(output(response)); + } + } + + /** + * 导出单个标准或增强 Skill 包。 + * + * @param id Skill ID + * @param format 导出格式 + * @param response HTTP 响应 + */ + @GetMapping("/export") + @SaCheckPermission("/api/v1/skill/export") + public void exportOne(BigInteger id, String format, HttpServletResponse response) { + if (id == null) { + throw new BusinessException("Skill ID 不能为空"); + } + writeExport(List.of(id), SkillImportFormat.from(format), response); } /** @@ -246,9 +531,10 @@ public class SkillController extends BaseCurdController { * @return 审批实例 ID */ @PostMapping("/submitPublishApproval") - @SaCheckPermission("/api/v1/skill/save") - public Result submitPublishApproval(@JsonBody("id") BigInteger id) { - return buildApprovalActionResult(skillPublishAppService.submitPublishApproval(id), "已提交发布审批", "已直接发布"); + @SaCheckPermission("/api/v1/skill/submitPublishApproval") + public Result submitPublishApproval( + @JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) { + return approvalResult(skillPublishAppService.submitPublishApproval(id), "已提交发布审批", "已直接发布"); } /** @@ -258,9 +544,10 @@ public class SkillController extends BaseCurdController { * @return 审批实例 ID */ @PostMapping("/submitOfflineApproval") - @SaCheckPermission("/api/v1/skill/save") - public Result submitOfflineApproval(@JsonBody("id") BigInteger id) { - return buildApprovalActionResult(skillPublishAppService.submitOfflineApproval(id), "已提交下线审批", "已直接下线"); + @SaCheckPermission("/api/v1/skill/submitOfflineApproval") + public Result submitOfflineApproval( + @JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) { + return approvalResult(skillPublishAppService.submitOfflineApproval(id), "已提交下线审批", "已直接下线"); } /** @@ -270,90 +557,142 @@ public class SkillController extends BaseCurdController { * @return 审批实例 ID */ @PostMapping("/submitDeleteApproval") - @SaCheckPermission("/api/v1/skill/remove") - public Result submitDeleteApproval(@JsonBody("id") BigInteger id) { - return buildApprovalActionResult(skillPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除"); - } - - @Override - protected Result onRemoveBefore(Collection ids) { - for (Serializable id : ids) { - Skill skill = service.getById(String.valueOf(id)); - if (skill != null) { - resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限删除该 Skill"); - } - } - return super.onRemoveBefore(ids); + @SaCheckPermission("/api/v1/skill/submitDeleteApproval") + public Result submitDeleteApproval( + @JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) { + return approvalResult(skillPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除"); } /** - * 查询 Skill 分页。 + * 查询 Skill 发布和审批派生状态。 * - * @param page 分页参数 - * @param queryWrapper 查询条件 - * @return Skill 分页 + * @param id Skill ID + * @return 发布状态 */ - @Override - protected Page queryPage(Page page, QueryWrapper queryWrapper) { - if (!applyCategoryPermission(queryWrapper)) { - return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), 0L); + @GetMapping("/publish/status") + @SaCheckPermission("/api/v1/skill/getDetail") + public Result publishStatus(BigInteger id) { + QueryWrapper query = descriptorQuery().eq(Skill::getId, id); + visibilityQueryHelper.applyReadableAccess(query); + Skill skill = skillService.getOne(query); + if (skill == null) { + throw new BusinessException(404, 404, "Skill 不存在"); } - applyPublishedOnlyFilter(queryWrapper); - Page result = super.queryPage(page, queryWrapper); - if (isPublishedOnlyRequest()) { - result.setRecords(result.getRecords().stream().map(skill -> service.fromSnapshot(skill.getPublishedSnapshotJson())).toList()); - } - skillApprovalStateService.fillSkillApprovalState(result.getRecords()); - aiResourceCreatorNameSupport.fillSkillCreatorNames(result.getRecords()); - return result; + fillListState(List.of(skill)); + return Result.ok(new SkillPublishStatusView(skill.getId(), skill.getPublishStatus(), + skill.getApprovalPending(), skill.getCurrentApprovalActionType(), skill.getDisplayPublishStatus(), + skill.getCurrentApprovalInstanceId())); } - private boolean applyCategoryPermission(QueryWrapper queryWrapper) { - RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode()); - if (!access.isRestricted()) { - return true; - } - if (access.getCategoryIds().isEmpty()) { - queryWrapper.eq(Skill::getCreatedBy, access.getAccountId()); - return true; - } - queryWrapper.and(SKILL.CREATED_BY.eq(access.getAccountId()).or(SKILL.CATEGORY_ID.in(access.getCategoryIds()))); - return true; + private QueryWrapper descriptorQuery() { + return QueryWrapper.create().select("id", "tenant_id", "dept_id", "category_id", "name", "display_name", "description", + "enabled", "visibility_scope", "source_type", "package_hash", "capability_hash", "snapshot_hash", + "resource_count", "capability_count", "reference_count", "script_count", "asset_count", + "publish_status", "current_approval_instance_id", "created", "created_by", "modified", "modified_by"); } - private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) { - if (isPublishedOnlyRequest()) { - queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode()); + private void writeExport(List ids, SkillImportFormat format, HttpServletResponse response) { + assertEnhancedExportPermission(format); + try (SkillExportArtifact artifact = skillExportService.prepare(ids, format)) { + response.setContentType(artifact.getMediaType()); + response.setHeader("Content-Disposition", attachment(artifact.getFileName())); + artifact.transferTo(output(response)); } } - private boolean isPublishedOnlyRequest() { - HttpServletRequest request = currentRequest(); - if (request == null) { - return false; + /** + * EasyFlow 增强包包含能力配置,导出时额外校验能力绑定查看权限。 + * + * @param format 导出格式 + */ + void assertEnhancedExportPermission(SkillImportFormat format) { + if (SkillImportFormat.EASYFLOW == format) { + StpUtil.checkPermission("/api/v1/skill/capability"); } - return "true".equalsIgnoreCase(request.getParameter("publishedOnly")); } - private HttpServletRequest currentRequest() { - ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); - if (attributes == null) { - return null; - } - return attributes.getRequest(); + private void fillListState(List skills) { + skillApprovalStateService.fillSkillApprovalState(skills); + creatorNameSupport.fillSkillCreatorNames(skills); } - private Result buildApprovalActionResult(ApprovalActionResult actionResult, - String approvalMessage, - String directMessage) { - return Result.ok(actionResult.isApprovalRequired() ? approvalMessage : directMessage, actionResult.getInstanceId()); + private SkillView toView(Skill skill) { + boolean readable = resourceAccessService.canAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ); + boolean manageable = resourceAccessService.canAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE); + return SkillView.from(skill, readable, manageable); + } + + private SkillView toPageView(Skill skill, LoginAccount account, boolean superAdmin) { + boolean sameTenant = account != null && account.getTenantId() != null + && Objects.equals(account.getTenantId(), skill.getTenantId()); + boolean manageable = sameTenant && (superAdmin || Objects.equals(account.getId(), skill.getCreatedBy())); + return SkillView.from(skill, sameTenant, manageable); + } + + private void transferFile(BigInteger skillId, String path, HttpServletResponse response, boolean preview) throws IOException { + SkillFileContent content = skillFileService.getContent(skillId, path); + String mediaType = content.getMediaType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : content.getMediaType(); + boolean inline = preview && isSafeInline(mediaType); + response.setContentType(inline ? mediaType : MediaType.APPLICATION_OCTET_STREAM_VALUE); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("Content-Security-Policy", "sandbox; default-src 'none'"); + response.setHeader("Content-Disposition", (inline ? "inline" : "attachment") + filenameParameter(fileName(path))); + if (Boolean.TRUE.equals(content.getIsText())) { + response.getOutputStream().write((content.getContent() == null ? "" : content.getContent()) + .getBytes(StandardCharsets.UTF_8)); + return; + } + try (InputStream inputStream = skillFileService.openResource(skillId, path)) { + StreamUtils.copy(inputStream, response.getOutputStream()); + } + } + + private boolean isSafeInline(String mediaType) { + String normalized = mediaType.toLowerCase(Locale.ROOT).split(";", 2)[0]; + return normalized.equals("application/pdf") || normalized.equals("text/plain") + || normalized.equals("text/markdown") || normalized.equals("image/png") + || normalized.equals("image/jpeg") || normalized.equals("image/gif") + || normalized.equals("image/webp") || normalized.equals("image/avif"); + } + + private String resolveSortColumn(String sortKey) { + if (!hasText(sortKey)) { + return "modified"; + } + String snake = sortKey.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT); + return PAGE_SORT_COLUMNS.contains(snake) ? snake : "modified"; + } + + private String attachment(String fileName) { + return "attachment" + filenameParameter(fileName); + } + + private String filenameParameter(String fileName) { + String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20"); + return "; filename*=UTF-8''" + encoded; } private String fileName(String path) { - if (path == null || path.isBlank()) { - return "asset"; + if (!hasText(path)) { + return "resource.bin"; } int index = path.lastIndexOf('/'); return index < 0 ? path : path.substring(index + 1); } + + private Result approvalResult(ApprovalActionResult result, String approvalMessage, String directMessage) { + return Result.ok(result.isApprovalRequired() ? approvalMessage : directMessage, result.getInstanceId()); + } + + private java.io.OutputStream output(HttpServletResponse response) { + try { + return response.getOutputStream(); + } catch (IOException exception) { + throw new BusinessException(500, 500, "创建 Skill 导出响应失败", exception); + } + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCapabilityBindingRequest.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCapabilityBindingRequest.java new file mode 100644 index 00000000..1c61d59d --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCapabilityBindingRequest.java @@ -0,0 +1,59 @@ +package tech.easyflow.admin.controller.skill.vo; + +import tech.easyflow.skill.entity.SkillCapabilityBinding; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +/** + * Skill 能力绑定写入白名单。 + * + * @param capabilityType 能力类型 + * @param targetId 当前环境目标 ID + * @param targetLogicalRef 跨环境逻辑引用 + * @param runtimeName 运行时名称 + * @param enabled 是否启用 + * @param selectionMode MCP 工具选择模式 + * @param selectedToolNamesJson 已选 MCP 工具 + * @param executionMode 执行模式 + * @param hitlEnabled 是否需要人工确认 + * @param hitlConfigJson 人工确认安全配置 + * @param optionsJson 执行安全配置 + * @param sortNo 排序号 + */ +public record SkillCapabilityBindingRequest(String capabilityType, + BigInteger targetId, + String targetLogicalRef, + String runtimeName, + Boolean enabled, + String selectionMode, + List selectedToolNamesJson, + String executionMode, + Boolean hitlEnabled, + Map hitlConfigJson, + Map optionsJson, + Integer sortNo) { + + /** + * 转换为能力绑定业务实体。 + * + * @return 仅包含可写字段的绑定实体 + */ + public SkillCapabilityBinding toEntity() { + SkillCapabilityBinding binding = new SkillCapabilityBinding(); + binding.setCapabilityType(capabilityType); + binding.setTargetId(targetId); + binding.setTargetLogicalRef(targetLogicalRef); + binding.setRuntimeName(runtimeName); + binding.setEnabled(enabled); + binding.setSelectionMode(selectionMode); + binding.setSelectedToolNamesJson(selectedToolNamesJson); + binding.setExecutionMode(executionMode); + binding.setHitlEnabled(hitlEnabled); + binding.setHitlConfigJson(hitlConfigJson); + binding.setOptionsJson(optionsJson); + binding.setSortNo(sortNo); + return binding; + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCapabilityReplaceView.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCapabilityReplaceView.java new file mode 100644 index 00000000..9b11fb43 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCapabilityReplaceView.java @@ -0,0 +1,13 @@ +package tech.easyflow.admin.controller.skill.vo; + +import java.util.List; + +/** + * 能力绑定原子替换结果。 + * + * @param bindings 保存后的白名单绑定视图 + * @param capabilityHash 新能力配置哈希 + */ +public record SkillCapabilityReplaceView(List bindings, + String capabilityHash) { +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCopyRequest.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCopyRequest.java new file mode 100644 index 00000000..05c386dd --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCopyRequest.java @@ -0,0 +1,17 @@ +package tech.easyflow.admin.controller.skill.vo; + +import java.math.BigInteger; + +/** + * Skill 复制请求白名单。 + * + * @param sourceId 源 Skill ID + * @param name 新 Skill 标准名称 + * @param displayName 新 Skill 展示名称 + * @param categoryId 目标分类 ID,可为空 + */ +public record SkillCopyRequest(BigInteger sourceId, + String name, + String displayName, + BigInteger categoryId) { +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillDraftRequest.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillDraftRequest.java new file mode 100644 index 00000000..547878e1 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillDraftRequest.java @@ -0,0 +1,50 @@ +package tech.easyflow.admin.controller.skill.vo; + +import tech.easyflow.skill.entity.Skill; + +import java.math.BigInteger; + +/** + * Skill 草稿写入白名单,拒绝客户端覆盖租户、归属人、发布态、快照和 hash 等服务端字段。 + * + * @param id Skill ID,创建时为空 + * @param categoryId 分类 ID + * @param displayName 展示名称 + * @param skillContent SKILL.md 内容,仅创建时使用;已有草稿正文通过文件接口原子保存 + * @param enabled 是否启用 + * @param visibilityScope 可见范围 + */ +public record SkillDraftRequest(BigInteger id, + BigInteger categoryId, + String displayName, + String skillContent, + Boolean enabled, + String visibilityScope) { + + /** + * 转换为仅包含可写字段的业务实体。 + * + * @return Skill 草稿实体 + */ + public Skill toEntity() { + Skill skill = new Skill(); + skill.setId(id); + skill.setCategoryId(categoryId); + skill.setDisplayName(displayName); + skill.setSkillContent(skillContent); + skill.setEnabled(enabled); + skill.setVisibilityScope(visibilityScope); + return skill; + } + + /** + * 转换为不包含 SKILL.md 正文的基础配置更新实体。 + * + * @return Skill 基础配置实体 + */ + public Skill toUpdateEntity() { + Skill skill = toEntity(); + skill.setSkillContent(null); + return skill; + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillPublishStatusView.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillPublishStatusView.java new file mode 100644 index 00000000..b7deb1bd --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillPublishStatusView.java @@ -0,0 +1,21 @@ +package tech.easyflow.admin.controller.skill.vo; + +import java.math.BigInteger; + +/** + * Skill 发布和审批派生状态。 + * + * @param id Skill ID + * @param publishStatus 真实发布状态 + * @param approvalPending 是否存在进行中审批 + * @param currentApprovalActionType 当前审批动作 + * @param displayPublishStatus 前端展示状态 + * @param currentApprovalInstanceId 当前审批实例 ID + */ +public record SkillPublishStatusView(BigInteger id, + String publishStatus, + Boolean approvalPending, + String currentApprovalActionType, + String displayPublishStatus, + BigInteger currentApprovalInstanceId) { +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillView.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillView.java new file mode 100644 index 00000000..5b92391c --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillView.java @@ -0,0 +1,184 @@ +package tech.easyflow.admin.controller.skill.vo; + +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.entity.SkillResource; + +import java.math.BigInteger; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 管理端 Skill 视图,不暴露租户字段、二进制内部引用和发布快照。 + * + * @param id Skill ID + * @param categoryId 分类 ID + * @param name 规范名称 + * @param displayName 展示名称 + * @param description 描述 + * @param metadataJson frontmatter 扩展元数据 + * @param skillContent SKILL.md 内容 + * @param enabled 是否启用 + * @param visibilityScope 可见范围 + * @param sourceType 来源类型 + * @param packageHash 包 hash + * @param capabilityHash 能力 hash + * @param snapshotHash 发布快照 hash + * @param resourceCount 资源数 + * @param capabilityCount 能力数 + * @param referenceCount 参考文档数 + * @param scriptCount 脚本数 + * @param assetCount 二进制资源数 + * @param publishStatus 发布状态 + * @param currentApprovalInstanceId 当前审批实例 ID + * @param approvalPending 是否审批中 + * @param currentApprovalActionType 当前审批动作 + * @param displayPublishStatus 展示发布状态 + * @param created 创建时间 + * @param modified 修改时间 + * @param createdByName 创建人名称 + * @param readable 当前用户是否可读 + * @param manageable 当前用户是否可管理 + * @param resources 包内资源摘要 + * @param bindings 能力绑定 + */ +public record SkillView(BigInteger id, + BigInteger categoryId, + String name, + String displayName, + String description, + Map metadataJson, + String skillContent, + Boolean enabled, + String visibilityScope, + String sourceType, + String packageHash, + String capabilityHash, + String snapshotHash, + Integer resourceCount, + Integer capabilityCount, + Integer referenceCount, + Integer scriptCount, + Integer assetCount, + String publishStatus, + BigInteger currentApprovalInstanceId, + Boolean approvalPending, + String currentApprovalActionType, + String displayPublishStatus, + Date created, + Date modified, + String createdByName, + boolean readable, + boolean manageable, + List resources, + List bindings) { + + /** + * 从业务实体创建安全视图。 + * + * @param skill Skill 实体 + * @param readable 是否可读 + * @param manageable 是否可管理 + * @return Skill 管理视图 + */ + public static SkillView from(Skill skill, boolean readable, boolean manageable) { + List resources = skill.getResources() == null ? null + : skill.getResources().stream().map(ResourceView::from).toList(); + List bindings = skill.getCapabilityBindings() == null ? null + : skill.getCapabilityBindings().stream() + .map(binding -> CapabilityView.from(binding, manageable)).toList(); + return new SkillView(skill.getId(), skill.getCategoryId(), skill.getName(), skill.getDisplayName(), + skill.getDescription(), skill.getMetadataJson(), skill.getSkillContent(), skill.getEnabled(), + skill.getVisibilityScope(), skill.getSourceType(), skill.getPackageHash(), skill.getCapabilityHash(), + skill.getSnapshotHash(), skill.getResourceCount(), skill.getCapabilityCount(), skill.getReferenceCount(), + skill.getScriptCount(), skill.getAssetCount(), skill.getPublishStatus(), + skill.getCurrentApprovalInstanceId(), skill.getApprovalPending(), skill.getCurrentApprovalActionType(), + skill.getDisplayPublishStatus(), skill.getCreated(), skill.getModified(), skill.getCreatedByName(), + readable, manageable, resources, bindings); + } + + /** + * Skill 包内资源摘要。 + * + * @param id 资源 ID + * @param path 路径 + * @param kind 类型 + * @param language 脚本语言 + * @param mediaType 媒体类型 + * @param isText 是否文本 + * @param contentHash 内容 hash + * @param size 字节数 + * @param metadataJson 扩展元数据 + */ + public record ResourceView(BigInteger id, String path, String kind, String language, String mediaType, + Boolean isText, String contentHash, Long size, Map metadataJson) { + + /** + * 转换资源实体。 + * + * @param resource 资源实体 + * @return 资源视图 + */ + public static ResourceView from(SkillResource resource) { + return new ResourceView(resource.getId(), resource.getNormalizedPath(), resource.getKind(), + resource.getLanguage(), resource.getMediaType(), resource.getIsText(), resource.getContentHash(), + resource.getSize(), resource.getMetadataJson()); + } + } + + /** + * Skill 能力绑定视图。 + * + * @param id 绑定 ID + * @param capabilityType 能力类型 + * @param targetId 目标 ID + * @param targetLogicalRef 跨环境逻辑引用 + * @param runtimeName 运行时名称 + * @param enabled 是否启用 + * @param selectionMode 工具选择模式 + * @param selectedToolNamesJson 已选工具 + * @param executionMode 执行模式 + * @param hitlEnabled 是否人工确认 + * @param hitlConfigJson 人工确认安全配置 + * @param optionsJson 执行安全配置 + * @param sortNo 排序号 + * @param targetName 目标名称 + * @param targetStatus 目标状态 + * @param resolvedToolNames 已解析工具 + */ + public record CapabilityView(BigInteger id, String capabilityType, BigInteger targetId, String targetLogicalRef, + String runtimeName, Boolean enabled, String selectionMode, + List selectedToolNamesJson, String executionMode, Boolean hitlEnabled, + Map hitlConfigJson, Map optionsJson, Integer sortNo, + String targetName, String targetStatus, List resolvedToolNames) { + + /** + * 转换绑定实体。 + * + * @param binding 绑定实体 + * @return 绑定视图 + */ + public static CapabilityView from(SkillCapabilityBinding binding) { + return from(binding, true); + } + + /** + * 按管理权限转换绑定实体,READ 用户看不到当前环境内部目标 ID。 + * + * @param binding 绑定实体 + * @param includeTargetId 是否包含目标 ID + * @return 绑定视图 + */ + public static CapabilityView from(SkillCapabilityBinding binding, boolean includeTargetId) { + boolean hideUnavailableTarget = !includeTargetId && "NO_PERMISSION".equals(binding.getTargetStatus()); + return new CapabilityView(binding.getId(), binding.getCapabilityType(), + includeTargetId ? binding.getTargetId() : null, + binding.getTargetLogicalRef(), binding.getRuntimeName(), binding.getEnabled(), + binding.getSelectionMode(), binding.getSelectedToolNamesJson(), binding.getExecutionMode(), + binding.getHitlEnabled(), binding.getHitlConfigJson(), binding.getOptionsJson(), binding.getSortNo(), + hideUnavailableTarget ? null : binding.getTargetName(), binding.getTargetStatus(), + hideUnavailableTarget ? List.of() : binding.getResolvedToolNames()); + } + } +} diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillCategoryControllerContractTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillCategoryControllerContractTest.java new file mode 100644 index 00000000..f5e30f1e --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillCategoryControllerContractTest.java @@ -0,0 +1,38 @@ +package tech.easyflow.admin.controller.skill; + +import org.testng.Assert; +import org.testng.annotations.Test; +import tech.easyflow.skill.service.SkillCategoryService; + +import static org.mockito.Mockito.mock; + +/** + * {@link SkillCategoryController} 查询参数安全契约测试。 + */ +public class SkillCategoryControllerContractTest { + + /** + * 分类排序只接受固定字段和方向,恶意片段应回退到默认排序。 + */ + @Test + public void categorySortUsesStrictAllowlist() { + SkillCategoryController controller = new SkillCategoryController(mock(SkillCategoryService.class)); + + Assert.assertEquals(controller.resolveOrderBy("categoryName", "desc"), + "category_name desc, id asc"); + Assert.assertEquals(controller.resolveOrderBy("sort_no desc; drop table tb_skill", null), + "sort_no asc, id asc"); + Assert.assertEquals(controller.resolveOrderBy("id", "unexpected"), "id asc"); + } + + /** + * 分类控制器不得继承未加租户范围的通用 list、page 和 detail 入口。 + */ + @Test + public void categoryControllerDoesNotExposeInheritedCrudQueries() { + Assert.expectThrows(NoSuchMethodException.class, + () -> SkillCategoryController.class.getMethod("detail", String.class)); + Assert.assertFalse(java.util.Arrays.stream(SkillCategoryController.class.getMethods()) + .anyMatch(method -> "list".equals(method.getName()) || "page".equals(method.getName()))); + } +} diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerContractTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerContractTest.java new file mode 100644 index 00000000..488ba3dc --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerContractTest.java @@ -0,0 +1,288 @@ +package tech.easyflow.admin.controller.skill; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.stp.StpUtil; +import org.testng.Assert; +import org.testng.annotations.Test; +import org.mockito.MockedStatic; +import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; +import tech.easyflow.admin.controller.skill.vo.SkillCapabilityBindingRequest; +import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest; +import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest; +import tech.easyflow.admin.controller.skill.vo.SkillView; +import tech.easyflow.common.domain.Result; +import tech.easyflow.common.web.jsonbody.JsonBody; +import tech.easyflow.common.web.jsonbody.JsonBodyParser; +import tech.easyflow.skill.capability.SkillCapabilityBindingService; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.file.SkillFileService; +import tech.easyflow.skill.imports.SkillExportService; +import tech.easyflow.skill.imports.SkillImportFormat; +import tech.easyflow.skill.imports.SkillImportService; +import tech.easyflow.skill.publish.SkillPublishAppService; +import tech.easyflow.skill.security.SkillVisibilityQueryHelper; +import tech.easyflow.skill.service.SkillApprovalStateService; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.validation.SkillValidationResult; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.ResourceAccessService; + +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.math.BigInteger; +import java.util.List; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link SkillController} 写入 DTO 与返回视图静态契约测试。 + */ +public class SkillControllerContractTest { + + /** + * 验证当前 Fastjson 与 JsonBody 解析链路支持 Skill 草稿 record。 + * + * @throws Exception DTO 反序列化失败 + */ + @Test + public void jsonBodyParserDeserializesSkillDraftRecord() throws Exception { + JSONObject json = JSON.parseObject(""" + { + "id": 101, + "categoryId": 9, + "displayName": "演示 Skill", + "skillContent": "---\\nname: demo-skill\\ndescription: Demo\\n---\\n# Demo\\n", + "enabled": true, + "visibilityScope": "PRIVATE" + } + """); + + SkillDraftRequest request = (SkillDraftRequest) JsonBodyParser.parseJsonBody( + json, SkillDraftRequest.class, SkillDraftRequest.class, ""); + + Assert.assertEquals(request.id(), BigInteger.valueOf(101)); + Assert.assertEquals(request.categoryId(), BigInteger.valueOf(9)); + Assert.assertEquals(request.displayName(), "演示 Skill"); + Assert.assertTrue(request.enabled()); + Assert.assertEquals(request.visibilityScope(), "PRIVATE"); + } + + /** + * 验证当前 Fastjson 与 JsonBody 解析链路支持含集合和映射的能力绑定 record。 + * + * @throws Exception DTO 反序列化失败 + */ + @Test + public void jsonBodyParserDeserializesCapabilityBindingRecord() throws Exception { + JSONObject json = JSON.parseObject(""" + { + "capabilityType": "MCP", + "targetId": 77, + "targetLogicalRef": "mcp://demo", + "runtimeName": "demo_mcp", + "enabled": true, + "selectionMode": "SELECTED", + "selectedToolNamesJson": ["search", "fetch"], + "executionMode": "SYNC", + "hitlEnabled": true, + "hitlConfigJson": {"prompt": "确认执行"}, + "optionsJson": {"timeoutMs": 3000}, + "sortNo": 2 + } + """); + + SkillCapabilityBindingRequest request = (SkillCapabilityBindingRequest) JsonBodyParser.parseJsonBody( + json, SkillCapabilityBindingRequest.class, SkillCapabilityBindingRequest.class, ""); + + Assert.assertEquals(request.capabilityType(), "MCP"); + Assert.assertEquals(request.targetId(), BigInteger.valueOf(77)); + Assert.assertEquals(request.selectedToolNamesJson(), List.of("search", "fetch")); + Assert.assertEquals(request.hitlConfigJson().get("prompt"), "确认执行"); + Assert.assertEquals(((Number) request.optionsJson().get("timeoutMs")).intValue(), 3000); + } + + /** + * 验证草稿写入口使用 JsonBody 白名单 DTO,并返回 SkillView。 + * + * @throws Exception 控制器方法反射失败 + */ + @Test + public void saveEndpointUsesDraftRequestAndSkillView() throws Exception { + Method method = SkillController.class.getMethod("save", SkillDraftRequest.class); + JsonBody jsonBody = method.getParameters()[0].getAnnotation(JsonBody.class); + ParameterizedType returnType = (ParameterizedType) method.getGenericReturnType(); + + Assert.assertNotNull(jsonBody); + Assert.assertEquals(returnType.getRawType(), Result.class); + Assert.assertEquals(returnType.getActualTypeArguments()[0], SkillView.class); + } + + /** + * 验证详情视图按 MANAGE 权限隐藏或保留当前环境目标 ID。 + */ + @Test + public void detailViewProjectsCapabilityTargetIdByManagePermission() { + SkillCapabilityBinding binding = binding(BigInteger.valueOf(77)); + Skill skill = new Skill(); + skill.setCapabilityBindings(List.of(binding)); + + SkillView readOnly = SkillView.from(skill, true, false); + SkillView manageable = SkillView.from(skill, true, true); + + Assert.assertNull(readOnly.bindings().get(0).targetId()); + Assert.assertEquals(manageable.bindings().get(0).targetId(), BigInteger.valueOf(77)); + } + + /** + * 验证能力替换响应沿用可编辑投影并保留目标 ID。 + */ + @Test + public void replaceResponseProjectionKeepsEditableTargetId() { + SkillView.CapabilityView view = SkillView.CapabilityView.from(binding(BigInteger.valueOf(88))); + + Assert.assertEquals(view.targetId(), BigInteger.valueOf(88)); + } + + /** + * 验证能力列表端点只调用按权限脱敏的读取方法。 + */ + @Test + public void capabilityListEndpointUsesPermissionAwareBindingRead() { + BigInteger skillId = BigInteger.valueOf(101); + SkillCapabilityBindingService bindingService = mock(SkillCapabilityBindingService.class); + SkillCapabilityBinding redacted = binding(null); + when(bindingService.listVisibleBindings(skillId)).thenReturn(List.of(redacted)); + SkillController controller = controller(bindingService); + + Result> result = controller.capabilityList(skillId); + + Assert.assertNull(result.getData().get(0).targetId()); + verify(bindingService).listVisibleBindings(skillId); + verify(bindingService, never()).listBindings(skillId); + } + + /** + * 验证复制需要新建和能力绑定双重操作权限。 + * + * @throws Exception 控制器方法反射失败 + */ + @Test + public void copyEndpointDeclaresIndependentOperationPermissions() throws Exception { + SaCheckPermission copyPermission = SkillController.class + .getMethod("copy", SkillCopyRequest.class).getAnnotation(SaCheckPermission.class); + + Assert.assertEquals(copyPermission.value(), + new String[]{"/api/v1/skill/save", "/api/v1/skill/capability"}); + } + + /** + * 验证正式删除审批入口使用真实操作权限,不引用历史死权限。 + * + * @throws Exception 控制器方法反射失败 + */ + @Test + public void deleteEndpointUsesCanonicalDeletePermission() throws Exception { + SaCheckPermission submitPermission = SkillController.class + .getMethod("submitDeleteApproval", BigInteger.class).getAnnotation(SaCheckPermission.class); + + Assert.assertEquals(submitPermission.value(), + new String[]{"/api/v1/skill/submitDeleteApproval"}); + Assert.assertFalse(java.util.Arrays.stream(SkillController.class.getDeclaredMethods()) + .map(method -> method.getAnnotation(SaCheckPermission.class)) + .filter(java.util.Objects::nonNull) + .flatMap(permission -> java.util.Arrays.stream(permission.value())) + .anyMatch("/api/v1/skill/remove"::equals)); + } + + /** + * 验证发布预检复用发布权限,并明确调用发布级校验。 + * + * @throws Exception 控制器方法反射失败 + */ + @Test + public void publishValidationUsesPublishPermissionAndFullValidation() throws Exception { + BigInteger skillId = BigInteger.valueOf(101); + SkillService skillService = mock(SkillService.class); + SkillValidationResult validation = new SkillValidationResult(); + validation.setValid(true); + when(skillService.validateSkill(skillId, true)).thenReturn(validation); + SkillController controller = controller(skillService, mock(SkillCapabilityBindingService.class)); + + Result result = controller.validatePublish(skillId); + SaCheckPermission permission = SkillController.class + .getMethod("validatePublish", BigInteger.class) + .getAnnotation(SaCheckPermission.class); + + Assert.assertSame(result.getData(), validation); + Assert.assertEquals(permission.value(), new String[]{"/api/v1/skill/submitPublishApproval"}); + verify(skillService).validateSkill(skillId, true); + } + + /** + * 验证标准导出不追加能力权限,EasyFlow 增强导出必须检查能力绑定查看权限。 + */ + @Test + public void enhancedExportRequiresCapabilityPermission() { + SkillController controller = controller(mock(SkillCapabilityBindingService.class)); + + try (MockedStatic stp = mockStatic(StpUtil.class)) { + controller.assertEnhancedExportPermission(SkillImportFormat.STANDARD); + stp.verify(() -> StpUtil.checkPermission("/api/v1/skill/capability"), never()); + + controller.assertEnhancedExportPermission(SkillImportFormat.EASYFLOW); + stp.verify(() -> StpUtil.checkPermission("/api/v1/skill/capability"), times(1)); + } + } + + /** + * 创建测试能力绑定。 + * + * @param targetId 目标 ID + * @return 能力绑定 + */ + private SkillCapabilityBinding binding(BigInteger targetId) { + SkillCapabilityBinding binding = new SkillCapabilityBinding(); + binding.setId(BigInteger.ONE); + binding.setCapabilityType("MCP"); + binding.setTargetId(targetId); + binding.setTargetLogicalRef("mcp:demo"); + binding.setRuntimeName("demo_mcp"); + binding.setEnabled(true); + binding.setSelectionMode("ALL"); + binding.setHitlEnabled(false); + return binding; + } + + /** + * 创建只注入能力服务的控制器测试实例。 + * + * @param bindingService 能力绑定服务 + * @return 控制器实例 + */ + private SkillController controller(SkillCapabilityBindingService bindingService) { + return controller(mock(SkillService.class), bindingService); + } + + /** + * 创建注入指定 Skill 与能力服务的控制器测试实例。 + * + * @param skillService Skill 服务 + * @param bindingService 能力绑定服务 + * @return 控制器实例 + */ + private SkillController controller(SkillService skillService, SkillCapabilityBindingService bindingService) { + return new SkillController(skillService, mock(SkillApprovalStateService.class), + mock(SkillPublishAppService.class), mock(SkillImportService.class), mock(SkillExportService.class), + mock(SkillFileService.class), bindingService, mock(ResourceAccessService.class), + mock(CategoryPermissionService.class), mock(SkillVisibilityQueryHelper.class), + mock(AiResourceCreatorNameSupport.class)); + } +} diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerProjectionTenantTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerProjectionTenantTest.java new file mode 100644 index 00000000..c8f91167 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerProjectionTenantTest.java @@ -0,0 +1,51 @@ +package tech.easyflow.admin.controller.skill; + +import com.mybatisflex.core.query.QueryWrapper; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.lang.reflect.Method; +import java.util.Locale; + +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; + +/** + * Skill 列表轻量投影的权限字段回归测试。 + */ +public class SkillControllerProjectionTenantTest { + + /** + * 验证列表投影包含内部 tenant_id,以便资源权限派生时不会将合法记录误判为不可读。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void descriptorProjectionShouldIncludeTenantId() throws Exception { + SkillController controller = mock(SkillController.class, CALLS_REAL_METHODS); + Method method = SkillController.class.getDeclaredMethod("descriptorQuery"); + method.setAccessible(true); + + QueryWrapper query = (QueryWrapper) method.invoke(controller); + + Assert.assertTrue(query.toSQL().toLowerCase(Locale.ROOT).contains("tenant_id"), + "Skill descriptor projection 缺少 tenant_id: " + query.toSQL()); + } + + /** + * 验证列表投影不会加载正文或发布快照等重字段。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void descriptorProjectionShouldExcludeHeavyContent() throws Exception { + SkillController controller = mock(SkillController.class, CALLS_REAL_METHODS); + Method method = SkillController.class.getDeclaredMethod("descriptorQuery"); + method.setAccessible(true); + + String sql = ((QueryWrapper) method.invoke(controller)).toSQL().toLowerCase(Locale.ROOT); + + Assert.assertFalse(sql.contains("skill_content"), "列表投影不应加载 SKILL.md 正文: " + sql); + Assert.assertFalse(sql.contains("published_snapshot_json"), "列表投影不应加载发布快照: " + sql); + } +} diff --git a/easyflow-commons/easyflow-common-cache/src/main/java/tech/easyflow/common/cache/ApplicationClassLoaderJavaValueDecoder.java b/easyflow-commons/easyflow-common-cache/src/main/java/tech/easyflow/common/cache/ApplicationClassLoaderJavaValueDecoder.java new file mode 100644 index 00000000..b7ce18d2 --- /dev/null +++ b/easyflow-commons/easyflow-common-cache/src/main/java/tech/easyflow/common/cache/ApplicationClassLoaderJavaValueDecoder.java @@ -0,0 +1,82 @@ +package tech.easyflow.common.cache; + +import com.alicp.jetcache.anno.SerialPolicy; +import com.alicp.jetcache.support.CacheEncodeException; +import com.alicp.jetcache.support.JavaValueDecoder; +import org.springframework.core.ConfigurableObjectInputStream; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.util.Objects; + +/** + * 使用应用类加载器反序列化 JetCache Java 缓存值。 + * + *

异步线程的上下文类加载器可能无法访问 Spring Boot 可执行包中的嵌套依赖, + * 因此解码时固定使用本类的定义类加载器。

+ */ +public class ApplicationClassLoaderJavaValueDecoder extends JavaValueDecoder { + + private final ClassLoader applicationClassLoader; + + /** + * 创建使用 EasyFlow 应用类加载器的 Java 缓存解码器。 + */ + public ApplicationClassLoaderJavaValueDecoder() { + this(ApplicationClassLoaderJavaValueDecoder.class.getClassLoader()); + } + + /** + * 创建使用指定类加载器的 Java 缓存解码器。 + * + * @param applicationClassLoader 反序列化缓存对象时使用的类加载器 + * @throws NullPointerException 类加载器为空时抛出 + */ + ApplicationClassLoaderJavaValueDecoder(ClassLoader applicationClassLoader) { + super(true); + this.applicationClassLoader = Objects.requireNonNull( + applicationClassLoader, + "applicationClassLoader must not be null" + ); + } + + /** + * 解码带 JetCache Java 编码标识的缓存值。 + * + * @param buffer Redis 中读取的缓存字节 + * @return 反序列化后的缓存对象 + * @throws CacheEncodeException 缓存内容为空、编码类型不匹配或反序列化失败时抛出 + */ + @Override + public Object apply(byte[] buffer) { + try { + if (buffer == null || buffer.length < Integer.BYTES) { + throw new CacheEncodeException("decode error: invalid java cache payload"); + } + int identityNumber = parseHeader(buffer); + if (identityNumber != SerialPolicy.IDENTITY_NUMBER_JAVA) { + throw new CacheEncodeException( + "decode error: unsupported cache identity number " + identityNumber + ); + } + return doApply(buffer); + } catch (CacheEncodeException e) { + throw e; + } catch (Throwable e) { + throw new CacheEncodeException("decode error", e); + } + } + + /** + * 创建绑定应用类加载器的对象输入流。 + * + * @param input 缓存对象字节输入流 + * @return 可从应用依赖中解析类的对象输入流 + * @throws IOException 对象输入流初始化失败时抛出 + */ + @Override + protected ObjectInputStream buildObjectInputStream(ByteArrayInputStream input) throws IOException { + return new ConfigurableObjectInputStream(input, applicationClassLoader); + } +} diff --git a/easyflow-commons/easyflow-common-cache/src/main/java/tech/easyflow/common/cache/CacheConfig.java b/easyflow-commons/easyflow-common-cache/src/main/java/tech/easyflow/common/cache/CacheConfig.java index 085e9629..601b2a55 100644 --- a/easyflow-commons/easyflow-common-cache/src/main/java/tech/easyflow/common/cache/CacheConfig.java +++ b/easyflow-commons/easyflow-common-cache/src/main/java/tech/easyflow/common/cache/CacheConfig.java @@ -10,6 +10,11 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.function.Function; + +/** + * EasyFlow 缓存基础配置。 + */ @Configuration public class CacheConfig { @@ -20,6 +25,9 @@ public class CacheConfig { private Cache defaultCache; + /** + * 根据平台配置初始化默认缓存。 + */ @PostConstruct public void init() { CacheType type = CacheType.LOCAL; @@ -35,8 +43,23 @@ public class CacheConfig { defaultCache = cacheManager.getOrCreateCache(quickConfig); } + /** + * 获取平台默认缓存。 + * + * @return 默认缓存实例 + */ @Bean("defaultCache") public Cache getDefaultCache() { return defaultCache; } + + /** + * 创建固定使用应用类加载器的 JetCache Java 解码器。 + * + * @return JetCache 缓存值解码函数 + */ + @Bean("easyFlowJetCacheValueDecoder") + public static Function easyFlowJetCacheValueDecoder() { + return new ApplicationClassLoaderJavaValueDecoder(); + } } diff --git a/easyflow-commons/easyflow-common-cache/src/test/java/tech/easyflow/common/cache/ApplicationClassLoaderJavaValueDecoderTest.java b/easyflow-commons/easyflow-common-cache/src/test/java/tech/easyflow/common/cache/ApplicationClassLoaderJavaValueDecoderTest.java new file mode 100644 index 00000000..c5e8c2e6 --- /dev/null +++ b/easyflow-commons/easyflow-common-cache/src/test/java/tech/easyflow/common/cache/ApplicationClassLoaderJavaValueDecoderTest.java @@ -0,0 +1,60 @@ +package tech.easyflow.common.cache; + +import com.alicp.jetcache.CacheValueHolder; +import com.alicp.jetcache.support.JavaValueEncoder; +import org.junit.Assert; +import org.junit.Test; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + * {@link ApplicationClassLoaderJavaValueDecoder} 回归测试。 + */ +public class ApplicationClassLoaderJavaValueDecoderTest { + + /** + * 验证异步线程上下文类加载器不可见应用依赖时仍可解码缓存值。 + * + * @throws Exception 异步任务执行失败时抛出 + */ + @Test + public void applyShouldUseApplicationClassLoaderInAsyncThread() throws Exception { + ApplicationClassLoaderJavaValueDecoder decoder = new ApplicationClassLoaderJavaValueDecoder(); + CacheValueHolder holder = new CacheValueHolder<>("workflow-state", TimeUnit.MINUTES.toMillis(1)); + byte[] encoded = new JavaValueEncoder(true).apply(holder); + ClassLoader isolatedClassLoader = new ClassLoader(null) { + }; + + assertClassIsInvisible(isolatedClassLoader, CacheValueHolder.class.getName()); + ExecutorService executor = Executors.newSingleThreadExecutor(task -> { + Thread thread = new Thread(task, "jetcache-decoder-test"); + thread.setContextClassLoader(isolatedClassLoader); + return thread; + }); + try { + Object decoded = executor.submit(() -> decoder.apply(encoded)).get(5, TimeUnit.SECONDS); + + Assert.assertTrue(decoded instanceof CacheValueHolder); + Assert.assertEquals("workflow-state", ((CacheValueHolder) decoded).getValue()); + } finally { + executor.shutdownNow(); + } + } + + /** + * 验证指定类加载器无法加载目标类。 + * + * @param classLoader 待验证类加载器 + * @param className 目标类名 + */ + private void assertClassIsInvisible(ClassLoader classLoader, String className) { + try { + classLoader.loadClass(className); + Assert.fail("isolated class loader should not load " + className); + } catch (ClassNotFoundException expected) { + // 隔离类加载器符合测试前提。 + } + } +} diff --git a/easyflow-commons/easyflow-common-file-storage/pom.xml b/easyflow-commons/easyflow-common-file-storage/pom.xml index e8a3482a..d27c1583 100644 --- a/easyflow-commons/easyflow-common-file-storage/pom.xml +++ b/easyflow-commons/easyflow-common-file-storage/pom.xml @@ -61,6 +61,13 @@ io.minio minio + + + junit + junit + ${junit.version} + test + diff --git a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageManager.java b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageManager.java index 1c32b0cf..19e9c9ae 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageManager.java +++ b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageManager.java @@ -10,46 +10,225 @@ import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Supplier; +/** + * 根据平台配置路由文件存储操作的统一入口。 + * + *

旧版操作每次使用当前后端;可恢复操作在 prepare 阶段固化后端,并在后续写入、检查及 + * 删除时严格按照句柄路由,避免配置切换后误操作另一个后端。

+ */ @Component("default") public class FileStorageManager implements FileStorageService { + /** 当前存储后端名称提供器。 */ + private final Supplier backendSupplier; + /** 按 bean 名称解析存储后端的函数。 */ + private final Function serviceResolver; + + /** + * 创建使用 Spring 上下文与当前存储配置的管理器。 + */ + public FileStorageManager() { + this(FileStorageManager::configuredBackend, FileStorageManager::springService); + } + + /** + * 创建使用指定路由提供器的管理器,供隔离测试使用。 + * + * @param backendSupplier 当前存储后端名称提供器 + * @param serviceResolver 按名称解析存储服务的函数 + */ + FileStorageManager(Supplier backendSupplier, + Function serviceResolver) { + this.backendSupplier = Objects.requireNonNull(backendSupplier, "backendSupplier 不能为空"); + this.serviceResolver = Objects.requireNonNull(serviceResolver, "serviceResolver 不能为空"); + } + + /** + * 使用当前后端保存文件。 + * + * @param file 上传文件 + * @return 文件 URL + */ @Override public String save(MultipartFile file) { - return getService().save(file); + return currentService().save(file); } + /** + * 使用当前后端及指定前置目录保存文件。 + * + * @param file 上传文件 + * @param prePath 前置目录 + * @return 文件 URL + */ @Override - public String save(MultipartFile file,String prePath) { - return getService().save(file,prePath); - } - - @Override - public void delete(String path) { - getService().delete(path); + public String save(MultipartFile file, String prePath) { + return currentService().save(file, prePath); } + /** + * 使用当前后端删除旧版 URL 或路径。 + * + * @param path 文件 URL 或路径 + */ + @Override + public void delete(String path) { + currentService().delete(path); + } + + /** + * 使用当前后端保存本地文件。 + * + * @param file 本地文件 + * @param prePath 前置目录 + * @return 文件 URL + */ @Override public String save(File file, String prePath) { - return getService().save(file, prePath); + return currentService().save(file, prePath); } + /** + * 使用当前后端打开文件流。 + * + * @param path 文件 URL 或路径 + * @return 文件输入流 + * @throws IOException 无法读取文件时抛出 + */ @Override public InputStream readStream(String path) throws IOException { - return getService().readStream(path); + return currentService().readStream(path); } + /** + * 使用当前后端获取文件大小。 + * + * @param path 文件 URL 或路径 + * @return 文件大小 + */ @Override public long getFileSize(String path) { - return getService().getFileSize(path); + return currentService().getFileSize(path); } - private FileStorageService getService() { - String type = StorageConfig.getInstance().getType(); - if (!StringUtils.hasText(type)) { - return SpringContextUtil.getBean(LocalFileStorageServiceImpl.class); - } else { - return SpringContextUtil.getBean(type); + /** + * 委托当前后端准备可恢复写句柄。 + * + * @param path 相对目录 + * @param filename 固定文件名 + * @return 包含当前后端路由的句柄 + */ + @Override + public FileStorageWriteHandle prepareRecoverableWrite(String path, String filename) { + return currentService().prepareRecoverableWrite(path, filename); + } + + /** + * 严格按句柄中的后端完成精确写入。 + * + * @param file 上传文件 + * @param handle 预先准备的句柄 + * @return 文件 URL 与恢复 locator + */ + @Override + public FileStorageWriteResult saveRecoverable(MultipartFile file, FileStorageWriteHandle handle) { + return serviceForHandle(handle).saveRecoverable(file, handle); + } + + /** + * 严格按句柄中的后端精确删除物理对象。 + * + * @param handle 物理对象句柄 + */ + @Override + public void deleteRecoverable(FileStorageWriteHandle handle) { + serviceForHandle(handle).deleteRecoverable(handle); + } + + /** + * 严格按句柄中的后端检查物理对象。 + * + * @param handle 物理对象句柄 + * @return 物理对象存在时返回 true + */ + @Override + public boolean existsRecoverable(FileStorageWriteHandle handle) { + return serviceForHandle(handle).existsRecoverable(handle); + } + + /** + * 解析当前配置对应的文件存储服务。 + * + * @return 当前文件存储服务 + */ + private FileStorageService currentService() { + return serviceForBackend(normalizeBackend(backendSupplier.get())); + } + + /** + * 从句柄解析固定文件存储服务。 + * + * @param handle 文件存储句柄 + * @return 句柄指定的文件存储服务 + */ + private FileStorageService serviceForHandle(FileStorageWriteHandle handle) { + if (handle == null) { + throw new IllegalArgumentException("文件存储写句柄不能为空"); } + return serviceForBackend(handle.getBackend()); + } + + /** + * 按已固化的后端名称解析服务,禁止回路由到管理器自身。 + * + * @param backend 后端 bean 名称 + * @return 具体文件存储服务 + */ + private FileStorageService serviceForBackend(String backend) { + if ("default".equals(backend)) { + throw new IllegalArgumentException("恢复句柄不能路由到 default 管理器"); + } + FileStorageService service = serviceResolver.apply(backend); + if (service == null || service == this) { + throw new IllegalStateException("文件存储后端不可用: " + backend); + } + return service; + } + + /** + * 读取并规范化当前配置中的后端名称。 + * + * @return 后端 bean 名称 + */ + private static String configuredBackend() { + String type = StorageConfig.getInstance().getType(); + return normalizeBackend(type); + } + + /** + * 将空配置映射到本地后端。 + * + * @param backend 配置值 + * @return 非空后端 bean 名称 + */ + private static String normalizeBackend(String backend) { + return StringUtils.hasText(backend) ? backend.trim() : "local"; + } + + /** + * 从 Spring 上下文按名称取得具体文件存储服务。 + * + * @param backend 后端 bean 名称 + * @return 具体服务 + */ + private static FileStorageService springService(String backend) { + if ("local".equals(backend)) { + return SpringContextUtil.getBean(LocalFileStorageServiceImpl.class); + } + return SpringContextUtil.getBean(backend, FileStorageService.class); } } diff --git a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageService.java b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageService.java index 962c8343..f17ed858 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageService.java +++ b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageService.java @@ -6,34 +6,123 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; +/** + * EasyFlow 文件存储统一接口。 + * + *

旧版 URL API 保持兼容;可恢复写入 API 允许调用方在物理写入前持久化精确定位信息。

+ */ public interface FileStorageService { - + /** + * 使用后端默认路径保存上传文件。 + * + * @param file 上传文件 + * @return 文件读取 URL + */ String save(MultipartFile file); - + /** + * 按旧版 URL 或路径删除文件。 + * + * @param path 文件 URL 或路径 + */ void delete(String path); /** - * 上传文件 + * 使用指定前置目录保存上传文件。 + * * @param file 文件 * @param prePath 存储桶和文件名中间的路径(不用加斜杠) * @return 文件url */ - default String save(MultipartFile file, String prePath){ + default String save(MultipartFile file, String prePath) { return ""; } - default String save(File file, String prePath){ + /** + * 使用指定前置目录保存本地文件。 + * + * @param file 本地文件 + * @param prePath 存储前置目录 + * @return 文件读取 URL + */ + default String save(File file, String prePath) { return ""; } + /** + * 打开文件读取流。 + * + * @param path 文件 URL 或路径 + * @return 文件输入流,由调用方关闭 + * @throws IOException 无法打开文件时抛出 + */ InputStream readStream(String path) throws IOException; /** - * 获取文件大小 - * @param path + * 获取文件大小。 + * + * @param path 文件 URL 或路径 * @return 文件大小 单位字节 */ - public long getFileSize(String path); + long getFileSize(String path); + + /** + * 在物理写入前准备一个具有稳定位置的恢复句柄。 + * + * @param path 基础路径下的相对目录 + * @param filename 固定文件名 + * @return 可在数据库中预先持久化的写入句柄 + * @throws UnsupportedOperationException 当前后端尚未实现可恢复写入时抛出 + */ + default FileStorageWriteHandle prepareRecoverableWrite(String path, String filename) { + throw unsupportedRecoverableOperation("prepareRecoverableWrite"); + } + + /** + * 将上传内容写入句柄指定的精确物理位置。 + * + * @param file 上传文件 + * @param handle 预先准备的写入句柄 + * @return 同时包含现有读取 URL 与恢复 locator 的写入结果 + * @throws UnsupportedOperationException 当前后端尚未实现可恢复写入时抛出 + */ + default FileStorageWriteResult saveRecoverable(MultipartFile file, FileStorageWriteHandle handle) { + throw unsupportedRecoverableOperation("saveRecoverable"); + } + + /** + * 精确且幂等地删除句柄对应的物理对象。 + * + *

仅在后端确认对象不存在后才能正常返回。

+ * + * @param handle 物理对象写入句柄 + * @throws UnsupportedOperationException 当前后端尚未实现可恢复删除时抛出 + * @throws RuntimeException 删除后仍能检测到物理对象时抛出 + */ + default void deleteRecoverable(FileStorageWriteHandle handle) { + throw unsupportedRecoverableOperation("deleteRecoverable"); + } + + /** + * 精确判断句柄对应的物理对象是否存在。 + * + * @param handle 物理对象写入句柄 + * @return 物理对象存在时返回 true + * @throws UnsupportedOperationException 当前后端尚未实现精确存在检查时抛出 + */ + default boolean existsRecoverable(FileStorageWriteHandle handle) { + throw unsupportedRecoverableOperation("existsRecoverable"); + } + + /** + * 创建统一的可恢复操作未实现异常。 + * + * @param operation 操作名称 + * @return fail-fast 异常 + */ + private UnsupportedOperationException unsupportedRecoverableOperation(String operation) { + return new UnsupportedOperationException( + getClass().getName() + " 不支持可恢复文件操作: " + operation); + } } diff --git a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageWriteHandle.java b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageWriteHandle.java new file mode 100644 index 00000000..7256c336 --- /dev/null +++ b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageWriteHandle.java @@ -0,0 +1,431 @@ +package tech.easyflow.common.filestorage; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Locale; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * 描述一次可恢复文件写入的不可变物理定位信息。 + * + *

句柄在上传前生成,随后可编码为有版本的 Base64URL locator 持久化。locator + * 只承担稳定、安全的结构化传输与损坏检测,不是访问凭证,也不提供防伪能力;调用方不得 + * 接受未经授权的外部 locator。

+ */ +public final class FileStorageWriteHandle { + + /** locator 文本前缀,其中包含当前编码版本。 */ + private static final String LOCATOR_PREFIX = "efsw1."; + /** 二进制编码版本。 */ + private static final int BINARY_VERSION = 1; + /** SHA-256 校验值长度。 */ + private static final int CHECKSUM_BYTES = 32; + /** locator 最大字符数,与数据库 storage_locator VARCHAR(2048) 契约一致。 */ + private static final int MAX_LOCATOR_CHARS = 2_048; + /** 后端名称最大 UTF-8 字节数。 */ + private static final int MAX_BACKEND_BYTES = 64; + /** 平台名称最大 UTF-8 字节数。 */ + private static final int MAX_PLATFORM_BYTES = 128; + /** 基础路径最大 UTF-8 字节数。 */ + private static final int MAX_BASE_PATH_BYTES = 4_096; + /** 相对路径最大 UTF-8 字节数。 */ + private static final int MAX_PATH_BYTES = 2_048; + /** 文件名最大 UTF-8 字节数。 */ + private static final int MAX_FILENAME_BYTES = 255; + /** 可安全作为 Spring bean 名称及持久化路由键的标识符。 */ + private static final Pattern ROUTE_PATTERN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]*"); + /** Base64URL 无填充文本允许的字符。 */ + private static final Pattern BASE64_URL_PATTERN = Pattern.compile("[A-Za-z0-9_-]+"); + /** Windows 保留设备名,避免 locator 在跨平台恢复时产生歧义。 */ + private static final Pattern WINDOWS_RESERVED_NAME = Pattern.compile( + "(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\\..*)?"); + + /** 负责处理该句柄的 EasyFlow 文件存储后端 bean 名称。 */ + private final String backend; + /** x-file-storage 平台名称;非 x-file-storage 后端可为空。 */ + private final String platform; + /** 准备写入时解析得到的持久基础路径或本地存储根目录。 */ + private final String basePath; + /** 基础路径下的规范化相对目录,以斜杠结尾;根目录使用空字符串。 */ + private final String path; + /** 目标对象的固定文件名。 */ + private final String filename; + + /** + * 创建并严格校验一个文件存储写句柄。 + * + * @param backend 存储后端路由名称 + * @param platform x-file-storage 平台名称,非该类后端可为空 + * @param basePath 持久基础路径或本地存储根目录 + * @param path 基础路径下的相对目录,可为空 + * @param filename 固定文件名 + * @throws IllegalArgumentException 任一字段为空、过长或包含不安全路径时抛出 + */ + public FileStorageWriteHandle(String backend, + String platform, + String basePath, + String path, + String filename) { + this.backend = validateRoute("backend", backend, false, MAX_BACKEND_BYTES); + this.platform = validateRoute("platform", platform, true, MAX_PLATFORM_BYTES); + this.basePath = validateBasePath(basePath); + this.path = normalizeRelativePath(path); + this.filename = validatePathSegment("filename", filename, MAX_FILENAME_BYTES); + if (buildLocator().length() > MAX_LOCATOR_CHARS) { + throw new IllegalArgumentException("文件存储 locator 超过 2048 字符持久化限制"); + } + } + + /** + * 获取负责处理该句柄的存储后端路由名称。 + * + * @return 存储后端 bean 名称 + */ + public String getBackend() { + return backend; + } + + /** + * 获取 x-file-storage 平台名称。 + * + * @return 平台名称,非 x-file-storage 后端时可为空字符串 + */ + public String getPlatform() { + return platform; + } + + /** + * 获取准备写入时固化的基础路径。 + * + * @return 基础路径或本地绝对根目录 + */ + public String getBasePath() { + return basePath; + } + + /** + * 获取规范化相对目录。 + * + * @return 空字符串或以斜杠结尾的相对目录 + */ + public String getPath() { + return path; + } + + /** + * 获取固定文件名。 + * + * @return 文件名 + */ + public String getFilename() { + return filename; + } + + /** + * 将句柄编码为带版本、无填充且具有完整性校验的 Base64URL locator。 + * + * @return 可安全持久化到文本字段的 locator + * @throws IllegalStateException 当前 JVM 不支持 SHA-256 或编码失败时抛出 + */ + public String encodeLocator() { + String locator = buildLocator(); + if (locator.length() > MAX_LOCATOR_CHARS) { + throw new IllegalStateException("文件存储 locator 超过 2048 字符持久化限制"); + } + return locator; + } + + /** + * 构造 locator 文本,长度检查由调用方在最终返回或构造校验阶段完成。 + * + * @return locator 文本 + */ + private String buildLocator() { + try { + ByteArrayOutputStream bodyBuffer = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bodyBuffer)) { + output.writeByte(BINARY_VERSION); + writeString(output, backend); + writeString(output, platform); + writeString(output, basePath); + writeString(output, path); + writeString(output, filename); + } + byte[] body = bodyBuffer.toByteArray(); + byte[] checksum = sha256(body); + ByteBuffer encoded = ByteBuffer.allocate(body.length + checksum.length); + encoded.put(body).put(checksum); + return LOCATOR_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(encoded.array()); + } catch (IOException exception) { + throw new IllegalStateException("编码文件存储 locator 失败", exception); + } + } + + /** + * 解码并严格校验一个文件存储 locator。 + * + * @param locator 由 {@link #encodeLocator()} 生成的 locator + * @return 不可变文件存储写句柄 + * @throws IllegalArgumentException locator 版本、编码、校验值或字段不合法时抛出 + */ + public static FileStorageWriteHandle decodeLocator(String locator) { + if (locator == null || locator.length() <= LOCATOR_PREFIX.length() + || locator.length() > MAX_LOCATOR_CHARS || !locator.startsWith(LOCATOR_PREFIX)) { + throw new IllegalArgumentException("文件存储 locator 格式不正确"); + } + String encoded = locator.substring(LOCATOR_PREFIX.length()); + if (!BASE64_URL_PATTERN.matcher(encoded).matches()) { + throw new IllegalArgumentException("文件存储 locator 不是无填充 Base64URL 编码"); + } + final byte[] bytes; + try { + bytes = Base64.getUrlDecoder().decode(encoded); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("文件存储 locator Base64URL 编码不正确", exception); + } + if (bytes.length <= CHECKSUM_BYTES + 1) { + throw new IllegalArgumentException("文件存储 locator 数据不完整"); + } + byte[] body = java.util.Arrays.copyOf(bytes, bytes.length - CHECKSUM_BYTES); + byte[] checksum = java.util.Arrays.copyOfRange(bytes, body.length, bytes.length); + if (!MessageDigest.isEqual(checksum, sha256(body))) { + throw new IllegalArgumentException("文件存储 locator 完整性校验失败"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(body))) { + int version = input.readUnsignedByte(); + if (version != BINARY_VERSION) { + throw new IllegalArgumentException("不支持的文件存储 locator 版本: " + version); + } + FileStorageWriteHandle handle = new FileStorageWriteHandle( + readString(input, "backend", MAX_BACKEND_BYTES), + readString(input, "platform", MAX_PLATFORM_BYTES), + readString(input, "basePath", MAX_BASE_PATH_BYTES), + readString(input, "path", MAX_PATH_BYTES), + readString(input, "filename", MAX_FILENAME_BYTES)); + if (input.available() != 0 || !handle.encodeLocator().equals(locator)) { + throw new IllegalArgumentException("文件存储 locator 包含非规范数据"); + } + return handle; + } catch (IOException exception) { + throw new IllegalArgumentException("文件存储 locator 数据不完整", exception); + } + } + + /** + * 将字符串以长度前缀 UTF-8 格式写入 locator 载荷。 + * + * @param output 目标数据流 + * @param value 字符串值 + * @throws IOException 写入失败时抛出 + */ + private static void writeString(DataOutputStream output, String value) throws IOException { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + output.writeInt(bytes.length); + output.write(bytes); + } + + /** + * 从 locator 载荷读取一个有界、严格 UTF-8 字符串。 + * + * @param input locator 数据流 + * @param field 字段名 + * @param maxBytes 最大 UTF-8 字节数 + * @return 解码字符串 + * @throws IOException 数据流不完整时抛出 + * @throws IllegalArgumentException 长度或 UTF-8 编码不合法时抛出 + */ + private static String readString(DataInputStream input, String field, int maxBytes) throws IOException { + int length = input.readInt(); + if (length < 0 || length > maxBytes || length > input.available()) { + throw new IllegalArgumentException(field + " 长度不正确"); + } + byte[] bytes = input.readNBytes(length); + try { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } catch (CharacterCodingException exception) { + throw new IllegalArgumentException(field + " 不是合法 UTF-8", exception); + } + } + + /** + * 校验存储后端或平台路由标识。 + * + * @param field 字段名 + * @param value 字段值 + * @param allowEmpty 是否允许空字符串 + * @param maxBytes 最大 UTF-8 字节数 + * @return 经校验的原值 + */ + private static String validateRoute(String field, String value, boolean allowEmpty, int maxBytes) { + if (value == null || (!allowEmpty && value.isBlank())) { + throw new IllegalArgumentException(field + " 不能为空"); + } + if (value.isEmpty() && allowEmpty) { + return value; + } + if (!value.equals(value.trim()) || utf8Length(value) > maxBytes || !ROUTE_PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException(field + " 不是合法路由标识"); + } + return value; + } + + /** + * 校验句柄中的基础路径。 + * + * @param value 基础路径 + * @return 经校验的原值 + */ + private static String validateBasePath(String value) { + if (value == null || utf8Length(value) > MAX_BASE_PATH_BYTES || containsControlCharacter(value)) { + throw new IllegalArgumentException("basePath 不合法或超过长度限制"); + } + validateNoTraversalSegments(value, "basePath"); + return value; + } + + /** + * 规范化并校验相对目录。 + * + * @param value 相对目录 + * @return 空字符串或以斜杠结尾的规范目录 + */ + private static String normalizeRelativePath(String value) { + if (value == null || value.isEmpty()) { + return ""; + } + if (!value.equals(value.trim()) || value.startsWith("/") || value.startsWith("\\") + || value.contains("\\") || value.contains("//") || containsControlCharacter(value)) { + throw new IllegalArgumentException("path 必须是规范的安全相对路径"); + } + String withoutTrailingSlash = value.endsWith("/") ? value.substring(0, value.length() - 1) : value; + if (withoutTrailingSlash.isEmpty() || utf8Length(withoutTrailingSlash) + 1 > MAX_PATH_BYTES) { + throw new IllegalArgumentException("path 不合法或超过长度限制"); + } + String[] segments = withoutTrailingSlash.split("/", -1); + for (String segment : segments) { + validatePathSegment("path", segment, MAX_FILENAME_BYTES); + } + return withoutTrailingSlash + "/"; + } + + /** + * 校验一个可移植的文件路径片段。 + * + * @param field 字段名 + * @param value 路径片段 + * @param maxBytes 最大 UTF-8 字节数 + * @return 经校验的原值 + */ + private static String validatePathSegment(String field, String value, int maxBytes) { + if (value == null || value.isBlank() || !value.equals(value.trim()) || ".".equals(value) || "..".equals(value) + || utf8Length(value) > maxBytes || containsControlCharacter(value) + || value.indexOf('/') >= 0 || value.indexOf('\\') >= 0 + || value.matches(".*[<>:\"|?*].*") || value.endsWith(".") + || WINDOWS_RESERVED_NAME.matcher(value.toUpperCase(Locale.ROOT)).matches()) { + throw new IllegalArgumentException(field + " 包含不安全路径片段"); + } + return value; + } + + /** + * 拒绝基础路径中的当前目录和父目录片段。 + * + * @param value 待检查路径 + * @param field 字段名 + */ + private static void validateNoTraversalSegments(String value, String field) { + for (String segment : value.split("[/\\\\]", -1)) { + if (".".equals(segment) || "..".equals(segment)) { + throw new IllegalArgumentException(field + " 包含路径穿越片段"); + } + } + } + + /** + * 判断字符串是否包含 ASCII 或 Unicode 控制字符。 + * + * @param value 待检查字符串 + * @return 包含控制字符时返回 true + */ + private static boolean containsControlCharacter(String value) { + return value.codePoints().anyMatch(codePoint -> Character.isISOControl(codePoint)); + } + + /** + * 计算字符串的 UTF-8 字节数。 + * + * @param value 字符串 + * @return UTF-8 字节数 + */ + private static int utf8Length(String value) { + return value.getBytes(StandardCharsets.UTF_8).length; + } + + /** + * 计算 SHA-256 完整性校验值。 + * + * @param bytes 输入字节 + * @return 32 字节 SHA-256 值 + */ + private static byte[] sha256(byte[] bytes) { + try { + return MessageDigest.getInstance("SHA-256").digest(bytes); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 JVM 不支持 SHA-256", exception); + } + } + + /** + * 比较两个写句柄的全部物理定位字段。 + * + * @param other 待比较对象 + * @return 字段全部相同时返回 true + */ + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof FileStorageWriteHandle handle)) { + return false; + } + return backend.equals(handle.backend) && platform.equals(handle.platform) + && basePath.equals(handle.basePath) && path.equals(handle.path) && filename.equals(handle.filename); + } + + /** + * 计算全部物理定位字段的哈希值。 + * + * @return 句柄哈希值 + */ + @Override + public int hashCode() { + return Objects.hash(backend, platform, basePath, path, filename); + } + + /** + * 返回不暴露额外内容的句柄摘要。 + * + * @return 后端、平台和相对对象路径摘要 + */ + @Override + public String toString() { + return "FileStorageWriteHandle{" + "backend='" + backend + '\'' + ", platform='" + platform + '\'' + + ", object='" + path + filename + "'}"; + } +} diff --git a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageWriteResult.java b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageWriteResult.java new file mode 100644 index 00000000..7362edb1 --- /dev/null +++ b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageWriteResult.java @@ -0,0 +1,90 @@ +package tech.easyflow.common.filestorage; + +import java.util.Objects; + +/** + * 可恢复文件写入完成后返回的不可变结果。 + * + *

URL 继续服务现有读取链路,locator 用于数据库提交失败或进程恢复时精确定位物理对象。

+ */ +public final class FileStorageWriteResult { + + /** 已写入文件的现有读取 URL。 */ + private final String url; + /** 可解码为 {@link FileStorageWriteHandle} 的恢复 locator。 */ + private final String locator; + + /** + * 创建文件存储写入结果。 + * + * @param url 已写入文件的读取 URL + * @param locator 恢复 locator + * @throws IllegalArgumentException URL 或 locator 为空、locator 无法解码时抛出 + */ + public FileStorageWriteResult(String url, String locator) { + if (url == null || url.isBlank()) { + throw new IllegalArgumentException("文件写入 URL 不能为空"); + } + if (locator == null || locator.isBlank()) { + throw new IllegalArgumentException("文件写入 locator 不能为空"); + } + FileStorageWriteHandle.decodeLocator(locator); + this.url = url; + this.locator = locator; + } + + /** + * 获取现有读取链路使用的 URL。 + * + * @return 文件 URL + */ + public String getUrl() { + return url; + } + + /** + * 获取精确恢复 locator。 + * + * @return 文件存储 locator + */ + public String getLocator() { + return locator; + } + + /** + * 比较 URL 与 locator。 + * + * @param other 待比较对象 + * @return 两个字段均相同时返回 true + */ + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof FileStorageWriteResult result)) { + return false; + } + return url.equals(result.url) && locator.equals(result.locator); + } + + /** + * 计算 URL 与 locator 的哈希值。 + * + * @return 结果哈希值 + */ + @Override + public int hashCode() { + return Objects.hash(url, locator); + } + + /** + * 返回不展开 locator 内容的写入结果摘要。 + * + * @return 写入结果摘要 + */ + @Override + public String toString() { + return "FileStorageWriteResult{" + "url='" + url + '\'' + ", locatorVersion='efsw1'}"; + } +} diff --git a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImpl.java b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImpl.java index 51d838ad..8b92e13b 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImpl.java +++ b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImpl.java @@ -9,29 +9,56 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.filestorage.FileStorageWriteHandle; +import tech.easyflow.common.filestorage.FileStorageWriteResult; import tech.easyflow.common.filestorage.utils.PathGeneratorUtil; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; - +/** + * EasyFlow 本地文件存储实现。 + */ @Component("local") public class LocalFileStorageServiceImpl implements FileStorageService { + /** 日志记录器。 */ private static final Logger LOG = LoggerFactory.getLogger(LocalFileStorageServiceImpl.class); + /** 可恢复句柄使用的后端路由名称。 */ + private static final String RECOVERABLE_BACKEND = "local"; - + /** 本地存储根目录。 */ @Value("${easyflow.storage.local.root:}") private String root; + /** 返回给旧读取链路的 URL 前缀。 */ @Value("${easyflow.storage.local.prefix:}") private String prefix; + /** + * 应用启动后的本地存储初始化钩子。 + */ @EventListener(ApplicationReadyEvent.class) public void init() { } - + /** + * 使用随机用户路径保存文件。 + * + * @param file 上传文件 + * @return 文件路径 + */ @Override public String save(MultipartFile file) { try { @@ -47,15 +74,28 @@ public class LocalFileStorageServiceImpl implements FileStorageService { } } + /** + * 打开本地文件读取流。 + * + * @param path 文件路径 + * @return 文件输入流 + * @throws IOException 文件不存在或不可读时抛出 + */ @Override public InputStream readStream(String path) throws IOException { File target = getLocalFile(path); return Files.newInputStream(target.toPath()); } + /** + * 获取本地文件大小。 + * + * @param path 文件路径 + * @return 文件大小,不存在时返回 0 + */ @Override public long getFileSize(String path) { - File target = null; + File target; try { target = getLocalFile(path); } catch (IOException e) { @@ -67,6 +107,11 @@ public class LocalFileStorageServiceImpl implements FileStorageService { return 0; } + /** + * 删除旧版路径对应的本地文件。 + * + * @param path 文件路径 + */ @Override public void delete(String path) { try { @@ -80,7 +125,9 @@ public class LocalFileStorageServiceImpl implements FileStorageService { /** * 递归删除文件或目录(支持删除非空目录) + * * @param file 要删除的文件或目录 + * @throws Exception 任一目标无法删除时抛出 */ private void deleteRecursively(File file) throws Exception { if (file == null || !file.exists()) { @@ -105,7 +152,13 @@ public class LocalFileStorageServiceImpl implements FileStorageService { } } - + /** + * 将旧版 URL 转换为本地文件。 + * + * @param path 文件 URL 或路径 + * @return 本地文件 + * @throws IOException 路径转换失败时抛出 + */ private File getLocalFile(String path) throws IOException { if (this.root == null || this.root.isEmpty()) { throw new RuntimeException("请指定存储根目录"); @@ -113,6 +166,13 @@ public class LocalFileStorageServiceImpl implements FileStorageService { return new File(this.root, path.replace(prefix, "")); } + /** + * 使用指定前置目录与随机用户路径保存文件。 + * + * @param file 上传文件 + * @param prePath 前置目录 + * @return 文件路径 + */ @Override public String save(MultipartFile file, String prePath) { try { @@ -131,4 +191,249 @@ public class LocalFileStorageServiceImpl implements FileStorageService { throw new RuntimeException(e.getMessage(), e); } } + + /** + * 准备包含真实、稳定本地根目录的可恢复写句柄。 + * + * @param path 根目录下的相对目录 + * @param filename 固定文件名 + * @return 本地可恢复写句柄 + */ + @Override + public FileStorageWriteHandle prepareRecoverableWrite(String path, String filename) { + try { + Path stableRoot = prepareStableRoot(); + return new FileStorageWriteHandle( + RECOVERABLE_BACKEND, "", stableRoot.toString(), path, filename); + } catch (IOException exception) { + throw new IllegalStateException("准备本地可恢复文件写入失败", exception); + } + } + + /** + * 通过同目录临时文件及原子替换写入句柄指定的精确本地文件。 + * + * @param file 上传文件 + * @param handle 本地可恢复写句柄 + * @return 本地读取 URL 与恢复 locator + * @throws RuntimeException 写入、刷盘、原子替换或结果确认失败时抛出 + */ + @Override + public FileStorageWriteResult saveRecoverable(MultipartFile file, FileStorageWriteHandle handle) { + if (file == null) { + throw new IllegalArgumentException("上传文件不能为空"); + } + requireLocalHandle(handle); + Path temporary = null; + try { + Path target = resolveControlledTarget(handle, true); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) + && (Files.isSymbolicLink(target) || !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS))) { + throw new IllegalStateException("本地可恢复写入目标不是普通文件: " + target); + } + temporary = recoverablePartPath(target, handle); + if (Files.exists(temporary, LinkOption.NOFOLLOW_LINKS) + && (Files.isSymbolicLink(temporary) + || !Files.isRegularFile(temporary, LinkOption.NOFOLLOW_LINKS))) { + throw new IllegalStateException("本地可恢复写入暂存目标不是普通文件: " + temporary); + } + try (InputStream input = file.getInputStream(); + FileChannel channel = FileChannel.open( + temporary, StandardOpenOption.CREATE, StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING)) { + OutputStream output = Channels.newOutputStream(channel); + input.transferTo(output); + channel.force(true); + } + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + throw new IllegalStateException("本地文件系统不支持可恢复写入所需的原子替换", exception); + } + temporary = null; + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { + throw new IllegalStateException("本地可恢复写入后未找到普通物理文件: " + target); + } + String objectPath = handle.getPath() + handle.getFilename(); + String url = StringUtils.hasText(prefix) + ? (prefix.endsWith("/") ? prefix : prefix + "/") + objectPath + : objectPath; + return new FileStorageWriteResult(url, handle.encodeLocator()); + } catch (IOException exception) { + throw new IllegalStateException("写入本地可恢复文件失败", exception); + } finally { + if (temporary != null) { + try { + Files.deleteIfExists(temporary); + } catch (IOException cleanupException) { + LOG.warn("清理本地可恢复写入临时文件失败: {}", temporary, cleanupException); + } + } + } + } + + /** + * 精确且幂等地删除句柄对应的最终文件与确定性暂存文件,并确认两者均不存在。 + * + * @param handle 本地可恢复写句柄 + * @throws RuntimeException 目标不安全、删除失败或删除后仍存在时抛出 + */ + @Override + public void deleteRecoverable(FileStorageWriteHandle handle) { + requireLocalHandle(handle); + try { + Path target = resolveControlledTarget(handle, false); + Path temporary = recoverablePartPath(target, handle); + deleteControlledRegularFile(target, "最终文件"); + deleteControlledRegularFile(temporary, "暂存文件"); + } catch (IOException exception) { + throw new IllegalStateException("删除本地可恢复文件失败", exception); + } + } + + /** + * 精确检查句柄对应的本地普通文件是否存在。 + * + * @param handle 本地可恢复写句柄 + * @return 普通物理文件存在时返回 true + * @throws RuntimeException 路径包含符号链接或目标不是普通文件时抛出 + */ + @Override + public boolean existsRecoverable(FileStorageWriteHandle handle) { + requireLocalHandle(handle); + try { + Path target = resolveControlledTarget(handle, false); + if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + if (Files.isSymbolicLink(target) || !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalStateException("本地恢复目标不是普通文件: " + target); + } + return true; + } catch (IOException exception) { + throw new IllegalStateException("检查本地可恢复文件失败", exception); + } + } + + /** + * 创建并解析配置根目录的真实路径,使句柄不依赖符号链接及后续配置切换。 + * + * @return 已存在的真实根目录 + * @throws IOException 无法创建或解析根目录时抛出 + */ + private Path prepareStableRoot() throws IOException { + if (!StringUtils.hasText(root)) { + throw new IllegalStateException("请指定存储根目录"); + } + Path configuredRoot = Path.of(root).toAbsolutePath().normalize(); + Files.createDirectories(configuredRoot); + Path realRoot = configuredRoot.toRealPath(); + if (!Files.isDirectory(realRoot, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(realRoot)) { + throw new IllegalStateException("本地存储根目录不是受控普通目录: " + configuredRoot); + } + return realRoot; + } + + /** + * 在句柄固化根目录下解析目标,并逐级拒绝符号链接与路径逃逸。 + * + * @param handle 本地可恢复写句柄 + * @param createDirectories 是否创建缺失目录 + * @return 受控目标文件路径 + * @throws IOException 路径检查或目录创建失败时抛出 + */ + private Path resolveControlledTarget(FileStorageWriteHandle handle, boolean createDirectories) throws IOException { + Path stableRoot = Path.of(handle.getBasePath()); + if (!stableRoot.isAbsolute() || !stableRoot.normalize().equals(stableRoot)) { + throw new IllegalArgumentException("本地恢复句柄中的根目录不是规范绝对路径"); + } + Path expectedTarget = stableRoot.resolve(handle.getPath()).resolve(handle.getFilename()).normalize(); + if (!expectedTarget.startsWith(stableRoot) || expectedTarget.getParent() == null + || !expectedTarget.getParent().startsWith(stableRoot)) { + throw new IllegalArgumentException("本地恢复目标逃逸存储根目录"); + } + if (!Files.exists(stableRoot, LinkOption.NOFOLLOW_LINKS)) { + if (!createDirectories) { + return expectedTarget; + } + Files.createDirectories(stableRoot); + } + if (Files.isSymbolicLink(stableRoot) || !Files.isDirectory(stableRoot, LinkOption.NOFOLLOW_LINKS) + || !stableRoot.toRealPath().equals(stableRoot)) { + throw new IllegalStateException("本地恢复句柄根目录不再是原受控目录: " + stableRoot); + } + + Path parent = stableRoot; + if (!handle.getPath().isEmpty()) { + String relativeDirectory = handle.getPath().substring(0, handle.getPath().length() - 1); + for (String segment : relativeDirectory.split("/")) { + Path next = parent.resolve(segment); + if (Files.exists(next, LinkOption.NOFOLLOW_LINKS)) { + if (Files.isSymbolicLink(next) || !Files.isDirectory(next, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalStateException("本地恢复路径包含非普通目录: " + next); + } + } else if (createDirectories) { + Files.createDirectory(next); + } else { + return expectedTarget; + } + parent = next; + } + } + if (!parent.toRealPath().equals(parent)) { + throw new IllegalStateException("本地恢复目标父目录已逃逸受控路径: " + parent); + } + return expectedTarget; + } + + /** + * 根据句柄稳定推导同目录暂存文件,确保进程在原子替换前退出时仍可精确回收。 + * + * @param target 最终目标文件 + * @param handle 可恢复写句柄 + * @return 确定性同目录暂存文件 + */ + Path recoverablePartPath(Path target, FileStorageWriteHandle handle) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(handle.encodeLocator().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return target.resolveSibling(".easyflow-part-" + HexFormat.of().formatHex(digest)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 JVM 不支持 SHA-256", exception); + } + } + + /** + * 删除受控普通文件并确认不存在;文件原本不存在时按幂等成功处理。 + * + * @param path 待删除文件 + * @param description 文件用途描述 + * @throws IOException 删除失败时抛出 + */ + private void deleteControlledRegularFile(Path path, String description) throws IOException { + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + return; + } + if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalStateException("拒绝删除非普通的本地恢复" + description + ": " + path); + } + Files.delete(path); + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalStateException("删除后本地恢复" + description + "仍存在: " + path); + } + } + + /** + * 校验句柄确实属于本地后端。 + * + * @param handle 待校验句柄 + */ + private void requireLocalHandle(FileStorageWriteHandle handle) { + if (handle == null) { + throw new IllegalArgumentException("本地文件存储写句柄不能为空"); + } + if (!RECOVERABLE_BACKEND.equals(handle.getBackend()) || !handle.getPlatform().isEmpty()) { + throw new IllegalArgumentException("文件存储写句柄不属于本地后端"); + } + } } diff --git a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java index 5697b6ff..66ed08a5 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java +++ b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java @@ -1,6 +1,8 @@ package tech.easyflow.common.filestorage.impl; import org.dromara.x.file.storage.core.FileInfo; +import org.dromara.x.file.storage.core.platform.FileStorage; +import org.dromara.x.file.storage.core.recorder.FileRecorder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -8,24 +10,49 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.filestorage.FileStorageWriteHandle; +import tech.easyflow.common.filestorage.FileStorageWriteResult; import tech.easyflow.common.filestorage.utils.PathGeneratorUtil; import tech.easyflow.common.util.OkHttpUtil; import java.io.*; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Objects; +/** + * 基于 x-file-storage 的 EasyFlow 文件存储实现。 + */ @Component("xFileStorage") public class XFIleStorageServiceImpl implements FileStorageService { + /** 日志记录器。 */ private static final Logger LOG = LoggerFactory.getLogger(XFIleStorageServiceImpl.class); + /** 可恢复句柄使用的后端路由名称。 */ + private static final String RECOVERABLE_BACKEND = "xFileStorage"; + /** x-file-storage 聚合服务。 */ @Autowired private org.dromara.x.file.storage.core.FileStorageService fileStorageService; + /** + * 使用默认目录上传文件。 + * + * @param file 上传文件 + * @return 文件 URL + */ @Override public String save(MultipartFile file) { return save(file, null); } + /** + * 使用指定前置目录上传文件。 + * + * @param file 上传文件 + * @param prePath 前置目录 + * @return 文件 URL + */ @Override public String save(MultipartFile file, String prePath) { String uploadPath = PathGeneratorUtil.generateUserPath(""); @@ -44,14 +71,34 @@ public class XFIleStorageServiceImpl implements FileStorageService { return fileInfo.getUrl(); } + /** + * 幂等删除指定文件;物理文件已不存在时同步清理残留记录。 + * + * @param path 文件路径 + * @throws RuntimeException 文件仍存在或残留记录无法清理时抛出 + */ @Override public void delete(String path) { boolean deleted = fileStorageService.delete(path); - if (!deleted) { - LOG.warn("删除文件失败或文件不存在,path={}", path); + if (deleted) { + return; + } + if (fileStorageService.exists(path)) { + throw new RuntimeException("删除文件失败,物理文件仍存在,path=" + path); + } + org.dromara.x.file.storage.core.recorder.FileRecorder recorder = fileStorageService.getFileRecorder(); + boolean recordDeleted = recorder != null && recorder.delete(path); + if (!recordDeleted && fileStorageService.getFileInfoByUrl(path) != null) { + throw new RuntimeException("物理文件已删除,但文件记录清理失败,path=" + path); } } + /** + * 通过文件 URL 打开远程读取流。 + * + * @param fileUrl 文件 URL + * @return 远程输入流 + */ @Override public InputStream readStream(String fileUrl) { return OkHttpUtil.getInputStream(fileUrl); @@ -73,7 +120,10 @@ public class XFIleStorageServiceImpl implements FileStorageService { } /** - * 获取文件的 Content-Type + * 获取上传文件的 Content-Type,并为文本文件补充 UTF-8 编码。 + * + * @param file 上传文件 + * @return 文件媒体类型 */ public static String getFileContentType(MultipartFile file) { String originalFilename = file.getOriginalFilename(); @@ -86,4 +136,300 @@ public class XFIleStorageServiceImpl implements FileStorageService { } return contentType; } + + /** + * 从当前默认 x-file-storage 平台解析平台名与公开基础路径,准备可恢复写句柄。 + * + * @param path 平台基础路径下的相对目录 + * @param filename 固定文件名 + * @return x-file-storage 可恢复写句柄 + * @throws RuntimeException 默认平台不存在或平台未公开 getBasePath 时抛出 + */ + @Override + public FileStorageWriteHandle prepareRecoverableWrite(String path, String filename) { + FileStorage storage = fileStorageService.getFileStorage(); + if (storage == null || !StringUtils.hasText(storage.getPlatform())) { + throw new IllegalStateException("x-file-storage 默认平台不可用"); + } + String basePath = readRequiredBasePath(storage); + return new FileStorageWriteHandle( + RECOVERABLE_BACKEND, storage.getPlatform(), basePath, path, filename); + } + + /** + * 使用句柄中的固定平台、路径及文件名上传文件。 + * + * @param file 上传文件 + * @param handle x-file-storage 可恢复写句柄 + * @return 文件 URL 与恢复 locator + * @throws RuntimeException 平台配置漂移、上传失败或实际位置不一致时抛出 + */ + @Override + public FileStorageWriteResult saveRecoverable(MultipartFile file, FileStorageWriteHandle handle) { + if (file == null) { + throw new IllegalArgumentException("上传文件不能为空"); + } + FileStorage storage = requireStorage(handle); + requireCurrentBasePathForWrite(storage, handle); + boolean physicalWriteMayHaveStarted = false; + try { + org.dromara.x.file.storage.core.upload.UploadPretreatment upload = fileStorageService.of(file) + .setPlatform(handle.getPlatform()) + .setPath(physicalPath(handle)) + .setSaveFilename(handle.getFilename()) + .setContentType(getFileContentType(file)); + physicalWriteMayHaveStarted = true; + FileInfo fileInfo = upload.upload(); + if (fileInfo == null || !StringUtils.hasText(fileInfo.getUrl())) { + throw new IllegalStateException("x-file-storage 未返回有效上传结果"); + } + verifyUploadedLocation(fileInfo, handle); + return new FileStorageWriteResult(fileInfo.getUrl(), handle.encodeLocator()); + } catch (RuntimeException exception) { + if (physicalWriteMayHaveStarted) { + try { + deletePhysicalAndConfirm(storage, handle); + cleanupRecorderBestEffort(storage, handle); + } catch (RuntimeException cleanupException) { + exception.addSuppressed(cleanupException); + } + } + throw exception; + } + } + + /** + * 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。 + * + * @param handle x-file-storage 可恢复写句柄 + * @throws RuntimeException 删除后物理对象仍存在时抛出 + */ + @Override + public void deleteRecoverable(FileStorageWriteHandle handle) { + FileStorage storage = requireStorage(handle); + requirePersistedBasePathSupport(storage, handle); + deletePhysicalAndConfirm(storage, handle); + cleanupRecorderBestEffort(storage, handle); + } + + /** + * 直接检查句柄指定平台上的物理对象,不依赖 Redis 或其他 FileRecorder 记录。 + * + * @param handle x-file-storage 可恢复写句柄 + * @return 物理对象存在时返回 true + */ + @Override + public boolean existsRecoverable(FileStorageWriteHandle handle) { + FileStorage storage = requireStorage(handle); + requirePersistedBasePathSupport(storage, handle); + return storage.exists(toFileInfo(handle)); + } + + /** + * 校验句柄并取得其固定平台。 + * + * @param handle 待处理句柄 + * @return 句柄指定的具体平台存储 + */ + private FileStorage requireStorage(FileStorageWriteHandle handle) { + if (handle == null) { + throw new IllegalArgumentException("x-file-storage 写句柄不能为空"); + } + if (!RECOVERABLE_BACKEND.equals(handle.getBackend()) || !StringUtils.hasText(handle.getPlatform())) { + throw new IllegalArgumentException("文件存储写句柄不属于 x-file-storage 后端"); + } + FileStorage storage = fileStorageService.getFileStorage(handle.getPlatform()); + if (storage == null) { + throw new IllegalStateException("x-file-storage 平台不存在: " + handle.getPlatform()); + } + return storage; + } + + /** + * 上传时要求平台当前基础路径仍与句柄一致,因为 x-file-storage 的 save 会覆盖 FileInfo.basePath。 + * + * @param storage 具体平台存储 + * @param handle 文件存储写句柄 + */ + private void requireCurrentBasePathForWrite(FileStorage storage, FileStorageWriteHandle handle) { + String currentBasePath = readRequiredBasePath(storage); + if (!Objects.equals(currentBasePath, handle.getBasePath())) { + throw new IllegalStateException("x-file-storage 平台基础路径已变化,无法写入预先确定的位置"); + } + } + + /** + * 基础路径发生配置漂移时,确认具体平台的物理 key 仍实际使用句柄中的持久 basePath。 + * + *

大多数对象存储使用 {@link FileStorage#getFileKey(FileInfo)} 默认实现,可安全清理历史 + * basePath;忽略 FileInfo.basePath 的平台会 fail-fast,避免删除当前新目录下的同名对象。

+ * + * @param storage 具体平台存储 + * @param handle 文件存储写句柄 + */ + private void requirePersistedBasePathSupport(FileStorage storage, FileStorageWriteHandle handle) { + String currentBasePath = readRequiredBasePath(storage); + if (Objects.equals(currentBasePath, handle.getBasePath())) { + return; + } + FileInfo fileInfo = toFileInfo(handle); + String expectedKey = handle.getBasePath() + physicalPath(handle) + handle.getFilename(); + if (!Objects.equals(expectedKey, storage.getFileKey(fileInfo))) { + throw new IllegalStateException("x-file-storage 平台基础路径已变化,且当前平台无法按持久 basePath 定位"); + } + } + + /** + * 反射调用具体平台公开的 getBasePath 方法。 + * + * @param storage 具体平台存储 + * @return 基础路径,平台返回 null 时规范为空字符串 + * @throws RuntimeException 平台未公开兼容方法或调用失败时抛出 + */ + private String readRequiredBasePath(FileStorage storage) { + try { + Method method = storage.getClass().getMethod("getBasePath"); + if (!String.class.equals(method.getReturnType())) { + throw new IllegalStateException("x-file-storage 平台 getBasePath 返回类型不是 String: " + + storage.getClass().getName()); + } + String basePath = (String) method.invoke(storage); + return basePath == null ? "" : basePath; + } catch (NoSuchMethodException exception) { + throw new IllegalStateException("x-file-storage 平台未公开 getBasePath: " + + storage.getClass().getName(), exception); + } catch (IllegalAccessException | InvocationTargetException exception) { + throw new IllegalStateException("读取 x-file-storage 平台基础路径失败: " + + storage.getClass().getName(), exception); + } + } + + /** + * 校验 x-file-storage 实际上传位置与预先持久化句柄完全一致。 + * + * @param fileInfo 实际上传结果 + * @param handle 预先准备的句柄 + */ + private void verifyUploadedLocation(FileInfo fileInfo, FileStorageWriteHandle handle) { + String actualBasePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath(); + String actualPath = fileInfo.getPath() == null ? "" : fileInfo.getPath(); + if (!handle.getPlatform().equals(fileInfo.getPlatform()) + || !handle.getBasePath().equals(actualBasePath) + || !physicalPath(handle).equals(actualPath) + || !handle.getFilename().equals(fileInfo.getFilename())) { + throw new IllegalStateException("x-file-storage 实际上传位置与恢复句柄不一致"); + } + } + + /** + * 构造仅包含精确物理定位字段的 FileInfo。 + * + * @param handle 文件存储写句柄 + * @return 供具体平台直接删除或检查的文件信息 + */ + private FileInfo toFileInfo(FileStorageWriteHandle handle) { + return new FileInfo() + .setPlatform(handle.getPlatform()) + .setBasePath(handle.getBasePath()) + .setPath(physicalPath(handle)) + .setFilename(handle.getFilename()); + } + + /** + * 将句柄中的安全相对目录转换为 x-file-storage 直接拼接 basePath 所需的物理目录。 + * + *

当前配置常使用不带尾斜杠的 basePath;此时必须补一个前导斜杠,避免生成 + * {@code attachmentskill-content/...} 一类错误对象键。

+ * + * @param handle 文件存储写句柄 + * @return 传给 x-file-storage 的精确物理目录 + */ + private String physicalPath(FileStorageWriteHandle handle) { + if (handle.getBasePath().isEmpty() || handle.getBasePath().endsWith("/")) { + return handle.getPath(); + } + return "/" + handle.getPath(); + } + + /** + * 直接删除具体平台物理对象,并以随后 exists 结果作为成功判据。 + * + * @param storage 具体平台存储 + * @param handle 文件存储写句柄 + */ + private void deletePhysicalAndConfirm(FileStorage storage, FileStorageWriteHandle handle) { + FileInfo fileInfo = toFileInfo(handle); + boolean deleted; + try { + deleted = storage.delete(fileInfo); + } catch (RuntimeException exception) { + final boolean stillExists; + try { + stillExists = storage.exists(fileInfo); + } catch (RuntimeException existsException) { + exception.addSuppressed(existsException); + throw exception; + } + if (!stillExists) { + return; + } + throw exception; + } + if (storage.exists(fileInfo)) { + throw new IllegalStateException("x-file-storage 删除后物理对象仍存在,platform=" + + handle.getPlatform() + ", path=" + handle.getPath() + handle.getFilename() + + ", deleteResult=" + deleted); + } + } + + /** + * 在物理删除已经确认成功后,尽力清理可推导 URL 对应的 recorder 记录。 + * + *

记录不存在、平台不能公开推导 URL 或清理失败均不改变物理删除成功结果。

+ * + * @param storage 具体平台存储 + * @param handle 文件存储写句柄 + */ + private void cleanupRecorderBestEffort(FileStorage storage, FileStorageWriteHandle handle) { + try { + FileRecorder recorder = fileStorageService.getFileRecorder(); + if (recorder == null) { + return; + } + String url = deriveUrlBestEffort(storage, toFileInfo(handle)); + if (!StringUtils.hasText(url)) { + return; + } + if (!recorder.delete(url)) { + LOG.debug("x-file-storage recorder 中没有可清理记录,url={}", url); + } + } catch (RuntimeException exception) { + LOG.warn("物理文件已删除,但清理 x-file-storage recorder 记录失败,platform={}", + handle.getPlatform(), exception); + } + } + + /** + * 使用平台公开的 getDomain 与 getFileKey 尽力推导 recorder 使用的 URL。 + * + * @param storage 具体平台存储 + * @param fileInfo 精确物理文件信息 + * @return 可推导 URL;平台不支持时返回 null + */ + private String deriveUrlBestEffort(FileStorage storage, FileInfo fileInfo) { + try { + Method method = storage.getClass().getMethod("getDomain"); + if (!String.class.equals(method.getReturnType())) { + return null; + } + String domain = (String) method.invoke(storage); + if (domain == null) { + return null; + } + return domain + storage.getFileKey(fileInfo); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | RuntimeException exception) { + LOG.debug("当前 x-file-storage 平台无法推导 recorder URL: {}", storage.getClass().getName()); + return null; + } + } } diff --git a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageManagerTest.java b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageManagerTest.java new file mode 100644 index 00000000..9ff4f662 --- /dev/null +++ b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageManagerTest.java @@ -0,0 +1,115 @@ +package tech.easyflow.common.filestorage; + +import org.junit.Test; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; + +/** + * {@link FileStorageManager} 可恢复操作固定后端路由测试。 + */ +public class FileStorageManagerTest { + + /** + * 验证 prepare 使用当前后端,而后续操作在默认后端切换后仍按句柄后端路由。 + */ + @Test + public void recoverableOperationsRouteByPreparedBackendAfterSwitch() { + RecordingStorage local = new RecordingStorage("local"); + RecordingStorage xFile = new RecordingStorage("xFileStorage"); + AtomicReference current = new AtomicReference<>("local"); + FileStorageManager manager = new FileStorageManager( + current::get, backend -> Map.of("local", local, "xFileStorage", xFile).get(backend)); + + FileStorageWriteHandle handle = manager.prepareRecoverableWrite("skill-content/ab", "content.bin"); + current.set("xFileStorage"); + FileStorageWriteResult result = manager.saveRecoverable(null, handle); + manager.deleteRecoverable(handle); + boolean exists = manager.existsRecoverable(handle); + + assertEquals("local", handle.getBackend()); + assertSame(local.result, result); + assertEquals(1, local.prepareCalls); + assertEquals(1, local.saveCalls); + assertEquals(1, local.deleteCalls); + assertEquals(1, local.existsCalls); + assertEquals(0, xFile.prepareCalls + xFile.saveCalls + xFile.deleteCalls + xFile.existsCalls); + assertFalse(exists); + } + + /** + * 可记录可恢复调用的存储测试替身。 + */ + private static final class RecordingStorage implements FileStorageService { + /** 后端名称。 */ + private final String backend; + /** 固定结果。 */ + private final FileStorageWriteResult result; + /** prepare 调用次数。 */ + private int prepareCalls; + /** save 调用次数。 */ + private int saveCalls; + /** delete 调用次数。 */ + private int deleteCalls; + /** exists 调用次数。 */ + private int existsCalls; + + /** + * 创建指定名称的存储替身。 + * + * @param backend 后端名称 + */ + private RecordingStorage(String backend) { + this.backend = backend; + FileStorageWriteHandle handle = new FileStorageWriteHandle( + backend, "", "/tmp/easyflow", "skill-content", "content.bin"); + this.result = new FileStorageWriteResult("/files/content.bin", handle.encodeLocator()); + } + + /** {@inheritDoc} */ + @Override public String save(MultipartFile file) { return ""; } + /** {@inheritDoc} */ + @Override public void delete(String path) { } + /** {@inheritDoc} */ + @Override public InputStream readStream(String path) throws IOException { return InputStream.nullInputStream(); } + /** {@inheritDoc} */ + @Override public long getFileSize(String path) { return 0; } + /** {@inheritDoc} */ + @Override public String save(File file, String prePath) { return ""; } + + /** {@inheritDoc} */ + @Override + public FileStorageWriteHandle prepareRecoverableWrite(String path, String filename) { + prepareCalls++; + return new FileStorageWriteHandle(backend, "", "/tmp/easyflow", path, filename); + } + + /** {@inheritDoc} */ + @Override + public FileStorageWriteResult saveRecoverable(MultipartFile file, FileStorageWriteHandle handle) { + saveCalls++; + return result; + } + + /** {@inheritDoc} */ + @Override + public void deleteRecoverable(FileStorageWriteHandle handle) { + deleteCalls++; + } + + /** {@inheritDoc} */ + @Override + public boolean existsRecoverable(FileStorageWriteHandle handle) { + existsCalls++; + return false; + } + } +} diff --git a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageServiceTest.java b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageServiceTest.java new file mode 100644 index 00000000..80ccd0e0 --- /dev/null +++ b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageServiceTest.java @@ -0,0 +1,48 @@ +package tech.easyflow.common.filestorage; + +import org.junit.Test; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; + +import static org.junit.Assert.assertThrows; + +/** + * {@link FileStorageService} 可恢复操作默认 fail-fast 契约测试。 + */ +public class FileStorageServiceTest { + + /** + * 验证尚未实现新契约的旧后端不会伪造成功结果。 + */ + @Test + public void recoverableDefaultsFailFast() { + FileStorageService legacyStorage = new LegacyStorage(); + FileStorageWriteHandle handle = new FileStorageWriteHandle( + "legacy", "", "/tmp/easyflow", "skill-content", "content.bin"); + + assertThrows(UnsupportedOperationException.class, + () -> legacyStorage.prepareRecoverableWrite("skill-content", "content.bin")); + assertThrows(UnsupportedOperationException.class, + () -> legacyStorage.saveRecoverable(null, handle)); + assertThrows(UnsupportedOperationException.class, + () -> legacyStorage.deleteRecoverable(handle)); + assertThrows(UnsupportedOperationException.class, + () -> legacyStorage.existsRecoverable(handle)); + } + + /** + * 仅实现旧版接口的存储替身。 + */ + private static final class LegacyStorage implements FileStorageService { + /** {@inheritDoc} */ + @Override public String save(MultipartFile file) { return ""; } + /** {@inheritDoc} */ + @Override public void delete(String path) { } + /** {@inheritDoc} */ + @Override public InputStream readStream(String path) throws IOException { return InputStream.nullInputStream(); } + /** {@inheritDoc} */ + @Override public long getFileSize(String path) { return 0; } + } +} diff --git a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageWriteHandleTest.java b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageWriteHandleTest.java new file mode 100644 index 00000000..f3692236 --- /dev/null +++ b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageWriteHandleTest.java @@ -0,0 +1,87 @@ +package tech.easyflow.common.filestorage; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** + * {@link FileStorageWriteHandle} 编解码与安全边界测试。 + */ +public class FileStorageWriteHandleTest { + + /** + * 验证 locator 可无损往返且相对目录会规范化为尾斜杠形式。 + */ + @Test + public void locatorRoundTripPreservesPhysicalLocation() { + FileStorageWriteHandle handle = new FileStorageWriteHandle( + "xFileStorage", "minio-1", "easyflow/", "skill-content/ab", "content.bin"); + + String locator = handle.encodeLocator(); + FileStorageWriteHandle decoded = FileStorageWriteHandle.decodeLocator(locator); + + assertEquals(handle, decoded); + assertEquals("skill-content/ab/", decoded.getPath()); + assertTrue(locator.startsWith("efsw1.")); + assertFalse(locator.contains("=")); + assertTrue(locator.length() <= 2048); + } + + /** + * 验证篡改后的 locator 无法绕过完整性校验。 + */ + @Test + public void tamperedLocatorIsRejected() { + FileStorageWriteHandle handle = new FileStorageWriteHandle( + "local", "", "/var/lib/easyflow", "skill-content", "content.bin"); + String locator = handle.encodeLocator(); + char replacement = locator.endsWith("A") ? 'B' : 'A'; + String tampered = locator.substring(0, locator.length() - 1) + replacement; + + assertThrows(IllegalArgumentException.class, + () -> FileStorageWriteHandle.decodeLocator(tampered)); + } + + /** + * 验证相对路径穿越、绝对路径与不可移植文件名都会被拒绝。 + */ + @Test + public void unsafePathsAreRejected() { + assertThrows(IllegalArgumentException.class, + () -> new FileStorageWriteHandle("local", "", "/tmp/easyflow", "../outside", "file.bin")); + assertThrows(IllegalArgumentException.class, + () -> new FileStorageWriteHandle("local", "", "/tmp/easyflow", "/absolute", "file.bin")); + assertThrows(IllegalArgumentException.class, + () -> new FileStorageWriteHandle("local", "", "/tmp/easyflow", "safe", "../file.bin")); + assertThrows(IllegalArgumentException.class, + () -> new FileStorageWriteHandle("local", "", "/tmp/easyflow", "safe", "CON")); + } + + /** + * 验证句柄在构造阶段就受数据库 VARCHAR(2048) locator 预算约束。 + */ + @Test + public void handleExceedingPersistentLocatorBudgetIsRejected() { + String oversizedBasePath = "/" + "a".repeat(1_700); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> new FileStorageWriteHandle( + "xFileStorage", "minio", oversizedBasePath, "skill-content", "content.bin")); + + assertTrue(exception.getMessage().contains("2048")); + } + + /** + * 验证解码器在 Base64 解码前拒绝超过数据库字段预算的输入。 + */ + @Test + public void oversizedLocatorTextIsRejectedBeforeDecode() { + String locator = "efsw1." + "A".repeat(2048); + + assertThrows(IllegalArgumentException.class, + () -> FileStorageWriteHandle.decodeLocator(locator)); + } +} diff --git a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImplTest.java b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImplTest.java new file mode 100644 index 00000000..c35f2f51 --- /dev/null +++ b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImplTest.java @@ -0,0 +1,163 @@ +package tech.easyflow.common.filestorage.impl; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.common.filestorage.FileStorageWriteHandle; +import tech.easyflow.common.filestorage.FileStorageWriteResult; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** + * {@link LocalFileStorageServiceImpl} 可恢复精确写删测试。 + */ +public class LocalFileStorageServiceImplTest { + + /** 每个测试使用的隔离临时目录。 */ + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + /** + * 验证固定位置原子写入、配置切换后仍按句柄根目录定位及幂等删除。 + * + * @throws Exception 测试目录或反射配置失败 + */ + @Test + public void recoverableWriteUsesPersistentRootAndDeletesIdempotently() throws Exception { + File originalRoot = temporaryFolder.newFolder("original-root"); + File changedRoot = temporaryFolder.newFolder("changed-root"); + LocalFileStorageServiceImpl service = createService(originalRoot, "/files"); + FileStorageWriteHandle handle = service.prepareRecoverableWrite("skill-content/ab", "content.bin"); + setField(service, "root", changedRoot.getAbsolutePath()); + + byte[] bytes = "recoverable-content".getBytes(java.nio.charset.StandardCharsets.UTF_8); + FileStorageWriteResult result = service.saveRecoverable(new BytesMultipartFile(bytes), handle); + Path target = Path.of(handle.getBasePath()).resolve(handle.getPath()).resolve(handle.getFilename()); + + assertEquals("/files/skill-content/ab/content.bin", result.getUrl()); + assertEquals(handle, FileStorageWriteHandle.decodeLocator(result.getLocator())); + assertTrue(service.existsRecoverable(handle)); + assertArrayEquals(bytes, Files.readAllBytes(target)); + assertFalse(Files.exists(changedRoot.toPath().resolve("skill-content/ab/content.bin"))); + + service.deleteRecoverable(handle); + service.deleteRecoverable(handle); + assertFalse(service.existsRecoverable(handle)); + } + + /** + * 验证崩溃窗口遗留的确定性 part 文件可由同一个句柄精确回收。 + * + * @throws Exception 测试目录或反射配置失败 + */ + @Test + public void deleteRecoverableRemovesFinalAndCrashLeftPartFile() throws Exception { + File root = temporaryFolder.newFolder("crash-root"); + LocalFileStorageServiceImpl service = createService(root, ""); + FileStorageWriteHandle handle = service.prepareRecoverableWrite("skill-content/cd", "content.bin"); + Path target = Path.of(handle.getBasePath()).resolve(handle.getPath()).resolve(handle.getFilename()); + Files.createDirectories(target.getParent()); + Files.writeString(target, "final"); + Path part = service.recoverablePartPath(target, handle); + Files.writeString(part, "partial"); + + service.deleteRecoverable(handle); + + assertFalse(Files.exists(target)); + assertFalse(Files.exists(part)); + } + + /** + * 验证句柄路径中的符号链接不会被跟随到存储根目录外。 + * + * @throws Exception 测试目录、符号链接或反射配置失败 + */ + @Test + public void recoverableWriteRejectsSymbolicLinkEscape() throws Exception { + File root = temporaryFolder.newFolder("symlink-root"); + File outside = temporaryFolder.newFolder("outside"); + Files.createSymbolicLink(root.toPath().resolve("escape"), outside.toPath()); + LocalFileStorageServiceImpl service = createService(root, ""); + FileStorageWriteHandle handle = service.prepareRecoverableWrite("escape", "content.bin"); + + assertThrows(IllegalStateException.class, + () -> service.saveRecoverable(new BytesMultipartFile(new byte[]{1}), handle)); + assertFalse(Files.exists(outside.toPath().resolve("content.bin"))); + } + + /** + * 创建具有测试根目录与 URL 前缀的服务。 + * + * @param root 本地根目录 + * @param prefix URL 前缀 + * @return 本地存储服务 + * @throws Exception 反射设置字段失败 + */ + private LocalFileStorageServiceImpl createService(File root, String prefix) throws Exception { + LocalFileStorageServiceImpl service = new LocalFileStorageServiceImpl(); + setField(service, "root", root.getAbsolutePath()); + setField(service, "prefix", prefix); + return service; + } + + /** + * 设置服务私有配置字段。 + * + * @param target 目标服务 + * @param name 字段名 + * @param value 字段值 + * @throws Exception 字段不存在或不可写时抛出 + */ + private void setField(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + /** + * 基于内存字节的 MultipartFile 测试替身。 + */ + private static final class BytesMultipartFile implements MultipartFile { + /** 文件内容。 */ + private final byte[] bytes; + + /** + * 创建测试上传文件。 + * + * @param bytes 文件内容 + */ + private BytesMultipartFile(byte[] bytes) { + this.bytes = bytes.clone(); + } + + /** {@inheritDoc} */ + @Override public String getName() { return "file"; } + /** {@inheritDoc} */ + @Override public String getOriginalFilename() { return "content.bin"; } + /** {@inheritDoc} */ + @Override public String getContentType() { return "application/octet-stream"; } + /** {@inheritDoc} */ + @Override public boolean isEmpty() { return bytes.length == 0; } + /** {@inheritDoc} */ + @Override public long getSize() { return bytes.length; } + /** {@inheritDoc} */ + @Override public byte[] getBytes() { return bytes.clone(); } + /** {@inheritDoc} */ + @Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); } + /** {@inheritDoc} */ + @Override public void transferTo(File dest) throws IOException { Files.write(dest.toPath(), bytes); } + } +} diff --git a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java new file mode 100644 index 00000000..479055d4 --- /dev/null +++ b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java @@ -0,0 +1,571 @@ +package tech.easyflow.common.filestorage.impl; + +import org.junit.Test; +import org.dromara.x.file.storage.core.FileInfo; +import org.dromara.x.file.storage.core.UploadPretreatment; +import org.dromara.x.file.storage.core.platform.FileStorage; +import org.dromara.x.file.storage.core.recorder.FileRecorder; +import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.common.filestorage.FileStorageWriteHandle; +import tech.easyflow.common.filestorage.FileStorageWriteResult; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.util.function.Consumer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** + * {@link XFIleStorageServiceImpl} 删除结果传播测试。 + */ +public class XFIleStorageServiceImplTest { + + /** + * 验证底层明确返回 false 时抛出带有效消息的异常。 + * + * @throws Exception 注入测试替身失败 + */ + @Test + public void deleteFalseThrowsNonEmptyException() throws Exception { + DeleteResultStorageService delegate = new DeleteResultStorageService(false, true, false); + XFIleStorageServiceImpl service = createService(delegate); + + RuntimeException exception = assertThrows( + RuntimeException.class, () -> service.delete("skill-content/retry.bin")); + + assertFalse(exception.getMessage() == null || exception.getMessage().isBlank()); + assertEquals("skill-content/retry.bin", delegate.getLastPath()); + } + + /** + * 验证底层确认删除成功时正常返回。 + * + * @throws Exception 注入测试替身失败 + */ + @Test + public void deleteTrueReturnsNormally() throws Exception { + DeleteResultStorageService delegate = new DeleteResultStorageService(true, false, false); + XFIleStorageServiceImpl service = createService(delegate); + + service.delete("skill-content/deleted.bin"); + + assertEquals("skill-content/deleted.bin", delegate.getLastPath()); + } + + /** + * 验证物理文件已不存在时会清理残留记录并按幂等成功返回。 + * + * @throws Exception 注入测试替身失败 + */ + @Test + public void deleteAbsentFileCleansResidualRecord() throws Exception { + DeleteResultStorageService delegate = new DeleteResultStorageService(false, false, true); + XFIleStorageServiceImpl service = createService(delegate); + + service.delete("skill-content/already-absent.bin"); + + assertEquals("skill-content/already-absent.bin", delegate.getLastPath()); + assertFalse(delegate.hasRecord()); + } + + /** + * 验证 prepare 与 save 固定平台、基础路径、相对路径及文件名,并同时返回 URL 与 locator。 + * + * @throws Exception 注入测试替身失败 + */ + @Test + public void recoverableSaveUsesExactPreparedLocation() throws Exception { + RecoverablePlatform platform = new RecoverablePlatform("minio-main", "attachment", "https://files/"); + RecoverableStorageService delegate = new RecoverableStorageService(platform); + XFIleStorageServiceImpl service = createService(delegate); + FileStorageWriteHandle handle = service.prepareRecoverableWrite("skill-content/ab", "content.bin"); + + FileStorageWriteResult result = service.saveRecoverable( + new BytesMultipartFile("content".getBytes(java.nio.charset.StandardCharsets.UTF_8)), handle); + + assertEquals("xFileStorage", handle.getBackend()); + assertEquals("minio-main", handle.getPlatform()); + assertEquals("attachment", handle.getBasePath()); + assertEquals("/skill-content/ab/", delegate.uploadPath); + assertEquals("content.bin", delegate.uploadFilename); + assertEquals("minio-main", delegate.uploadPlatform); + assertEquals("https://files/attachment/skill-content/ab/content.bin", result.getUrl()); + assertEquals(handle, FileStorageWriteHandle.decodeLocator(result.getLocator())); + assertTrue(platform.exists); + } + + /** + * 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。 + * + * @throws Exception 注入测试替身失败 + */ + @Test + public void recoverableDeleteWithoutRecorderEntryStillDeletesPhysicalObject() throws Exception { + RecoverablePlatform platform = new RecoverablePlatform("minio-main", "easyflow/", "https://files/"); + platform.exists = true; + RecoverableStorageService delegate = new RecoverableStorageService(platform); + XFIleStorageServiceImpl service = createService(delegate); + FileStorageWriteHandle handle = new FileStorageWriteHandle( + "xFileStorage", "minio-main", "easyflow/", "skill-content/ab", "content.bin"); + + service.deleteRecoverable(handle); + + assertFalse(platform.exists); + assertEquals(1, platform.deleteCalls); + assertEquals(1, delegate.recorderDeleteCalls); + } + + /** + * 验证具体平台报告删除失败且物理对象仍存在时必须抛出异常。 + * + * @throws Exception 注入测试替身失败 + */ + @Test + public void recoverableDeleteFailureIsNotMaskedByRecorder() throws Exception { + RecoverablePlatform platform = new RecoverablePlatform("minio-main", "easyflow/", "https://files/"); + platform.exists = true; + platform.deleteSucceeds = false; + RecoverableStorageService delegate = new RecoverableStorageService(platform); + XFIleStorageServiceImpl service = createService(delegate); + FileStorageWriteHandle handle = new FileStorageWriteHandle( + "xFileStorage", "minio-main", "easyflow/", "skill-content/ab", "content.bin"); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, () -> service.deleteRecoverable(handle)); + + assertTrue(exception.getMessage().contains("仍存在")); + assertTrue(platform.exists); + assertEquals(0, delegate.recorderDeleteCalls); + } + + /** + * 验证物理删除确认成功后,recorder 清理异常不会反向伪造物理失败。 + * + * @throws Exception 注入测试替身失败 + */ + @Test + public void recoverableDeleteIgnoresRecorderCleanupFailureAfterPhysicalSuccess() throws Exception { + RecoverablePlatform platform = new RecoverablePlatform("minio-main", "attachment", "https://files/"); + platform.exists = true; + RecoverableStorageService delegate = new RecoverableStorageService(platform); + delegate.recorderDeleteThrows = true; + XFIleStorageServiceImpl service = createService(delegate); + FileStorageWriteHandle handle = new FileStorageWriteHandle( + "xFileStorage", "minio-main", "attachment", "skill-content/ab", "content.bin"); + + service.deleteRecoverable(handle); + + assertFalse(platform.exists); + assertEquals(1, delegate.recorderDeleteCalls); + } + + /** + * 验证平台默认 basePath 切换后,支持 FileInfo.basePath 的对象存储仍按历史句柄删除旧对象。 + * + * @throws Exception 注入测试替身失败 + */ + @Test + public void recoverableDeleteUsesPersistedBasePathAfterConfigurationSwitch() throws Exception { + RecoverablePlatform platform = new RecoverablePlatform("minio-main", "old-root", "https://files/"); + RecoverableStorageService delegate = new RecoverableStorageService(platform); + XFIleStorageServiceImpl service = createService(delegate); + FileStorageWriteHandle handle = service.prepareRecoverableWrite("skill-content/ab", "content.bin"); + platform.basePath = "new-root"; + platform.exists = true; + + service.deleteRecoverable(handle); + + assertEquals("old-root/skill-content/ab/content.bin", platform.lastDeletedKey); + assertFalse(platform.exists); + } + + /** + * 验证上传前 basePath 已切换时 fail-fast,避免把预留 locator 写向新目录。 + * + * @throws Exception 注入测试替身失败 + */ + @Test + public void recoverableSaveRejectsBasePathSwitchBeforeUpload() throws Exception { + RecoverablePlatform platform = new RecoverablePlatform("minio-main", "old-root", "https://files/"); + RecoverableStorageService delegate = new RecoverableStorageService(platform); + XFIleStorageServiceImpl service = createService(delegate); + FileStorageWriteHandle handle = service.prepareRecoverableWrite("skill-content/ab", "content.bin"); + platform.basePath = "new-root"; + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> service.saveRecoverable(new BytesMultipartFile(new byte[]{1}), handle)); + + assertTrue(exception.getMessage().contains("基础路径已变化")); + assertNull(delegate.uploadPlatform); + } + + /** + * 验证未公开 getBasePath 的 x-file-storage 平台在 prepare 阶段立即失败。 + * + * @throws Exception 注入测试替身失败 + */ + @Test + public void recoverablePrepareFailsWhenPlatformDoesNotExposeBasePath() throws Exception { + RecoverableStorageService delegate = new RecoverableStorageService(new NoBasePathPlatform("custom")); + XFIleStorageServiceImpl service = createService(delegate); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> service.prepareRecoverableWrite("skill-content", "content.bin")); + + assertTrue(exception.getMessage().contains("getBasePath")); + } + + /** + * 创建注入指定底层存储替身的服务。 + * + * @param delegate 底层存储替身 + * @return 待测试服务 + * @throws Exception 反射注入失败 + */ + private XFIleStorageServiceImpl createService( + org.dromara.x.file.storage.core.FileStorageService delegate) throws Exception { + XFIleStorageServiceImpl service = new XFIleStorageServiceImpl(); + Field field = XFIleStorageServiceImpl.class.getDeclaredField("fileStorageService"); + field.setAccessible(true); + field.set(service, delegate); + return service; + } + + /** + * 支持精确物理操作的 x-file-storage 平台测试替身。 + */ + public static final class RecoverablePlatform implements FileStorage { + /** 平台名称。 */ + private String platform; + /** 基础路径。 */ + private String basePath; + /** URL 域名前缀。 */ + private final String domain; + /** 物理存在状态。 */ + private boolean exists; + /** 删除是否成功。 */ + private boolean deleteSucceeds = true; + /** 删除调用次数。 */ + private int deleteCalls; + /** 最后删除的完整对象 key。 */ + private String lastDeletedKey; + + /** + * 创建平台替身。 + * + * @param platform 平台名 + * @param basePath 基础路径 + * @param domain URL 域名前缀 + */ + public RecoverablePlatform(String platform, String basePath, String domain) { + this.platform = platform; + this.basePath = basePath; + this.domain = domain; + } + + /** + * 获取公开基础路径。 + * + * @return 基础路径 + */ + public String getBasePath() { return basePath; } + + /** + * 获取公开 URL 域名前缀。 + * + * @return 域名前缀 + */ + public String getDomain() { return domain; } + + /** {@inheritDoc} */ + @Override public String getPlatform() { return platform; } + /** {@inheritDoc} */ + @Override public void setPlatform(String platform) { this.platform = platform; } + /** {@inheritDoc} */ + @Override public boolean save(FileInfo fileInfo, UploadPretreatment pre) { exists = true; return true; } + + /** {@inheritDoc} */ + @Override + public boolean delete(FileInfo fileInfo) { + deleteCalls++; + lastDeletedKey = getFileKey(fileInfo); + if (deleteSucceeds) { + exists = false; + } + return deleteSucceeds; + } + + /** {@inheritDoc} */ + @Override public boolean exists(FileInfo fileInfo) { return exists; } + /** {@inheritDoc} */ + @Override public void download(FileInfo fileInfo, Consumer consumer) { } + /** {@inheritDoc} */ + @Override public void downloadTh(FileInfo fileInfo, Consumer consumer) { } + } + + /** + * 不公开基础路径的平台替身。 + */ + private static final class NoBasePathPlatform implements FileStorage { + /** 平台名。 */ + private String platform; + + /** + * 创建平台替身。 + * + * @param platform 平台名 + */ + private NoBasePathPlatform(String platform) { this.platform = platform; } + + /** {@inheritDoc} */ + @Override public String getPlatform() { return platform; } + /** {@inheritDoc} */ + @Override public void setPlatform(String platform) { this.platform = platform; } + /** {@inheritDoc} */ + @Override public boolean save(FileInfo fileInfo, UploadPretreatment pre) { return true; } + /** {@inheritDoc} */ + @Override public boolean delete(FileInfo fileInfo) { return true; } + /** {@inheritDoc} */ + @Override public boolean exists(FileInfo fileInfo) { return false; } + /** {@inheritDoc} */ + @Override public void download(FileInfo fileInfo, Consumer consumer) { } + /** {@inheritDoc} */ + @Override public void downloadTh(FileInfo fileInfo, Consumer consumer) { } + } + + /** + * 可捕获固定上传参数并提供具体平台的聚合服务替身。 + */ + private static final class RecoverableStorageService + extends org.dromara.x.file.storage.core.FileStorageService { + /** 具体平台。 */ + private final FileStorage platform; + /** 上传平台。 */ + private String uploadPlatform; + /** 上传路径。 */ + private String uploadPath; + /** 上传文件名。 */ + private String uploadFilename; + /** recorder 删除调用次数。 */ + private int recorderDeleteCalls; + /** recorder 删除是否抛出异常。 */ + private boolean recorderDeleteThrows; + + /** + * 创建聚合服务替身。 + * + * @param platform 具体平台 + */ + private RecoverableStorageService(FileStorage platform) { + this.platform = platform; + setFileRecorder(new FileRecorder() { + @Override public boolean save(FileInfo fileInfo) { return true; } + @Override public void update(FileInfo fileInfo) { } + @Override public FileInfo getByUrl(String url) { return null; } + @Override public boolean delete(String url) { + recorderDeleteCalls++; + if (recorderDeleteThrows) { + throw new IllegalStateException("recorder unavailable"); + } + return false; + } + @Override public void saveFilePart(org.dromara.x.file.storage.core.upload.FilePartInfo filePartInfo) { } + @Override public void deleteFilePartByUploadId(String uploadId) { } + }); + } + + /** {@inheritDoc} */ + @SuppressWarnings("unchecked") + @Override public T getFileStorage() { return (T) platform; } + + /** {@inheritDoc} */ + @SuppressWarnings("unchecked") + @Override + public T getFileStorage(String name) { + return platform.getPlatform().equals(name) ? (T) platform : null; + } + + /** {@inheritDoc} */ + @Override + public org.dromara.x.file.storage.core.upload.UploadPretreatment of(Object file) { + return new CapturingUploadPretreatment(this); + } + } + + /** + * 不访问真实网络、仅捕获上传参数的预处理器。 + */ + private static final class CapturingUploadPretreatment + extends org.dromara.x.file.storage.core.upload.UploadPretreatment { + /** 所属聚合服务替身。 */ + private final RecoverableStorageService delegate; + + /** + * 创建捕获预处理器。 + * + * @param delegate 聚合服务替身 + */ + private CapturingUploadPretreatment(RecoverableStorageService delegate) { + this.delegate = delegate; + } + + /** + * 测试替身不创建 FileWrapper,仅保持生产链式调用兼容。 + * + * @param contentType 文件媒体类型 + * @return 当前预处理器 + */ + @Override + public org.dromara.x.file.storage.core.upload.UploadPretreatment setContentType(String contentType) { + return this; + } + + /** {@inheritDoc} */ + @Override + public FileInfo upload() { + delegate.uploadPlatform = getPlatform(); + delegate.uploadPath = getPath(); + delegate.uploadFilename = getSaveFilename(); + RecoverablePlatform platform = (RecoverablePlatform) delegate.platform; + platform.exists = true; + return new FileInfo() + .setPlatform(getPlatform()) + .setBasePath(platform.getBasePath()) + .setPath(getPath()) + .setFilename(getSaveFilename()) + .setUrl(platform.getDomain() + platform.getBasePath() + getPath() + getSaveFilename()); + } + } + + /** + * 基于字节数组的 MultipartFile 测试替身。 + */ + private static final class BytesMultipartFile implements MultipartFile { + /** 文件内容。 */ + private final byte[] bytes; + + /** + * 创建上传文件替身。 + * + * @param bytes 文件内容 + */ + private BytesMultipartFile(byte[] bytes) { this.bytes = bytes.clone(); } + + /** {@inheritDoc} */ + @Override public String getName() { return "file"; } + /** {@inheritDoc} */ + @Override public String getOriginalFilename() { return "content.bin"; } + /** {@inheritDoc} */ + @Override public String getContentType() { return "application/octet-stream"; } + /** {@inheritDoc} */ + @Override public boolean isEmpty() { return bytes.length == 0; } + /** {@inheritDoc} */ + @Override public long getSize() { return bytes.length; } + /** {@inheritDoc} */ + @Override public byte[] getBytes() { return bytes.clone(); } + /** {@inheritDoc} */ + @Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); } + /** {@inheritDoc} */ + @Override public void transferTo(File dest) throws IOException { Files.write(dest.toPath(), bytes); } + } + + /** + * 可控制删除结果的 x-file-storage 测试替身。 + */ + private static final class DeleteResultStorageService + extends org.dromara.x.file.storage.core.FileStorageService { + + private final boolean deleteResult; + private final boolean exists; + private boolean recordExists; + private String lastPath; + + /** + * 创建测试替身。 + * + * @param deleteResult 删除返回值 + * @param exists 物理文件是否存在 + * @param recordExists 是否存在文件记录 + */ + private DeleteResultStorageService(boolean deleteResult, boolean exists, boolean recordExists) { + this.deleteResult = deleteResult; + this.exists = exists; + this.recordExists = recordExists; + setFileRecorder(new FileRecorder() { + @Override public boolean save(FileInfo fileInfo) { return true; } + @Override public void update(FileInfo fileInfo) { } + @Override public FileInfo getByUrl(String url) { + return DeleteResultStorageService.this.recordExists ? new FileInfo() : null; + } + @Override public boolean delete(String url) { + boolean previous = DeleteResultStorageService.this.recordExists; + DeleteResultStorageService.this.recordExists = false; + return previous; + } + @Override public void saveFilePart(org.dromara.x.file.storage.core.upload.FilePartInfo filePartInfo) { } + @Override public void deleteFilePartByUploadId(String uploadId) { } + }); + } + + /** + * 返回预设删除结果并记录路径。 + * + * @param path 删除路径 + * @return 预设结果 + */ + @Override + public boolean delete(String path) { + lastPath = path; + return deleteResult; + } + + /** + * 返回预设物理存在状态。 + * + * @param path 文件路径 + * @return 预设存在状态 + */ + @Override + public boolean exists(String path) { + return exists; + } + + /** + * 返回测试文件记录。 + * + * @param url 文件 URL + * @return 记录存在时返回 FileInfo + */ + @Override + public FileInfo getFileInfoByUrl(String url) { + return recordExists ? new FileInfo() : null; + } + + /** + * 获取最后一次删除路径。 + * + * @return 删除路径 + */ + private String getLastPath() { + return lastPath; + } + + /** + * 判断测试文件记录是否仍存在。 + * + * @return 存在时返回 true + */ + private boolean hasRecord() { + return recordExists; + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/permission/McpAccessPermissionChecker.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/permission/McpAccessPermissionChecker.java new file mode 100644 index 00000000..fe369356 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/permission/McpAccessPermissionChecker.java @@ -0,0 +1,41 @@ +package tech.easyflow.ai.permission; + +import cn.dev33.satoken.stp.StpUtil; +import org.springframework.stereotype.Component; +import tech.easyflow.common.web.exceptions.BusinessException; + +/** + * MCP 查询与使用权限检查器。 + * + *

MCP 当前没有独立的资源级 {@code USE} 权限,平台沿用 MCP 管理模块已有的 + * {@code /api/v1/mcp/query} 权限作为查看、选择和使用 MCP 的授权边界。

+ */ +@Component +public class McpAccessPermissionChecker { + + /** MCP 模块现有查询权限码。 */ + public static final String MCP_QUERY_PERMISSION = "/api/v1/mcp/query"; + + /** + * 判断当前登录用户是否可以查询和使用 MCP。 + * + * @return 已登录且拥有 MCP 查询权限时返回 {@code true} + */ + public boolean canUseMcp() { + return StpUtil.isLogin() && StpUtil.hasPermission(MCP_QUERY_PERMISSION); + } + + /** + * 校验当前登录用户是否可以查询和使用 MCP。 + * + * @throws BusinessException 未登录或缺少 MCP 查询权限时抛出 + */ + public void assertCanUseMcp() { + if (!StpUtil.isLogin()) { + throw new BusinessException(401, 401, "未登录或登录态无效"); + } + if (!StpUtil.hasPermission(MCP_QUERY_PERMISSION)) { + throw new BusinessException(403, 403, "无权限查询或使用 MCP"); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AbstractAiResourceLifecycleHandler.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AbstractAiResourceLifecycleHandler.java index 197f7d31..37dd1747 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AbstractAiResourceLifecycleHandler.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AbstractAiResourceLifecycleHandler.java @@ -166,6 +166,42 @@ public abstract class AbstractAiResourceLifecycleHandler implements ApprovalS protected void validateDelete(T resource, PublishStatus currentStatus) { } + /** + * 构建删除审批使用的治理快照。 + * + *

默认沿用资源快照;包含敏感配置或需要发布级校验的资源可覆盖此方法, + * 返回不依赖发布可用性的最小治理信息。

+ * + * @param resource 资源 + * @return 删除审批治理快照 + */ + protected Map buildDeleteResourceSnapshot(T resource) { + return buildResourceSnapshot(resource); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean canAccessApprovalDetail(Object identifier) { + if (identifier == null) { + return false; + } + try { + T resource = requireResource(new BigInteger(String.valueOf(identifier))); + assertManagePermission(resource); + return true; + } catch (NumberFormatException exception) { + return false; + } catch (BusinessException exception) { + // 资源不存在或无权管理都按不可见处理;服务端异常仍向上抛出,避免静默掩盖故障。 + if (exception.getHttpStatus() >= 400 && exception.getHttpStatus() < 500) { + return false; + } + throw exception; + } + } + /** * 下线成功后的额外副作用。 * @@ -286,7 +322,7 @@ public abstract class AbstractAiResourceLifecycleHandler implements ApprovalS throw new BusinessException("当前" + resourceLabel() + "存在进行中的审批,请先处理完成"); } validateDelete(resource, currentStatus); - return buildResourceSnapshot(resource); + return buildDeleteResourceSnapshot(resource); } /** diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/permission/McpAccessPermissionCheckerTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/permission/McpAccessPermissionCheckerTest.java new file mode 100644 index 00000000..31d881d2 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/permission/McpAccessPermissionCheckerTest.java @@ -0,0 +1,69 @@ +package tech.easyflow.ai.permission; + +import cn.dev33.satoken.stp.StpUtil; +import org.junit.Test; +import org.mockito.MockedStatic; +import tech.easyflow.common.web.exceptions.BusinessException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mockStatic; + +/** + * {@link McpAccessPermissionChecker} 的现有 MCP RBAC 语义回归测试。 + */ +public class McpAccessPermissionCheckerTest { + + /** + * 未登录调用方必须收到 401。 + */ + @Test + public void unauthenticatedCallerIsRejected() { + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + stpUtil.when(StpUtil::isLogin).thenReturn(false); + + BusinessException exception = assertThrows(BusinessException.class, + () -> new McpAccessPermissionChecker().assertCanUseMcp()); + + assertEquals(401, exception.getHttpStatus()); + assertFalse(new McpAccessPermissionChecker().canUseMcp()); + } + } + + /** + * 已登录但缺少 MCP 查询权限的调用方必须收到 403。 + */ + @Test + public void callerWithoutMcpQueryPermissionIsRejected() { + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + stpUtil.when(StpUtil::isLogin).thenReturn(true); + stpUtil.when(() -> StpUtil.hasPermission(McpAccessPermissionChecker.MCP_QUERY_PERMISSION)) + .thenReturn(false); + + BusinessException exception = assertThrows(BusinessException.class, + () -> new McpAccessPermissionChecker().assertCanUseMcp()); + + assertEquals(403, exception.getHttpStatus()); + assertFalse(new McpAccessPermissionChecker().canUseMcp()); + } + } + + /** + * MCP 查询权限同时授予 MCP 候选查看和绑定使用能力。 + */ + @Test + public void mcpQueryPermissionAllowsUse() { + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + stpUtil.when(StpUtil::isLogin).thenReturn(true); + stpUtil.when(() -> StpUtil.hasPermission(McpAccessPermissionChecker.MCP_QUERY_PERMISSION)) + .thenReturn(true); + McpAccessPermissionChecker checker = new McpAccessPermissionChecker(); + + checker.assertCanUseMcp(); + + assertTrue(checker.canUseMcp()); + } + } +} diff --git a/easyflow-modules/easyflow-module-approval/pom.xml b/easyflow-modules/easyflow-module-approval/pom.xml index 13079569..f7be9143 100644 --- a/easyflow-modules/easyflow-module-approval/pom.xml +++ b/easyflow-modules/easyflow-module-approval/pom.xml @@ -39,5 +39,11 @@ ${junit.version} test + + org.mockito + mockito-core + 5.12.0 + test + diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalInstanceBase.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalInstanceBase.java index 3c2c3fc6..01272cbe 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalInstanceBase.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalInstanceBase.java @@ -20,6 +20,9 @@ public class ApprovalInstanceBase implements Serializable { @Id(keyType = KeyType.Generator, value = "snowFlakeId", comment = "主键") private BigInteger id; + @Column(tenantId = true, comment = "租户ID") + private BigInteger tenantId; + @Column(comment = "流程ID") private BigInteger flowId; @@ -82,6 +85,14 @@ public class ApprovalInstanceBase implements Serializable { this.id = id; } + public BigInteger getTenantId() { + return tenantId; + } + + public void setTenantId(BigInteger tenantId) { + this.tenantId = tenantId; + } + public BigInteger getFlowId() { return flowId; } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalInstancePageVo.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalInstancePageVo.java index 7bc7a018..b0c37385 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalInstancePageVo.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalInstancePageVo.java @@ -31,6 +31,16 @@ public class ApprovalInstancePageVo { private BigInteger applicantId; + /** + * 申请人展示名称。 + */ + private String applicantName; + + /** + * 申请人登录账号。 + */ + private String applicantAccount; + private Date submittedAt; private Date finishedAt; @@ -131,6 +141,42 @@ public class ApprovalInstancePageVo { this.applicantId = applicantId; } + /** + * 获取申请人展示名称。 + * + * @return 申请人展示名称 + */ + public String getApplicantName() { + return applicantName; + } + + /** + * 设置申请人展示名称。 + * + * @param applicantName 申请人展示名称 + */ + public void setApplicantName(String applicantName) { + this.applicantName = applicantName; + } + + /** + * 获取申请人登录账号。 + * + * @return 申请人登录账号 + */ + public String getApplicantAccount() { + return applicantAccount; + } + + /** + * 设置申请人登录账号。 + * + * @param applicantAccount 申请人登录账号 + */ + public void setApplicantAccount(String applicantAccount) { + this.applicantAccount = applicantAccount; + } + public Date getSubmittedAt() { return submittedAt; } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalActionFacade.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalActionFacade.java index 1cff02a0..b573f03b 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalActionFacade.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalActionFacade.java @@ -19,6 +19,15 @@ public interface ApprovalActionFacade { */ ApprovalActionResult submit(ApprovalSubmitRequest request); + /** + * 判断当前登录用户是否经资源处理器授权查看审批详情。 + * + * @param resourceType 资源类型 + * @param identifier 资源标识 + * @return 允许查看时返回 {@code true} + */ + boolean canAccessApprovalDetail(String resourceType, Object identifier); + /** * 处理审批通过后的业务回调。 * diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalSubjectHandler.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalSubjectHandler.java index ef82325f..23defb52 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalSubjectHandler.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalSubjectHandler.java @@ -26,6 +26,17 @@ public interface ApprovalSubjectHandler { */ ApprovalSubmitRequest buildSubmitRequest(BigInteger resourceId, String actionType, BigInteger operatorId); + /** + * 判断当前登录用户是否可通过资源权限查看审批详情。 + * + *

审批申请人和任务处理人的访问由审批模块统一判断;该方法只负责补充资源自身的 + * 授权口径,避免审批详情绕过资源权限系统。

+ * + * @param identifier 资源标识 + * @return 允许查看时返回 {@code true} + */ + boolean canAccessApprovalDetail(Object identifier); + /** * 校验资源是否已发布。 * diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalActionFacadeImpl.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalActionFacadeImpl.java index f9887c44..2c66bff8 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalActionFacadeImpl.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalActionFacadeImpl.java @@ -49,6 +49,15 @@ public class ApprovalActionFacadeImpl implements ApprovalActionFacade { return ApprovalActionResult.required(instanceId); } + /** + * {@inheritDoc} + */ + @Override + public boolean canAccessApprovalDetail(String resourceType, Object identifier) { + ApprovalSubjectHandler handler = getHandler(resourceType); + return handler.canAccessApprovalDetail(identifier); + } + /** * {@inheritDoc} */ diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java index d19ed9cc..72f7e6b7 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java @@ -5,6 +5,8 @@ import com.mybatisflex.core.query.QueryWrapper; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.approval.entity.ApprovalInstance; import tech.easyflow.approval.entity.ApprovalFlowStep; @@ -25,6 +27,8 @@ import tech.easyflow.approval.mapper.ApprovalLogMapper; import tech.easyflow.approval.mapper.ApprovalTaskMapper; import tech.easyflow.approval.service.ApprovalInstanceService; import tech.easyflow.approval.service.ApprovalMatchService; +import tech.easyflow.system.entity.SysAccount; +import tech.easyflow.system.service.SysAccountService; import javax.annotation.Resource; import java.math.BigInteger; @@ -61,6 +65,9 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { @Resource private ApprovalAssigneeService approvalAssigneeService; + @Resource + private SysAccountService sysAccountService; + @Lazy @Resource private ApprovalActionFacade approvalActionFacade; @@ -71,13 +78,24 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { @Override @Transactional(rollbackFor = Exception.class) public BigInteger submitApproval(ApprovalSubmitRequest request) { - ApprovalFlowDetailVo flow = approvalMatchService.matchFlow(request); - if (CollectionUtil.isEmpty(flow.getSteps())) { - throw new BusinessException("审批流程未配置步骤"); + if (request == null) { + throw new BusinessException("审批请求不能为空"); } if (request.getApplicantId() == null) { throw new BusinessException("申请人不能为空"); } + LoginAccount loginAccount = requireCurrentLoginAccount(); + if (!loginAccount.getId().equals(request.getApplicantId())) { + throw new BusinessException(403, 403, "不允许以其他账号身份提交审批"); + } + SysAccount applicant = requireTenantAccount(request.getApplicantId(), "申请人"); + if (!loginAccount.getTenantId().equals(applicant.getTenantId())) { + throw new BusinessException(403, 403, "申请人租户信息与当前登录态不一致"); + } + ApprovalFlowDetailVo flow = approvalMatchService.matchFlow(request); + if (CollectionUtil.isEmpty(flow.getSteps())) { + throw new BusinessException("审批流程未配置步骤"); + } List steps = new ArrayList<>(flow.getSteps()); steps.sort(Comparator.comparing(ApprovalFlowStepVo::getStepNo)); @@ -85,6 +103,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { Date now = new Date(); ApprovalInstance instance = new ApprovalInstance(); + instance.setTenantId(applicant.getTenantId()); instance.setFlowId(flow.getId()); instance.setFlowVersion(flow.getVersion()); instance.setResourceType(flow.getResourceType()); @@ -124,7 +143,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { @Override @Transactional(rollbackFor = Exception.class) public void approve(BigInteger instanceId, String comment, BigInteger operatorId) { - ApprovalInstance instance = requireActiveInstance(instanceId); + ApprovalInstance instance = requireActiveInstance(instanceId, operatorId); ApprovalTask currentTask = requireCurrentTask(instanceId, instance.getCurrentStepNo()); assertTaskOperable(currentTask, operatorId); List steps = resolveFrozenSteps(instance); @@ -161,7 +180,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { @Override @Transactional(rollbackFor = Exception.class) public void reject(BigInteger instanceId, String comment, BigInteger operatorId) { - ApprovalInstance instance = requireActiveInstance(instanceId); + ApprovalInstance instance = requireActiveInstance(instanceId, operatorId); ApprovalTask currentTask = requireCurrentTask(instanceId, instance.getCurrentStepNo()); assertTaskOperable(currentTask, operatorId); Date now = new Date(); @@ -184,7 +203,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { @Override @Transactional(rollbackFor = Exception.class) public void revoke(BigInteger instanceId, String comment, BigInteger operatorId) { - ApprovalInstance instance = requireActiveInstance(instanceId); + ApprovalInstance instance = requireActiveInstance(instanceId, operatorId); // 撤回属于发起人的自助操作,不能沿用审批任务处理人的授权口径。 if (!Objects.equals(instance.getApplicantId(), operatorId)) { throw new BusinessException(403, 403, "仅审批申请人可以撤回该请求"); @@ -209,7 +228,9 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { */ @Override public boolean existsActiveInstance(String resourceType, BigInteger resourceId) { + BigInteger tenantId = requireCurrentTenantId(); QueryWrapper queryWrapper = QueryWrapper.create() + .eq(ApprovalInstance::getTenantId, tenantId) .eq(ApprovalInstance::getResourceType, resourceType) .eq(ApprovalInstance::getResourceId, resourceId) .notIn(ApprovalInstance::getStatus, @@ -224,7 +245,9 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { */ @Override public ApprovalInstance getById(BigInteger instanceId) { - return approvalInstanceMapper.selectOneById(instanceId); + return approvalInstanceMapper.selectOneByQuery(QueryWrapper.create() + .eq(ApprovalInstance::getId, instanceId) + .eq(ApprovalInstance::getTenantId, requireCurrentTenantId())); } private Map buildInstanceSnapshot(ApprovalSubmitRequest request, ApprovalFlowDetailVo flow, @@ -344,15 +367,17 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { approvalLogMapper.insert(log); } - private ApprovalInstance requireActiveInstance(BigInteger instanceId) { + private ApprovalInstance requireActiveInstance(BigInteger instanceId, BigInteger operatorId) { if (instanceId == null) { throw new BusinessException("审批实例ID不能为空"); } - ApprovalInstance instance = approvalInstanceMapper.selectOneByQuery( - QueryWrapper.create().eq(ApprovalInstance::getId, instanceId).forUpdate() - ); + SysAccount operator = requireTenantAccount(operatorId, "操作人"); + ApprovalInstance instance = approvalInstanceMapper.selectOneByQuery(QueryWrapper.create() + .eq(ApprovalInstance::getId, instanceId) + .eq(ApprovalInstance::getTenantId, operator.getTenantId()) + .forUpdate()); if (instance == null) { - throw new BusinessException("审批实例不存在"); + throw new BusinessException(404, 404, "审批实例不存在"); } if (ApprovalInstanceStatus.from(instance.getStatus()).isFinished()) { throw new BusinessException("审批实例已结束,无法继续处理"); @@ -360,6 +385,49 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { return instance; } + /** + * 读取账号及其稳定租户归属。 + * + * @param accountId 账号 ID + * @param accountLabel 账号角色说明 + * @return 有效账号 + * @throws BusinessException 账号不存在或缺少租户归属时抛出 + */ + private SysAccount requireTenantAccount(BigInteger accountId, String accountLabel) { + if (accountId == null) { + throw new BusinessException(accountLabel + "不能为空"); + } + SysAccount account = sysAccountService.getById(accountId); + if (account == null || account.getTenantId() == null) { + throw new BusinessException(403, 403, accountLabel + "不存在或租户信息无效"); + } + return account; + } + + /** + * 获取当前登录账号的租户 ID。 + * + * @return 当前租户 ID + * @throws BusinessException 登录态缺少账号或租户信息时抛出 + */ + private BigInteger requireCurrentTenantId() { + return requireCurrentLoginAccount().getTenantId(); + } + + /** + * 获取完整的当前登录账号。 + * + * @return 当前登录账号 + * @throws BusinessException 登录态缺少账号或租户信息时抛出 + */ + private LoginAccount requireCurrentLoginAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException(401, 401, "未登录或登录态无效"); + } + return account; + } + private ApprovalTask requireCurrentTask(BigInteger instanceId, Integer stepNo) { QueryWrapper queryWrapper = QueryWrapper.create() .eq(ApprovalTask::getInstanceId, instanceId) diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java index 5e9b0d60..d7816976 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java @@ -26,9 +26,11 @@ import tech.easyflow.approval.mapper.ApprovalFlowStepMapper; import tech.easyflow.approval.mapper.ApprovalInstanceMapper; import tech.easyflow.approval.mapper.ApprovalLogMapper; import tech.easyflow.approval.mapper.ApprovalTaskMapper; +import tech.easyflow.approval.service.ApprovalActionFacade; import tech.easyflow.approval.service.ApprovalAssigneeService; import tech.easyflow.approval.service.ApprovalQueryService; import tech.easyflow.system.entity.SysAccount; +import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.SysAccountService; import javax.annotation.Resource; @@ -64,6 +66,12 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService { @Resource private ApprovalAssigneeService approvalAssigneeService; + @Resource + private ApprovalActionFacade approvalActionFacade; + + @Resource + private CategoryPermissionService categoryPermissionService; + @Resource private SysAccountService sysAccountService; @@ -80,6 +88,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService { return new Page<>(List.of(), safePageNumber(pageNumber), safePageSize(pageSize), 0L); } QueryWrapper queryWrapper = buildBaseQuery(resourceType, actionType, keyword); + queryWrapper.eq(ApprovalInstance::getTenantId, account.getTenantId()); queryWrapper.in(ApprovalInstance::getId, instanceIds); queryWrapper.in(ApprovalInstance::getStatus, List.of( ApprovalInstanceStatus.PENDING.getCode(), @@ -109,6 +118,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService { return new Page<>(List.of(), safePageNumber(pageNumber), safePageSize(pageSize), 0L); } QueryWrapper queryWrapper = buildBaseQuery(resourceType, actionType, keyword); + queryWrapper.eq(ApprovalInstance::getTenantId, account.getTenantId()); queryWrapper.in(ApprovalInstance::getId, instanceIds); queryWrapper.orderBy("finished_at desc, id desc"); return mapPage(queryWrapper, safePageNumber(pageNumber), safePageSize(pageSize), false, account, Set.of()); @@ -122,6 +132,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService { Long pageNumber, Long pageSize) { LoginAccount account = requireLoginAccount(); QueryWrapper queryWrapper = buildBaseQuery(resourceType, actionType, keyword); + queryWrapper.eq(ApprovalInstance::getTenantId, account.getTenantId()); queryWrapper.eq(ApprovalInstance::getApplicantId, account.getId()); queryWrapper.orderBy("submitted_at desc, id desc"); return mapPage(queryWrapper, safePageNumber(pageNumber), safePageSize(pageSize), false, account, Set.of()); @@ -132,10 +143,18 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService { */ @Override public ApprovalInstanceDetailVo detail(BigInteger instanceId) { - ApprovalInstance instance = approvalInstanceMapper.selectOneById(instanceId); - if (instance == null) { - throw new BusinessException("审批实例不存在"); + LoginAccount account = requireLoginAccount(); + ApprovalInstance instance = approvalInstanceMapper.selectOneByQuery(QueryWrapper.create() + .eq(ApprovalInstance::getId, instanceId) + .eq(ApprovalInstance::getTenantId, account.getTenantId())); + if (instance == null || !Objects.equals(account.getTenantId(), instance.getTenantId())) { + throw new BusinessException(404, 404, "审批实例不存在"); } + List tasks = approvalTaskMapper.selectListByQuery( + QueryWrapper.create().eq(ApprovalTask::getInstanceId, instanceId)); + Set roleIds = approvalAssigneeService.getAvailableRoleIds(account.getId()); + assertDetailAccess(instance, tasks, account, roleIds); + ApprovalInstanceDetailVo detail = new ApprovalInstanceDetailVo(); detail.setId(instance.getId()); detail.setFlowId(instance.getFlowId()); @@ -152,12 +171,10 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService { detail.setFinishedAt(instance.getFinishedAt()); detail.setSnapshotJson(instance.getSnapshotJson()); - List tasks = approvalTaskMapper.selectListByQuery( - QueryWrapper.create().eq(ApprovalTask::getInstanceId, instanceId)); List logs = approvalLogMapper.selectListByQuery( QueryWrapper.create().eq(ApprovalLog::getInstanceId, instanceId)); Map frozenStepMap = resolveFrozenStepMap(instance); - Map accountMap = loadAccountMap(instance, tasks, logs); + Map accountMap = loadAccountMap(instance, tasks, logs, account.getTenantId()); detail.setApplicantName(resolveAccountName(accountMap.get(instance.getApplicantId()))); detail.setApplicantAccount(resolveAccountLoginName(accountMap.get(instance.getApplicantId()))); @@ -201,8 +218,6 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService { }) .collect(Collectors.toList())); - LoginAccount account = requireLoginAccount(); - Set roleIds = approvalAssigneeService.getAvailableRoleIds(account.getId()); boolean active = !ApprovalInstanceStatus.from(instance.getStatus()).isFinished(); boolean canReview = active && tasks.stream().anyMatch(item -> item.getStepNo().equals(instance.getCurrentStepNo()) @@ -220,10 +235,11 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService { * @param instance 审批实例 * @param tasks 审批任务列表 * @param logs 审批日志列表 + * @param tenantId 当前租户 ID * @return 账号 ID 到账号实体的映射 */ private Map loadAccountMap(ApprovalInstance instance, List tasks, - List logs) { + List logs, BigInteger tenantId) { Set accountIds = new HashSet<>(); if (instance.getApplicantId() != null) { accountIds.add(instance.getApplicantId()); @@ -239,7 +255,9 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService { if (CollectionUtil.isEmpty(accountIds)) { return Map.of(); } - return sysAccountService.listByIds(accountIds).stream() + return sysAccountService.list(QueryWrapper.create() + .in(SysAccount::getId, accountIds) + .eq(SysAccount::getTenantId, tenantId)).stream() .collect(Collectors.toMap( SysAccount::getId, account -> account, @@ -297,6 +315,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService { boolean pendingMode, LoginAccount account, Set roleIds) { Page page = approvalInstanceMapper.paginate(pageNumber, pageSize, queryWrapper); List records = page.getRecords(); + Map applicantAccountMap = loadApplicantAccountMap(records, account.getTenantId()); Set pendingTaskInstanceIds = pendingMode ? approvalAssigneeService.listPendingInstanceIds(account.getId(), roleIds, records.stream().map(ApprovalInstance::getId).collect(Collectors.toList())) @@ -315,6 +334,9 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService { item.setSummary(record.getSummary()); item.setApplicationReason(record.getApplicationReason()); item.setApplicantId(record.getApplicantId()); + SysAccount applicantAccount = applicantAccountMap.get(record.getApplicantId()); + item.setApplicantName(resolveAccountName(applicantAccount)); + item.setApplicantAccount(resolveAccountLoginName(applicantAccount)); item.setSubmittedAt(record.getSubmittedAt()); item.setFinishedAt(record.getFinishedAt()); boolean active = !ApprovalInstanceStatus.from(record.getStatus()).isFinished(); @@ -332,6 +354,31 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService { return voPage; } + /** + * 批量加载分页记录中的申请人账号,避免列表逐行查询。 + * + * @param records 审批实例分页记录 + * @param tenantId 当前租户 ID + * @return 申请人 ID 到账号实体的映射 + */ + private Map loadApplicantAccountMap(List records, BigInteger tenantId) { + Set applicantIds = records.stream() + .map(ApprovalInstance::getApplicantId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (CollectionUtil.isEmpty(applicantIds)) { + return Map.of(); + } + return sysAccountService.list(QueryWrapper.create() + .in(SysAccount::getId, applicantIds) + .eq(SysAccount::getTenantId, tenantId)).stream() + .collect(Collectors.toMap( + SysAccount::getId, + account -> account, + (left, right) -> left, + LinkedHashMap::new)); + } + private long safePageNumber(Long pageNumber) { return pageNumber == null || pageNumber < 1 ? 1L : pageNumber; } @@ -342,12 +389,39 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService { private LoginAccount requireLoginAccount() { LoginAccount account = SaTokenUtil.getLoginAccount(); - if (account == null) { + if (account == null || account.getId() == null || account.getTenantId() == null) { throw new BusinessException("当前未登录"); } return account; } + /** + * 校验审批详情的主体权限。 + * + * @param instance 审批实例 + * @param tasks 审批任务 + * @param account 当前账号 + * @param roleIds 当前账号的有效角色 ID + * @throws BusinessException 当前用户不是申请人、处理人、同租户超管或资源授权者时抛出 + */ + private void assertDetailAccess(ApprovalInstance instance, + List tasks, + LoginAccount account, + Set roleIds) { + if (account.getId().equals(instance.getApplicantId()) + || categoryPermissionService.isSuperAdmin(account)) { + return; + } + boolean taskParticipant = tasks.stream().anyMatch(task -> account.getId().equals(task.getActedBy()) + || ApprovalTaskStatus.PENDING.getCode().equals(task.getStatus()) + && approvalAssigneeService.canHandleTask(task, account.getId(), roleIds)); + if (taskParticipant + || approvalActionFacade.canAccessApprovalDetail(instance.getResourceType(), instance.getResourceId())) { + return; + } + throw new BusinessException(403, 403, "无权限查看该审批实例"); + } + private String resolveCurrentStepName(ApprovalInstance instance) { Map stepMap = resolveFrozenStepMap(instance); return resolveStepName(stepMap, instance.getCurrentStepNo()); diff --git a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImplAccessTest.java b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImplAccessTest.java new file mode 100644 index 00000000..ebe3282b --- /dev/null +++ b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImplAccessTest.java @@ -0,0 +1,158 @@ +package tech.easyflow.approval.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.MockitoJUnitRunner; +import tech.easyflow.approval.entity.ApprovalInstance; +import tech.easyflow.approval.entity.ApprovalLog; +import tech.easyflow.approval.entity.ApprovalTask; +import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest; +import tech.easyflow.approval.enums.ApprovalInstanceStatus; +import tech.easyflow.approval.enums.ApprovalTaskStatus; +import tech.easyflow.approval.mapper.ApprovalInstanceMapper; +import tech.easyflow.approval.mapper.ApprovalLogMapper; +import tech.easyflow.approval.mapper.ApprovalTaskMapper; +import tech.easyflow.approval.service.ApprovalActionFacade; +import tech.easyflow.approval.service.ApprovalMatchService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.SysAccount; +import tech.easyflow.system.service.SysAccountService; + +import java.math.BigInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link ApprovalInstanceServiceImpl} 审批提交身份授权测试。 + */ +@RunWith(MockitoJUnitRunner.class) +public class ApprovalInstanceServiceImplAccessTest { + + @Mock + private ApprovalMatchService approvalMatchService; + + @Mock + private SysAccountService sysAccountService; + + @Mock + private ApprovalInstanceMapper approvalInstanceMapper; + + @Mock + private ApprovalTaskMapper approvalTaskMapper; + + @Mock + private ApprovalLogMapper approvalLogMapper; + + @Mock + private ApprovalActionFacade approvalActionFacade; + + @InjectMocks + private ApprovalInstanceServiceImpl service; + + /** + * 验证同租户账号也不能代替当前登录人发起审批。 + */ + @Test + public void submitApprovalShouldRejectForgedApplicantBeforeMatchingFlow() { + LoginAccount loginAccount = new LoginAccount(); + loginAccount.setId(BigInteger.ONE); + loginAccount.setTenantId(BigInteger.valueOf(42)); + ApprovalSubmitRequest request = new ApprovalSubmitRequest(); + request.setApplicantId(BigInteger.TWO); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount); + BusinessException exception = assertThrows(BusinessException.class, + () -> service.submitApproval(request)); + assertEquals(403, exception.getHttpStatus()); + } + + verify(sysAccountService, never()).getById(BigInteger.TWO); + verify(approvalMatchService, never()).matchFlow(request); + } + + /** + * 验证申请人可撤回进行中的审批,并同步结束当前任务与恢复资源状态。 + */ + @Test + public void revokeShouldCompleteCurrentTaskForApplicant() { + BigInteger applicantId = BigInteger.valueOf(7); + BigInteger tenantId = BigInteger.valueOf(42); + BigInteger instanceId = BigInteger.valueOf(101); + SysAccount applicant = tenantAccount(applicantId, tenantId); + ApprovalInstance instance = activeInstance(instanceId, applicantId, tenantId); + ApprovalTask task = new ApprovalTask(); + task.setInstanceId(instanceId); + task.setStepNo(1); + task.setStatus(ApprovalTaskStatus.PENDING.getCode()); + + when(sysAccountService.getById(applicantId)).thenReturn(applicant); + when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance); + when(approvalTaskMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(task); + + service.revoke(instanceId, "内容需要调整", applicantId); + + assertEquals(ApprovalInstanceStatus.REVOKED.getCode(), instance.getStatus()); + assertNotNull(instance.getFinishedAt()); + assertEquals(ApprovalTaskStatus.REVOKED.getCode(), task.getStatus()); + assertEquals(applicantId, task.getActedBy()); + assertEquals("内容需要调整", task.getComment()); + verify(approvalLogMapper).insert(any(ApprovalLog.class)); + verify(approvalActionFacade).handleRevoked(instance, applicantId, "内容需要调整"); + } + + /** + * 验证非申请人即使属于同一租户也不能撤回审批。 + */ + @Test + public void revokeShouldRejectSameTenantNonApplicant() { + BigInteger applicantId = BigInteger.valueOf(7); + BigInteger operatorId = BigInteger.valueOf(8); + BigInteger tenantId = BigInteger.valueOf(42); + BigInteger instanceId = BigInteger.valueOf(101); + + when(sysAccountService.getById(operatorId)).thenReturn(tenantAccount(operatorId, tenantId)); + when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))) + .thenReturn(activeInstance(instanceId, applicantId, tenantId)); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> service.revoke(instanceId, "尝试撤回", operatorId) + ); + + assertEquals(403, exception.getHttpStatus()); + verify(approvalTaskMapper, never()).selectOneByQuery(any(QueryWrapper.class)); + verify(approvalActionFacade, never()) + .handleRevoked(any(ApprovalInstance.class), any(BigInteger.class), any(String.class)); + } + + private ApprovalInstance activeInstance(BigInteger instanceId, BigInteger applicantId, BigInteger tenantId) { + ApprovalInstance instance = new ApprovalInstance(); + instance.setId(instanceId); + instance.setApplicantId(applicantId); + instance.setTenantId(tenantId); + instance.setCurrentStepNo(1); + instance.setStatus(ApprovalInstanceStatus.PENDING.getCode()); + return instance; + } + + private SysAccount tenantAccount(BigInteger accountId, BigInteger tenantId) { + SysAccount account = new SysAccount(); + account.setId(accountId); + account.setTenantId(tenantId); + return account; + } +} diff --git a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImplConcurrencyTest.java b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImplConcurrencyTest.java new file mode 100644 index 00000000..dfc3c00a --- /dev/null +++ b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImplConcurrencyTest.java @@ -0,0 +1,72 @@ +package tech.easyflow.approval.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import tech.easyflow.approval.entity.ApprovalInstance; +import tech.easyflow.approval.enums.ApprovalInstanceStatus; +import tech.easyflow.approval.mapper.ApprovalInstanceMapper; +import tech.easyflow.approval.mapper.ApprovalTaskMapper; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.SysAccount; +import tech.easyflow.system.service.SysAccountService; + +import java.math.BigInteger; +import java.util.Locale; + +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link ApprovalInstanceServiceImpl} 审批决策并发互斥测试。 + */ +@RunWith(MockitoJUnitRunner.class) +public class ApprovalInstanceServiceImplConcurrencyTest { + + @Mock + private ApprovalInstanceMapper approvalInstanceMapper; + @Mock + private ApprovalTaskMapper approvalTaskMapper; + @Mock + private SysAccountService sysAccountService; + @InjectMocks + private ApprovalInstanceServiceImpl service; + + /** + * 审批实例与当前任务必须在状态判断前加行锁,避免重复执行同一决策。 + */ + @Test + public void approvalDecisionLocksInstanceAndCurrentTask() { + BigInteger instanceId = BigInteger.valueOf(101); + BigInteger tenantId = BigInteger.valueOf(42); + ApprovalInstance instance = new ApprovalInstance(); + instance.setId(instanceId); + instance.setTenantId(tenantId); + instance.setStatus(ApprovalInstanceStatus.PENDING.getCode()); + instance.setCurrentStepNo(1); + SysAccount operator = new SysAccount(); + operator.setId(BigInteger.ONE); + operator.setTenantId(tenantId); + when(sysAccountService.getById(BigInteger.ONE)).thenReturn(operator); + when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance); + when(approvalTaskMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null); + + assertThrows(BusinessException.class, + () -> service.approve(instanceId, "通过", BigInteger.ONE)); + + ArgumentCaptor instanceQuery = ArgumentCaptor.forClass(QueryWrapper.class); + ArgumentCaptor taskQuery = ArgumentCaptor.forClass(QueryWrapper.class); + verify(approvalInstanceMapper).selectOneByQuery(instanceQuery.capture()); + verify(approvalTaskMapper).selectOneByQuery(taskQuery.capture()); + assertTrue(instanceQuery.getValue().toSQL().toLowerCase(Locale.ROOT).contains("for update")); + assertTrue(instanceQuery.getValue().toSQL().toLowerCase(Locale.ROOT).contains("tenant_id")); + assertTrue(taskQuery.getValue().toSQL().toLowerCase(Locale.ROOT).contains("for update")); + } +} diff --git a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceTenantMigrationContractTest.java b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceTenantMigrationContractTest.java new file mode 100644 index 00000000..50637c47 --- /dev/null +++ b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceTenantMigrationContractTest.java @@ -0,0 +1,55 @@ +package tech.easyflow.approval.service.impl; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; + +/** + * V32 审批实例租户迁移契约测试。 + */ +public class ApprovalInstanceTenantMigrationContractTest { + + /** + * 验证历史实例从申请人账号回填租户,且空租户会阻断迁移。 + * + * @throws Exception 迁移文件不可读时抛出 + */ + @Test + public void migrationShouldBackfillAndGuardApprovalTenant() throws Exception { + String sql = migrationSql(); + + assertTrue(sql.contains("ADD COLUMN `tenant_id` BIGINT UNSIGNED NULL")); + assertTrue(sql.contains("LEFT JOIN `tb_sys_account` applicant ON applicant.`id` = approval.`applicant_id`")); + assertTrue(sql.contains("applicant.`id` IS NULL OR applicant.`tenant_id` IS NULL")); + assertTrue(sql.contains("JOIN `tb_sys_account` applicant ON applicant.`id` = approval.`applicant_id`")); + assertTrue(sql.contains("SET approval.`tenant_id` = applicant.`tenant_id`")); + assertTrue(sql.contains("tmp_approval_instance_tenant_guard")); + assertTrue(sql.indexOf("tmp_approval_instance_tenant_guard") < sql.indexOf("ADD COLUMN `tenant_id`")); + assertTrue(sql.contains("MODIFY COLUMN `tenant_id` BIGINT UNSIGNED NOT NULL")); + assertTrue(sql.contains("`tenant_id`, `status`, `submitted_at`")); + } + + /** + * 读取工作区中的 V32 MySQL 迁移。 + * + * @return 迁移 SQL + * @throws Exception 迁移文件不存在或不可读时抛出 + */ + private String migrationSql() throws Exception { + Path root = Path.of(System.getProperty("maven.multiModuleProjectDirectory", + Path.of(System.getProperty("user.dir")).toAbsolutePath().toString())); + while (root != null) { + Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/" + + "db/migration/mysql/V32__mysql_approval_instance_tenant.sql"); + if (Files.isRegularFile(migration)) { + return Files.readString(migration, StandardCharsets.UTF_8); + } + root = root.getParent(); + } + throw new IllegalStateException("找不到 V32 审批实例租户迁移"); + } +} diff --git a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImplAccessTest.java b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImplAccessTest.java new file mode 100644 index 00000000..ad79f5f2 --- /dev/null +++ b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImplAccessTest.java @@ -0,0 +1,283 @@ +package tech.easyflow.approval.service.impl; + +import com.mybatisflex.core.paginate.Page; +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.MockitoJUnitRunner; +import tech.easyflow.approval.entity.ApprovalInstance; +import tech.easyflow.approval.entity.ApprovalLog; +import tech.easyflow.approval.entity.ApprovalTask; +import tech.easyflow.approval.entity.vo.ApprovalInstanceDetailVo; +import tech.easyflow.approval.entity.vo.ApprovalInstancePageVo; +import tech.easyflow.approval.enums.ApprovalAssigneeType; +import tech.easyflow.approval.enums.ApprovalEventType; +import tech.easyflow.approval.enums.ApprovalInstanceStatus; +import tech.easyflow.approval.enums.ApprovalTaskStatus; +import tech.easyflow.approval.mapper.ApprovalFlowStepMapper; +import tech.easyflow.approval.mapper.ApprovalInstanceMapper; +import tech.easyflow.approval.mapper.ApprovalLogMapper; +import tech.easyflow.approval.mapper.ApprovalTaskMapper; +import tech.easyflow.approval.service.ApprovalActionFacade; +import tech.easyflow.approval.service.ApprovalAssigneeService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.SysAccount; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.SysAccountService; + +import java.math.BigInteger; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mockStatic; + +/** + * {@link ApprovalQueryServiceImpl} 审批详情租户和主体授权回归测试。 + */ +@RunWith(MockitoJUnitRunner.class) +public class ApprovalQueryServiceImplAccessTest { + + private static final BigInteger INSTANCE_ID = BigInteger.valueOf(101); + private static final BigInteger RESOURCE_ID = BigInteger.valueOf(501); + private static final BigInteger TENANT_ID = BigInteger.valueOf(42); + + @Mock + private ApprovalInstanceMapper approvalInstanceMapper; + @Mock + private ApprovalTaskMapper approvalTaskMapper; + @Mock + private ApprovalLogMapper approvalLogMapper; + @Mock + private ApprovalFlowStepMapper approvalFlowStepMapper; + @Mock + private ApprovalAssigneeService approvalAssigneeService; + @Mock + private ApprovalActionFacade approvalActionFacade; + @Mock + private CategoryPermissionService categoryPermissionService; + @Mock + private SysAccountService sysAccountService; + @InjectMocks + private ApprovalQueryServiceImpl service; + + /** + * 验证详情查询显式带租户条件,并拒绝 Mapper 异常返回的跨租户实例。 + */ + @Test + public void detailShouldRejectCrossTenantInstanceBeforeReadingSnapshot() { + LoginAccount account = account(7, 42); + ApprovalInstance instance = instance(99, 99); + when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + BusinessException exception = assertThrows(BusinessException.class, + () -> service.detail(INSTANCE_ID)); + assertEquals(404, exception.getHttpStatus()); + } + + ArgumentCaptor query = ArgumentCaptor.forClass(QueryWrapper.class); + verify(approvalInstanceMapper).selectOneByQuery(query.capture()); + assertTrue(query.getValue().toSQL().toLowerCase(Locale.ROOT).contains("tenant_id")); + verify(approvalTaskMapper, never()).selectListByQuery(any(QueryWrapper.class)); + verify(approvalLogMapper, never()).selectListByQuery(any(QueryWrapper.class)); + } + + /** + * 验证同租户普通用户不能仅凭审批查询操作权限读取完整资源快照。 + */ + @Test + public void detailShouldRejectSameTenantNonParticipant() { + LoginAccount account = account(7, 42); + ApprovalInstance instance = instance(8, 42); + when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance); + when(approvalTaskMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of()); + when(approvalAssigneeService.getAvailableRoleIds(account.getId())).thenReturn(Set.of()); + when(approvalActionFacade.canAccessApprovalDetail("SKILL", RESOURCE_ID)).thenReturn(false); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + BusinessException exception = assertThrows(BusinessException.class, + () -> service.detail(INSTANCE_ID)); + assertEquals(403, exception.getHttpStatus()); + } + + verify(approvalLogMapper, never()).selectListByQuery(any(QueryWrapper.class)); + } + + /** + * 验证申请人仍可读取自己发起的审批快照。 + */ + @Test + public void detailShouldAllowApplicant() { + LoginAccount account = account(7, 42); + ApprovalInstance instance = instance(7, 42); + Map snapshot = instance.getSnapshotJson(); + stubAuthorizedDetail(instance, account, List.of()); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + ApprovalInstanceDetailVo detail = service.detail(INSTANCE_ID); + assertSame(snapshot, detail.getSnapshotJson()); + assertFalse(detail.isCanApprove()); + assertFalse(detail.isCanReject()); + assertTrue(detail.isCanRevoke()); + } + } + + /** + * 验证当前待办处理人可查看审批详情。 + */ + @Test + public void detailShouldAllowCurrentTaskHandler() { + LoginAccount account = account(7, 42); + ApprovalInstance instance = instance(8, 42); + ApprovalTask task = task(ApprovalTaskStatus.PENDING.getCode(), null); + task.setAssigneeType(ApprovalAssigneeType.USER.getCode()); + task.setAssigneeTargetId(account.getId()); + stubAuthorizedDetail(instance, account, List.of(task)); + when(approvalAssigneeService.canHandleTask(task, account.getId(), Set.of())).thenReturn(true); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + ApprovalInstanceDetailVo detail = service.detail(INSTANCE_ID); + assertEquals(INSTANCE_ID, detail.getId()); + assertTrue(detail.isCanApprove()); + assertTrue(detail.isCanReject()); + assertFalse(detail.isCanRevoke()); + } + } + + /** + * 验证实际处理过历史步骤的用户仍可查看审批详情。 + */ + @Test + public void detailShouldAllowHistoricalActor() { + LoginAccount account = account(7, 42); + ApprovalInstance instance = instance(8, 42); + ApprovalTask task = task(ApprovalTaskStatus.APPROVED.getCode(), account.getId()); + stubAuthorizedDetail(instance, account, List.of(task)); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + assertEquals(INSTANCE_ID, service.detail(INSTANCE_ID).getId()); + } + } + + /** + * 验证审批说明在详情、审批任务和提交日志中完整透传。 + */ + @Test + public void detailShouldExposeApplicationReasonAcrossRelatedViews() { + LoginAccount account = account(7, 42); + ApprovalInstance instance = instance(7, 42); + instance.setApplicationReason("发布新的问答流程"); + ApprovalTask task = task(ApprovalTaskStatus.PENDING.getCode(), null); + ApprovalLog log = new ApprovalLog(); + log.setEventType(ApprovalEventType.SUBMITTED.getCode()); + stubAuthorizedDetail(instance, account, List.of(task)); + when(approvalLogMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(log)); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + ApprovalInstanceDetailVo detail = service.detail(INSTANCE_ID); + assertEquals("发布新的问答流程", detail.getApplicationReason()); + assertEquals("发布新的问答流程", detail.getTasks().get(0).getApplicationReason()); + assertEquals("发布新的问答流程", detail.getLogs().get(0).getApplicationReason()); + } + } + + /** + * 验证审批分页列表返回申请人填写的审批说明和账号信息。 + */ + @Test + public void initiatedPageShouldExposeApplicationReasonAndApplicant() { + LoginAccount account = account(7, 42); + ApprovalInstance instance = instance(7, 42); + instance.setApplicationReason("发布新的问答流程"); + SysAccount applicant = new SysAccount(); + applicant.setId(account.getId()); + applicant.setNickname("陈子默"); + applicant.setLoginName("czm"); + Page page = new Page<>(List.of(instance), 1L, 10L, 1L); + when(approvalInstanceMapper.paginate(anyLong(), anyLong(), any(QueryWrapper.class))).thenReturn(page); + when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(applicant)); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + ApprovalInstancePageVo item = service.initiatedPage(null, null, null, 1L, 10L) + .getRecords() + .get(0); + assertEquals("发布新的问答流程", item.getApplicationReason()); + assertEquals("陈子默", item.getApplicantName()); + assertEquals("czm", item.getApplicantAccount()); + assertTrue(item.isCanRevoke()); + assertFalse(item.isCanApprove()); + assertFalse(item.isCanReject()); + } + } + + private void stubAuthorizedDetail(ApprovalInstance instance, LoginAccount account, List tasks) { + when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance); + when(approvalTaskMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(tasks); + when(approvalAssigneeService.getAvailableRoleIds(account.getId())).thenReturn(Set.of()); + when(approvalLogMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of()); + when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of()); + } + + private ApprovalInstance instance(long applicantId, long tenantId) { + ApprovalInstance instance = new ApprovalInstance(); + instance.setId(INSTANCE_ID); + instance.setTenantId(BigInteger.valueOf(tenantId)); + instance.setFlowId(BigInteger.valueOf(301)); + instance.setFlowVersion(1); + instance.setResourceType("SKILL"); + instance.setResourceId(RESOURCE_ID); + instance.setActionType("PUBLISH"); + instance.setStatus(ApprovalInstanceStatus.PENDING.getCode()); + instance.setCurrentStepNo(1); + instance.setApplicantId(BigInteger.valueOf(applicantId)); + instance.setSnapshotJson(Map.of( + "resourceSnapshot", Map.of("skillContent", "private prompt"), + "steps", List.of(Map.of( + "stepNo", 1, + "stepName", "审核", + "assigneeType", ApprovalAssigneeType.USER.getCode(), + "assigneeTargetId", 7)))); + return instance; + } + + private ApprovalTask task(String status, BigInteger actedBy) { + ApprovalTask task = new ApprovalTask(); + task.setInstanceId(INSTANCE_ID); + task.setStepNo(1); + task.setStatus(status); + task.setActedBy(actedBy); + return task; + } + + private LoginAccount account(long accountId, long tenantId) { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(accountId)); + account.setTenantId(BigInteger.valueOf(tenantId)); + return account; + } +} diff --git a/easyflow-modules/easyflow-module-skill/pom.xml b/easyflow-modules/easyflow-module-skill/pom.xml index e2538dc2..f75aa3ac 100644 --- a/easyflow-modules/easyflow-module-skill/pom.xml +++ b/easyflow-modules/easyflow-module-skill/pom.xml @@ -37,6 +37,10 @@ tech.easyflow easyflow-common-file-storage + + tech.easyflow + easyflow-common-cache + com.mybatis-flex mybatis-flex-spring-boot3-starter @@ -49,11 +53,26 @@ org.springframework.boot spring-boot-starter-web + + org.apache.commons + commons-compress + junit junit ${junit.version} test + + org.mockito + mockito-core + 5.12.0 + test + + + com.mysql + mysql-connector-j + test + diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityBindingService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityBindingService.java new file mode 100644 index 00000000..72b86623 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityBindingService.java @@ -0,0 +1,124 @@ +package tech.easyflow.skill.capability; + +import com.mybatisflex.core.service.IService; +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.enums.SkillCapabilityType; +import tech.easyflow.skill.validation.SkillValidationResult; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +/** + * Skill 能力绑定业务服务。 + */ +public interface SkillCapabilityBindingService extends IService { + + /** + * 查询当前用户可查看的 Skill 能力绑定。 + * + * @param skillId Skill ID + * @return 有序绑定列表 + */ + List listBindings(BigInteger skillId); + + /** + * 查询面向管理端读取接口的安全绑定,并按当前用户 MANAGE 权限隐藏内部目标 ID。 + * + * @param skillId Skill ID + * @return 有序安全绑定列表 + */ + List listVisibleBindings(BigInteger skillId); + + /** + * 原子替换 Skill 能力绑定。 + * + * @param skillId Skill ID + * @param bindings 新绑定列表 + * @return 保存后的绑定列表 + */ + List replaceBindings(BigInteger skillId, List bindings); + + /** + * 按客户端读取到的能力 hash 原子替换绑定,防止多标签页相互覆盖。 + * + * @param skillId Skill ID + * @param bindings 新绑定列表 + * @param expectedCapabilityHash 客户端读取到的能力 hash + * @return 保存后的绑定列表 + */ + List replaceBindings(BigInteger skillId, + List bindings, + String expectedCapabilityHash); + + /** + * 校验待保存或现有绑定。 + * + * @param skillId Skill ID + * @param bindings 可选待校验绑定,为空时校验已保存绑定 + * @param publishValidation 是否执行发布级实时工具解析 + * @return 结构化校验结果 + */ + SkillValidationResult validateBindings(BigInteger skillId, + List bindings, + boolean publishValidation); + + /** + * 校验增强包导入预览中的能力绑定。 + * + *

该入口不读取或写入 Skill 业务数据,也不要求已有 Skill 权限。未映射目标仅保留给 + * 导入映射步骤处理;已经映射的目标仍会校验当前操作者的使用权限和可用状态。

+ * + * @param bindings 从增强包 manifest 还原的能力绑定 + * @return 结构化校验结果 + */ + SkillValidationResult validateImportBindings(List bindings); + + /** + * 查询可绑定能力候选项。 + * + * @param capabilityType 能力类型 + * @param keyword 关键词 + * @return 候选列表 + */ + List listCandidates(SkillCapabilityType capabilityType, String keyword); + + /** + * 按需获取 MCP 工具清单。 + * + * @param targetId MCP ID + * @return MCP 候选详情 + */ + SkillCapabilityCandidate getMcpTools(BigInteger targetId); + + /** + * 构建经过发布级校验的安全快照。 + * + * @param skillId Skill ID + * @return 不含凭据的能力快照 + */ + List> buildPublishSnapshot(BigInteger skillId); + + /** + * 计算当前能力配置 hash。 + * + * @param bindings 能力绑定 + * @return SHA-256 hash + */ + String calculateHash(List bindings); + + /** + * 基于数据库原始绑定计算 hash,不暴露可能被展示边界脱敏的历史配置。 + * + * @param skillId Skill ID + * @return SHA-256 hash + */ + String calculateStoredHash(BigInteger skillId); + + /** + * 删除 Skill 的全部能力绑定。 + * + * @param skillId Skill ID + */ + void removeBySkillId(BigInteger skillId); +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityBindingServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityBindingServiceImpl.java new file mode 100644 index 00000000..146f832f --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityBindingServiceImpl.java @@ -0,0 +1,990 @@ +package tech.easyflow.skill.capability; + +import com.easyagents.skill.util.SkillHashes; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.spring.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tech.easyflow.ai.permission.McpAccessPermissionChecker; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.enums.SkillCapabilityExecutionMode; +import tech.easyflow.skill.enums.SkillCapabilitySelectionMode; +import tech.easyflow.skill.enums.SkillCapabilityType; +import tech.easyflow.skill.mapper.SkillCapabilityBindingMapper; +import tech.easyflow.skill.mapper.SkillMapper; +import tech.easyflow.skill.security.SkillCredentialValueGuard; +import tech.easyflow.skill.security.SkillPortableTargetSanitizer; +import tech.easyflow.skill.security.SkillSensitiveConfigSanitizer; +import tech.easyflow.skill.validation.SkillValidationIssue; +import tech.easyflow.skill.validation.SkillValidationResult; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Skill 能力绑定业务服务实现。 + */ +@Service +public class SkillCapabilityBindingServiceImpl + extends ServiceImpl + implements SkillCapabilityBindingService { + + private static final Pattern RUNTIME_NAME_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_-]{0,63}$"); + private static final Pattern MCP_TOOL_NAME_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_.-]{0,127}$"); + private static final int MAX_BINDINGS = 200; + private static final int MAX_SELECTED_TOOLS = 200; + private static final int MAX_CONFIG_BYTES = 4096; + + private final SkillMapper skillMapper; + private final SkillCapabilityTargetAccessService targetAccessService; + private final McpAccessPermissionChecker mcpAccessPermissionChecker; + private final ResourceAccessService resourceAccessService; + private final ObjectMapper objectMapper; + + /** + * 创建 Skill 能力绑定服务。 + * + * @param skillMapper Skill Mapper + * @param targetAccessService 目标授权服务 + * @param mcpAccessPermissionChecker MCP 查询与使用权限检查器 + * @param resourceAccessService Skill 资源授权服务 + * @param objectMapper JSON 映射器 + */ + public SkillCapabilityBindingServiceImpl(SkillMapper skillMapper, + SkillCapabilityTargetAccessService targetAccessService, + McpAccessPermissionChecker mcpAccessPermissionChecker, + ResourceAccessService resourceAccessService, + ObjectMapper objectMapper) { + this.skillMapper = skillMapper; + this.targetAccessService = targetAccessService; + this.mcpAccessPermissionChecker = mcpAccessPermissionChecker; + this.resourceAccessService = resourceAccessService; + this.objectMapper = objectMapper; + } + + /** + * {@inheritDoc} + */ + @Override + public List listBindings(BigInteger skillId) { + return listBindings(skillId, false); + } + + /** + * {@inheritDoc} + */ + @Override + public List listVisibleBindings(BigInteger skillId) { + return listBindings(skillId, true); + } + + /** + * 查询并填充绑定展示状态,可选按 MANAGE 权限移除内部目标标识。 + * + * @param skillId Skill ID + * @param redactReadOnlyTargets 是否为只读调用方脱敏 + * @return 有序绑定列表 + */ + private List listBindings(BigInteger skillId, boolean redactReadOnlyTargets) { + Skill skill = requireSkill(skillId); + resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看 Skill 能力绑定"); + boolean manageable = !redactReadOnlyTargets + || resourceAccessService.canAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE); + List bindings = listRaw(skillId); + for (SkillCapabilityBinding binding : bindings) { + enrichDisplayStatus(binding); + boolean targetPermissionDenied = "NO_PERMISSION".equals(binding.getTargetStatus()); + if (!manageable || targetPermissionDenied) { + binding.setTargetId(null); + if (targetPermissionDenied) { + // Skill 管理权限不能替代目标能力权限;目标不可读时只保留可删除的绑定外壳。 + binding.setTargetLogicalRef(null); + binding.setTargetName(null); + binding.setSelectedToolNamesJson(List.of()); + binding.setResolvedToolNames(List.of()); + } + } + sanitizeBindingForExposure(binding); + } + return bindings; + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public List replaceBindings(BigInteger skillId, List bindings) { + return replaceBindingsInternal(skillId, bindings, null); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public List replaceBindings(BigInteger skillId, + List bindings, + String expectedCapabilityHash) { + if (expectedCapabilityHash == null || !expectedCapabilityHash.matches("^[a-f0-9]{64}$")) { + throw new BusinessException(409, 4093, "缺少或无效的能力配置版本,请重新加载后再保存"); + } + return replaceBindingsInternal(skillId, bindings, expectedCapabilityHash); + } + + private List replaceBindingsInternal(BigInteger skillId, + List bindings, + String expectedCapabilityHash) { + Skill skill = requireSkill(skillId, true); + resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限管理 Skill 能力绑定"); + if (expectedCapabilityHash != null && !expectedCapabilityHash.equals(skill.getCapabilityHash())) { + throw new BusinessException(409, 4093, "能力配置已被其他操作更新,请重新加载后合并"); + } + List safeBindings = bindings == null ? new ArrayList<>() : new ArrayList<>(bindings); + if (safeBindings.size() > MAX_BINDINGS) { + throw new BusinessException("单个 Skill 最多绑定 " + MAX_BINDINGS + " 项能力"); + } + SkillValidationResult validation = validateInternal(safeBindings, ValidationMode.SAVE); + assertNoErrors(validation); + + QueryWrapper deleteQuery = QueryWrapper.create() + .eq(SkillCapabilityBinding::getTenantId, skill.getTenantId()) + .eq(SkillCapabilityBinding::getSkillId, skillId); + long existingBindingCount = count(deleteQuery); + if (existingBindingCount > 0 && getMapper().deleteByQuery(deleteQuery) != existingBindingCount) { + throw new BusinessException(500, 500, "替换 Skill 能力绑定失败,请稍后重试"); + } + LoginAccount account = requireAccount(); + Date now = new Date(); + for (int index = 0; index < safeBindings.size(); index++) { + SkillCapabilityBinding binding = safeBindings.get(index); + binding.setId(null); + binding.setTenantId(skill.getTenantId()); + binding.setSkillId(skillId); + binding.setSortNo(index); + binding.setCreated(now); + binding.setCreatedBy(account.getId()); + binding.setModified(now); + binding.setModifiedBy(account.getId()); + } + if (!safeBindings.isEmpty()) { + if (!saveBatch(safeBindings)) { + throw new BusinessException(500, 500, "保存 Skill 能力绑定失败,请稍后重试"); + } + } + Skill update = new Skill(); + update.setId(skillId); + update.setCapabilityCount(safeBindings.size()); + update.setCapabilityHash(calculateHash(safeBindings)); + update.setModified(now); + update.setModifiedBy(account.getId()); + QueryWrapper updateQuery = QueryWrapper.create() + .eq(Skill::getId, skillId) + .eq(Skill::getTenantId, skill.getTenantId()); + if (expectedCapabilityHash != null) { + updateQuery.eq(Skill::getCapabilityHash, expectedCapabilityHash); + } + if (skillMapper.updateByQuery(update, updateQuery) != 1) { + if (expectedCapabilityHash != null) { + throw new BusinessException(409, 4093, "能力配置已被其他操作更新,请重新加载后合并"); + } + throw new BusinessException(500, 500, "更新 Skill 能力摘要失败,请稍后重试"); + } + return listBindings(skillId); + } + + /** + * {@inheritDoc} + */ + @Override + public SkillValidationResult validateBindings(BigInteger skillId, + List bindings, + boolean publishValidation) { + Skill skill = requireSkill(skillId); + resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, + bindings == null ? ResourceAction.READ : ResourceAction.MANAGE, + bindings == null ? "无权限校验 Skill 能力绑定" : "无权限校验待保存的 Skill 能力绑定"); + return validateInternal(bindings == null ? listRaw(skillId) : bindings, + publishValidation ? ValidationMode.PUBLISH : ValidationMode.SAVE); + } + + /** + * {@inheritDoc} + */ + @Override + public SkillValidationResult validateImportBindings(List bindings) { + return validateInternal(bindings == null ? List.of() : bindings, ValidationMode.IMPORT_PREVIEW); + } + + /** + * {@inheritDoc} + */ + @Override + public List listCandidates(SkillCapabilityType capabilityType, String keyword) { + return targetAccessService.listCandidates(capabilityType, keyword); + } + + /** + * {@inheritDoc} + */ + @Override + public SkillCapabilityCandidate getMcpTools(BigInteger targetId) { + return targetAccessService.getMcpTools(targetId); + } + + /** + * {@inheritDoc} + */ + @Override + public List> buildPublishSnapshot(BigInteger skillId) { + List bindings = listRaw(skillId); + ValidatedBindings validated = validateInternalWithTargets(bindings, ValidationMode.PUBLISH); + assertNoErrors(validated.result()); + List> snapshots = new ArrayList<>(); + for (int index = 0; index < bindings.size(); index++) { + SkillCapabilityBinding binding = bindings.get(index); + SkillCapabilityType capabilityType = SkillCapabilityType.from(binding.getCapabilityType()); + SkillCapabilityTarget target = Boolean.TRUE.equals(binding.getEnabled()) + ? validated.targetsByIndex().get(index) : null; + Map snapshot = new LinkedHashMap<>(); + snapshot.put("capabilityType", binding.getCapabilityType()); + snapshot.put("runtimeName", binding.getRuntimeName()); + snapshot.put("enabled", binding.getEnabled()); + snapshot.put("selectionMode", binding.getSelectionMode()); + snapshot.put("selectedToolNames", binding.getSelectedToolNamesJson()); + snapshot.put("resolvedToolNames", binding.getResolvedToolNames()); + snapshot.put("executionMode", binding.getExecutionMode()); + snapshot.put("hitlEnabled", binding.getHitlEnabled()); + snapshot.put("hitlConfig", SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson())); + snapshot.put("options", SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson())); + snapshot.put("sortNo", binding.getSortNo()); + if (target != null) { + String targetName = SkillPortableTargetSanitizer.safePortableMetadataOrNull(target.getName()); + String targetRevision = SkillPortableTargetSanitizer.safePortableMetadataOrNull(target.getRevision()); + if (targetName != null) { + snapshot.put("targetName", targetName); + } + snapshot.put("targetLogicalRef", SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved( + capabilityType, target.getLogicalRef())); + if (targetRevision != null) { + snapshot.put("targetRevision", targetRevision); + } + } else { + snapshot.put("targetLogicalRef", SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved( + capabilityType, binding.getTargetLogicalRef())); + } + assertCredentialFreeSnapshot(snapshot); + snapshots.add(snapshot); + } + return snapshots; + } + + /** + * {@inheritDoc} + */ + @Override + public String calculateHash(List bindings) { + List> canonical = new ArrayList<>(); + if (bindings != null) { + bindings.stream().sorted((left, right) -> Integer.compare( + left.getSortNo() == null ? 0 : left.getSortNo(), + right.getSortNo() == null ? 0 : right.getSortNo())) + .forEach(binding -> { + Map item = new LinkedHashMap<>(); + item.put("type", binding.getCapabilityType()); + item.put("targetId", binding.getTargetId() == null ? null : binding.getTargetId().toString()); + item.put("targetLogicalRef", binding.getTargetLogicalRef()); + item.put("runtimeName", binding.getRuntimeName()); + item.put("enabled", binding.getEnabled()); + item.put("selectionMode", binding.getSelectionMode()); + item.put("selectedTools", binding.getSelectedToolNamesJson()); + item.put("executionMode", binding.getExecutionMode()); + item.put("hitlEnabled", binding.getHitlEnabled()); + item.put("hitlConfig", SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson())); + item.put("options", SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson())); + canonical.add(item); + }); + } + try { + return SkillHashes.sha256Hex(objectMapper.writeValueAsString(canonicalize(canonical)) + .getBytes(StandardCharsets.UTF_8)); + } catch (JsonProcessingException exception) { + throw new BusinessException(500, 500, "计算 Skill 能力配置 hash 失败", exception); + } + } + + /** + * {@inheritDoc} + */ + @Override + public String calculateStoredHash(BigInteger skillId) { + Skill skill = requireSkill(skillId); + resourceAccessService.assertAccess( + CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限读取 Skill 能力摘要"); + return calculateHash(listRaw(skillId)); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void removeBySkillId(BigInteger skillId) { + if (skillId != null) { + QueryWrapper deleteQuery = QueryWrapper.create() + .eq(SkillCapabilityBinding::getTenantId, requireAccount().getTenantId()) + .eq(SkillCapabilityBinding::getSkillId, skillId); + long existingBindingCount = count(deleteQuery); + if (existingBindingCount > 0 && getMapper().deleteByQuery(deleteQuery) != existingBindingCount) { + throw new BusinessException(500, 500, "删除 Skill 能力绑定失败,请稍后重试"); + } + } + } + + private SkillValidationResult validateInternal(List bindings, ValidationMode mode) { + return validateInternalWithTargets(bindings, mode).result(); + } + + /** + * 校验能力绑定并保留本次调用已授权的目标摘要,供发布快照复用。 + * + * @param bindings 能力绑定 + * @param mode 校验场景 + * @return 校验结果与按绑定序号记录的目标摘要 + */ + private ValidatedBindings validateInternalWithTargets(List bindings, + ValidationMode mode) { + assertMcpAccessWhenPresent(bindings); + List issues = new ArrayList<>(); + if (bindings.size() > MAX_BINDINGS) { + issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_BINDING_LIMIT", + "单个 Skill 最多绑定 " + MAX_BINDINGS + " 项能力", "capabilities")); + SkillValidationResult result = new SkillValidationResult(); + result.setIssues(issues); + result.setValid(false); + return new ValidatedBindings(result, Map.of()); + } + Set runtimeNames = new HashSet<>(); + Map targetCache = new HashMap<>(); + Map targetsByIndex = new HashMap<>(); + for (int index = 0; index < bindings.size(); index++) { + validateOne(bindings.get(index), index, mode, runtimeNames, targetCache, targetsByIndex, issues); + } + SkillValidationResult result = new SkillValidationResult(); + result.setIssues(issues); + result.setValid(issues.stream().noneMatch(issue -> "ERROR".equals(issue.getSeverity()))); + return new ValidatedBindings(result, targetsByIndex); + } + + /** + * 当配置中出现 MCP 能力时校验当前操作者的 MCP 查询与使用权限。 + * + *

该检查先于目标映射执行,因此禁用或尚未映射的 MCP 绑定也不能绕过授权。

+ * + * @param bindings 待校验能力绑定 + */ + private void assertMcpAccessWhenPresent(List bindings) { + boolean containsMcp = bindings.stream() + .filter(java.util.Objects::nonNull) + .map(SkillCapabilityBinding::getCapabilityType) + .anyMatch(type -> type != null && SkillCapabilityType.MCP.name().equalsIgnoreCase(type.trim())); + if (containsMcp) { + mcpAccessPermissionChecker.assertCanUseMcp(); + } + } + + private void validateOne(SkillCapabilityBinding binding, + int index, + ValidationMode mode, + Set runtimeNames, + Map targetCache, + Map targetsByIndex, + List issues) { + String path = "capabilities[" + index + "]"; + if (binding == null) { + issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_EMPTY", "能力绑定不能为空", path)); + return; + } + validateCredentialFields(binding, path, issues); + SkillCapabilityType type; + try { + type = SkillCapabilityType.from(binding.getCapabilityType()); + binding.setCapabilityType(type.name()); + } catch (BusinessException exception) { + issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_TYPE_INVALID", + "能力类型不受支持", path + ".capabilityType")); + return; + } + boolean enabled = binding.getEnabled() == null || binding.getEnabled(); + binding.setEnabled(enabled); + binding.setHitlEnabled(Boolean.TRUE.equals(binding.getHitlEnabled())); + validateSafeConfigs(binding, path, issues); + if (binding.getRuntimeName() == null || !RUNTIME_NAME_PATTERN.matcher(binding.getRuntimeName()).matches()) { + issues.add(SkillValidationIssue.of("ERROR", "RUNTIME_NAME_INVALID", + "运行时名称必须以字母开头,且只包含字母、数字、下划线或连字符,最长 64 个字符", + path + ".runtimeName")); + } + validateStaticTypeConfiguration(binding, type, enabled, path, issues); + if (binding.getTargetId() == null) { + if (!SkillPortableTargetSanitizer.isSafeLogicalRef(type, binding.getTargetLogicalRef())) { + issues.add(SkillValidationIssue.of("ERROR", "TARGET_LOGICAL_REF_INVALID", + "未映射能力的目标逻辑引用格式不正确", path + ".targetLogicalRef")); + } + if (mode.allowUnresolvedTarget()) { + validateRuntimeNamesWithoutTarget(binding, type, enabled, path, runtimeNames, issues); + } else { + issues.add(SkillValidationIssue.of(enabled ? "ERROR" : "WARNING", "TARGET_UNRESOLVED", + enabled ? "启用的能力必须映射目标资源" : "能力尚未映射目标资源,保持禁用后可保存", + path + ".targetId")); + } + return; + } + if (binding.getTargetLogicalRef() != null && binding.getTargetLogicalRef().length() > 512) { + issues.add(SkillValidationIssue.of("ERROR", "TARGET_LOGICAL_REF_INVALID", + "目标逻辑引用不能超过 512 个字符", path + ".targetLogicalRef")); + } + SkillCapabilityTarget target; + try { + boolean resolveMcpTools = mode.publishValidation() && enabled && type == SkillCapabilityType.MCP; + TargetCacheKey cacheKey = new TargetCacheKey(type, binding.getTargetId(), resolveMcpTools); + target = targetCache.get(cacheKey); + if (target == null) { + target = targetAccessService.requireUsableTarget(binding, resolveMcpTools); + targetCache.put(cacheKey, target); + } + } catch (BusinessException exception) { + boolean permissionError = exception.getHttpStatus() == 403; + issues.add(SkillValidationIssue.of(permissionError || enabled ? "ERROR" : "WARNING", + permissionError ? "TARGET_NO_PERMISSION" : "TARGET_UNAVAILABLE", + permissionError ? "当前用户无权使用目标能力" : "目标能力当前不可用", + path + ".targetId")); + return; + } + targetsByIndex.put(index, target); + validateResolvedTargetCredentials(target, path, issues); + binding.setTargetName(target.getName()); + binding.setTargetStatus(target.getStatus()); + binding.setTargetLogicalRef(target.getLogicalRef()); + if (type == SkillCapabilityType.MCP) { + validateMcp(binding, target, enabled, mode.publishValidation(), path, runtimeNames, issues); + } else { + if (enabled && binding.getRuntimeName() != null + && !runtimeNames.add(binding.getRuntimeName().toLowerCase(Locale.ROOT))) { + issues.add(SkillValidationIssue.of("ERROR", "RUNTIME_NAME_DUPLICATE", + "最终运行时工具名重复", path + ".runtimeName")); + } + } + } + + /** + * 校验能力绑定所有持久化字符串面不含认证凭据。 + * + * @param binding 能力绑定 + * @param path 能力绑定路径 + * @param issues 问题集合 + */ + private void validateCredentialFields(SkillCapabilityBinding binding, + String path, + List issues) { + validateCredentialValue(binding.getCapabilityType(), path + ".capabilityType", issues); + validateCredentialValue(binding.getTargetLogicalRef(), path + ".targetLogicalRef", issues); + validateCredentialValue(binding.getRuntimeName(), path + ".runtimeName", issues); + validateCredentialValue(binding.getSelectionMode(), path + ".selectionMode", issues); + validateCredentialValue(binding.getExecutionMode(), path + ".executionMode", issues); + List selectedTools = binding.getSelectedToolNamesJson() == null + ? List.of() : binding.getSelectedToolNamesJson(); + for (int index = 0; index < selectedTools.size(); index++) { + validateCredentialValue(selectedTools.get(index), + path + ".selectedToolNamesJson[" + index + "]", issues); + } + } + + /** + * 将单个字符串中的凭据问题转换为稳定、无回显的校验结果。 + * + * @param value 字符串值 + * @param path 字段路径 + * @param issues 问题集合 + */ + private void validateCredentialValue(String value, + String path, + List issues) { + if (SkillCredentialValueGuard.containsCredential(value)) { + issues.add(SkillValidationIssue.of("ERROR", "SENSITIVE_VALUE_DETECTED", + "能力配置不能包含认证凭据,请改用运行环境中的安全配置", path)); + } + } + + /** + * 校验目标解析结果中会进入发布快照的字符串字段。 + * + * @param target 已授权目标摘要 + * @param path 能力绑定路径 + * @param issues 问题集合 + */ + private void validateResolvedTargetCredentials(SkillCapabilityTarget target, + String path, + List issues) { + // 展示元数据与逻辑引用在快照构造时采用 fail-closed 降级;工具名会直接成为运行时名称,必须阻断。 + List tools = target.getToolNames() == null ? List.of() : target.getToolNames(); + for (int index = 0; index < tools.size(); index++) { + validateCredentialValue(tools.get(index), path + ".resolvedToolNames[" + index + "]", issues); + } + } + + /** + * 对最终快照执行纵深凭据检查,防止未来新增字符串字段遗漏显式校验。 + * + * @param snapshot 单项发布快照 + */ + private void assertCredentialFreeSnapshot(Object snapshot) { + if (snapshot instanceof String text) { + if (SkillCredentialValueGuard.containsCredential(text)) { + throw new BusinessException("Skill 能力发布快照包含不安全配置"); + } + return; + } + if (snapshot instanceof Map map) { + map.values().forEach(this::assertCredentialFreeSnapshot); + return; + } + if (snapshot instanceof List list) { + list.forEach(this::assertCredentialFreeSnapshot); + } + } + + /** + * 在目标尚未映射时校验可由 manifest 独立确定的最终运行时名称。 + * + * @param binding 能力绑定 + * @param type 能力类型 + * @param enabled 是否启用 + * @param path 问题路径 + * @param runtimeNames 已占用的运行时名称 + * @param issues 问题集合 + */ + private void validateRuntimeNamesWithoutTarget(SkillCapabilityBinding binding, + SkillCapabilityType type, + boolean enabled, + String path, + Set runtimeNames, + List issues) { + if (type == SkillCapabilityType.MCP) { + validateMcp(binding, null, enabled, false, path, runtimeNames, issues); + return; + } + if (enabled && binding.getRuntimeName() != null + && !runtimeNames.add(binding.getRuntimeName().toLowerCase(Locale.ROOT))) { + issues.add(SkillValidationIssue.of("ERROR", "RUNTIME_NAME_DUPLICATE", + "最终运行时工具名重复", path + ".runtimeName")); + } + } + + private void validateMcp(SkillCapabilityBinding binding, + SkillCapabilityTarget target, + boolean enabled, + boolean publishValidation, + String path, + Set runtimeNames, + List issues) { + SkillCapabilitySelectionMode mode = SkillCapabilitySelectionMode.fromOrDefault(binding.getSelectionMode()); + List selected = binding.getSelectedToolNamesJson(); + if (!enabled) { + return; + } + List available = target == null ? List.of() : target.getToolNames(); + List resolved; + if (mode == SkillCapabilitySelectionMode.SELECTED) { + resolved = selected; + } else if (publishValidation) { + resolved = available; + } else { + return; + } + if (resolved.isEmpty()) { + if (mode == SkillCapabilitySelectionMode.SELECTED) { + // SELECTED 空清单已经由静态配置校验给出精确问题,避免重复且含混的发布错误。 + return; + } + issues.add(SkillValidationIssue.of("ERROR", "MCP_TOOLS_EMPTY", "MCP 当前没有可发布的工具", + path + ".selectedToolNamesJson")); + return; + } + if (publishValidation && mode == SkillCapabilitySelectionMode.SELECTED && !available.containsAll(selected)) { + issues.add(SkillValidationIssue.of("ERROR", "MCP_TOOL_MISSING", + "部分已选择的 MCP 工具已不存在,请重新选择", path + ".selectedToolNamesJson")); + return; + } + binding.setResolvedToolNames(resolved); + for (String toolName : resolved) { + String finalName = binding.getRuntimeName() + "_" + toolName; + if (finalName.length() > 128 || !Pattern.matches("^[A-Za-z][A-Za-z0-9_.-]{0,127}$", finalName)) { + issues.add(SkillValidationIssue.of("ERROR", "MCP_RUNTIME_NAME_INVALID", + "MCP 最终工具名不符合平台命名规则", path + ".runtimeName")); + continue; + } + if (!runtimeNames.add(finalName.toLowerCase(Locale.ROOT))) { + issues.add(SkillValidationIssue.of("ERROR", "RUNTIME_NAME_DUPLICATE", + "最终运行时工具名重复", path + ".runtimeName")); + } + } + } + + private void validateStaticTypeConfiguration(SkillCapabilityBinding binding, + SkillCapabilityType type, + boolean enabled, + String path, + List issues) { + if (type != SkillCapabilityType.MCP) { + if (binding.getSelectionMode() != null && !binding.getSelectionMode().isBlank()) { + issues.add(SkillValidationIssue.of("ERROR", "MCP_SELECTION_MODE_NOT_ALLOWED", + "工作流或插件能力不能配置 MCP 工具选择模式", path + ".selectionMode")); + } + if (binding.getSelectedToolNamesJson() != null && !binding.getSelectedToolNamesJson().isEmpty()) { + issues.add(SkillValidationIssue.of("ERROR", "MCP_TOOL_SELECTION_NOT_ALLOWED", + "工作流或插件能力不能配置 MCP 工具清单", path + ".selectedToolNamesJson")); + } + try { + binding.setExecutionMode(SkillCapabilityExecutionMode.fromOrDefault(binding.getExecutionMode()).name()); + } catch (BusinessException exception) { + issues.add(SkillValidationIssue.of("ERROR", "EXECUTION_MODE_INVALID", + "能力执行模式不受支持", path + ".executionMode")); + binding.setExecutionMode(SkillCapabilityExecutionMode.SYNC.name()); + } + binding.setSelectionMode(null); + binding.setSelectedToolNamesJson(List.of()); + return; + } + if (binding.getExecutionMode() != null && !binding.getExecutionMode().isBlank()) { + issues.add(SkillValidationIssue.of("ERROR", "MCP_EXECUTION_MODE_NOT_ALLOWED", + "MCP 能力不能配置工作流或插件执行模式", path + ".executionMode")); + } + binding.setExecutionMode(null); + SkillCapabilitySelectionMode mode; + try { + mode = SkillCapabilitySelectionMode.fromOrDefault(binding.getSelectionMode()); + } catch (BusinessException exception) { + issues.add(SkillValidationIssue.of("ERROR", "MCP_SELECTION_MODE_INVALID", + "MCP 工具选择模式不受支持", path + ".selectionMode")); + mode = SkillCapabilitySelectionMode.ALL; + } + binding.setSelectionMode(mode.name()); + List requested = binding.getSelectedToolNamesJson() == null + ? List.of() : binding.getSelectedToolNamesJson(); + if (requested.size() > MAX_SELECTED_TOOLS) { + issues.add(SkillValidationIssue.of("ERROR", "MCP_TOOL_SELECTION_LIMIT", + "MCP 最多选择 " + MAX_SELECTED_TOOLS + " 个工具", path + ".selectedToolNamesJson")); + } + Set validTools = new LinkedHashSet<>(); + for (int toolIndex = 0; toolIndex < requested.size(); toolIndex++) { + String tool = requested.get(toolIndex); + if (tool == null || !MCP_TOOL_NAME_PATTERN.matcher(tool).matches()) { + issues.add(SkillValidationIssue.of("ERROR", "MCP_TOOL_NAME_INVALID", + "MCP 工具名不符合平台命名规则", + path + ".selectedToolNamesJson[" + toolIndex + "]")); + } else { + validTools.add(tool); + } + } + List selected = new ArrayList<>(validTools); + selected.sort(String::compareTo); + binding.setSelectedToolNamesJson(selected); + if (mode == SkillCapabilitySelectionMode.SELECTED && selected.isEmpty()) { + issues.add(SkillValidationIssue.of(enabled ? "ERROR" : "WARNING", "MCP_TOOL_SELECTION_EMPTY", + "MCP SELECTED 模式至少选择一个工具", path + ".selectedToolNamesJson")); + } + } + + private void validateSafeConfigs(SkillCapabilityBinding binding, + String path, + List issues) { + Map originalHitl = binding.getHitlConfigJson() == null + ? Map.of() : binding.getHitlConfigJson(); + Map originalOptions = binding.getOptionsJson() == null + ? Map.of() : binding.getOptionsJson(); + Map safeHitl = SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson()); + Map safeOptions = SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson()); + if (!safeHitl.equals(originalHitl)) { + issues.add(SkillValidationIssue.of("ERROR", "HITL_CONFIG_UNSAFE", + "HITL 配置包含未允许字段或复杂值", path + ".hitlConfigJson")); + } + if (!safeOptions.equals(originalOptions)) { + issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_OPTIONS_UNSAFE", + "能力选项包含未允许字段或复杂值", path + ".optionsJson")); + } + try { + if (objectMapper.writeValueAsBytes(safeHitl).length > MAX_CONFIG_BYTES + || objectMapper.writeValueAsBytes(safeOptions).length > MAX_CONFIG_BYTES) { + issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_CONFIG_TOO_LARGE", + "能力配置不能超过 4 KiB", path)); + } + } catch (JsonProcessingException exception) { + issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_CONFIG_INVALID", + "能力配置无法序列化", path)); + } + binding.setHitlConfigJson(safeHitl); + binding.setOptionsJson(safeOptions); + validateSafeConfigValues(safeHitl, safeOptions, path, issues); + } + + private void validateSafeConfigValues(Map hitl, + Map options, + String path, + List issues) { + for (Map.Entry entry : hitl.entrySet()) { + int maxLength = "confirmLabel".equals(entry.getKey()) || "cancelLabel".equals(entry.getKey()) + ? 128 : 2_000; + if (!(entry.getValue() instanceof String text) || text.length() > maxLength) { + issues.add(SkillValidationIssue.of("ERROR", "HITL_CONFIG_VALUE_INVALID", + "HITL 配置字段类型或长度不正确:" + entry.getKey(), path + ".hitlConfigJson." + entry.getKey())); + } else if (SkillCredentialValueGuard.containsCredential(text)) { + issues.add(SkillValidationIssue.of("ERROR", "SENSITIVE_VALUE_DETECTED", + "HITL 配置不能包含认证凭据,请改用运行环境中的安全配置", + path + ".hitlConfigJson." + entry.getKey())); + } + } + for (Map.Entry entry : options.entrySet()) { + if (entry.getValue() instanceof String text) { + validateCredentialValue(text, path + ".optionsJson." + entry.getKey(), issues); + } + } + validateIntegerOption(options, "timeoutMs", 100, 300_000, path, issues); + validateIntegerOption(options, "retryCount", 0, 10, path, issues); + for (String key : List.of("async", "readOnly")) { + if (options.containsKey(key) && !(options.get(key) instanceof Boolean)) { + issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_OPTION_VALUE_INVALID", + "能力选项必须为布尔值:" + key, path + ".optionsJson." + key)); + } + } + } + + private void validateIntegerOption(Map options, + String key, + int minimum, + int maximum, + String path, + List issues) { + if (!options.containsKey(key)) { + return; + } + Object value = options.get(key); + boolean valid = value instanceof Number number + && number.doubleValue() == number.longValue() + && number.longValue() >= minimum + && number.longValue() <= maximum; + if (!valid) { + issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_OPTION_VALUE_INVALID", + "能力选项数值超出范围:" + key, path + ".optionsJson." + key)); + } + } + + private void enrichDisplayStatus(SkillCapabilityBinding binding) { + if (binding.getTargetId() == null) { + binding.setTargetStatus("UNRESOLVED"); + return; + } + try { + SkillCapabilityTarget target = targetAccessService.requireUsableTarget(binding, false); + binding.setTargetName(target.getName()); + binding.setTargetStatus("AVAILABLE"); + } catch (BusinessException exception) { + boolean permissionDenied = exception.getHttpStatus() == 403; + binding.setTargetStatus(permissionDenied ? "NO_PERMISSION" : "UNAVAILABLE"); + if (permissionDenied) { + // 目标不可读时不能沿用调用前对象中可能存在的展示残留。 + binding.setTargetName(null); + binding.setResolvedToolNames(List.of()); + } + } + } + + /** + * 对读取出的历史能力配置执行展示边界脱敏,防止遗留脏数据通过列表或详情接口回显。 + * + * @param binding 待读取能力绑定 + */ + private void sanitizeBindingForExposure(SkillCapabilityBinding binding) { + binding.setCapabilityType(safeNonCredentialOrNull(binding.getCapabilityType())); + binding.setTargetLogicalRef(safeNonCredentialOrNull(binding.getTargetLogicalRef())); + binding.setRuntimeName(safeNonCredentialOrNull(binding.getRuntimeName())); + binding.setSelectionMode(safeNonCredentialOrNull(binding.getSelectionMode())); + binding.setExecutionMode(safeNonCredentialOrNull(binding.getExecutionMode())); + binding.setTargetName(SkillPortableTargetSanitizer.safePortableMetadataOrNull(binding.getTargetName())); + binding.setTargetStatus(safeNonCredentialOrNull(binding.getTargetStatus())); + binding.setSelectedToolNamesJson(sanitizeToolNamesForExposure(binding.getSelectedToolNamesJson())); + binding.setResolvedToolNames(sanitizeToolNamesForExposure(binding.getResolvedToolNames())); + + Map hitl = SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson()); + hitl.entrySet().removeIf(entry -> !(entry.getValue() instanceof String text) + || SkillCredentialValueGuard.containsCredential(text)); + binding.setHitlConfigJson(hitl); + + Map options = SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson()); + options.entrySet().removeIf(entry -> !isSafeOptionForExposure(entry.getKey(), entry.getValue())); + binding.setOptionsJson(options); + } + + /** + * 过滤历史工具名中的异常或凭据式值。 + * + * @param values 原始工具名 + * @return 可安全展示的工具名 + */ + private List sanitizeToolNamesForExposure(List values) { + if (values == null || values.isEmpty()) { + return List.of(); + } + return values.stream() + .filter(java.util.Objects::nonNull) + .filter(value -> MCP_TOOL_NAME_PATTERN.matcher(value).matches()) + .filter(value -> !SkillCredentialValueGuard.containsCredential(value)) + .toList(); + } + + /** + * 判断能力选项是否符合公开返回的严格类型和值域。 + * + * @param key 选项键 + * @param value 选项值 + * @return 可安全展示时为 true + */ + private boolean isSafeOptionForExposure(String key, Object value) { + if (("async".equals(key) || "readOnly".equals(key))) { + return value instanceof Boolean; + } + if (!(value instanceof Number number) + || number.doubleValue() != number.longValue()) { + return false; + } + long numeric = number.longValue(); + if ("timeoutMs".equals(key)) { + return numeric >= 100 && numeric <= 300_000; + } + return "retryCount".equals(key) && numeric >= 0 && numeric <= 10; + } + + /** + * 返回不含凭据的字符串;敏感或空白值统一移除。 + * + * @param value 原始字符串 + * @return 可安全返回的值 + */ + private String safeNonCredentialOrNull(String value) { + return value == null || value.isBlank() || SkillCredentialValueGuard.containsCredential(value) + ? null : value; + } + + private List listRaw(BigInteger skillId) { + return list(QueryWrapper.create() + .eq(SkillCapabilityBinding::getTenantId, requireAccount().getTenantId()) + .eq(SkillCapabilityBinding::getSkillId, skillId) + .orderBy("sort_no asc, id asc")); + } + + private Skill requireSkill(BigInteger skillId) { + return requireSkill(skillId, false); + } + + private Skill requireSkill(BigInteger skillId, boolean forUpdate) { + if (skillId == null) { + throw new BusinessException("Skill ID 不能为空"); + } + LoginAccount account = requireAccount(); + QueryWrapper query = QueryWrapper.create() + .eq(Skill::getId, skillId) + .eq(Skill::getTenantId, account.getTenantId()); + if (forUpdate) { + query.forUpdate(); + } + Skill skill = skillMapper.selectOneByQuery(query); + if (skill == null) { + throw new BusinessException(404, 404, "Skill 不存在"); + } + return skill; + } + + private LoginAccount requireAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException(401, 401, "未登录或登录态无效"); + } + return account; + } + + private void assertNoErrors(SkillValidationResult result) { + result.getIssues().stream().filter(issue -> "ERROR".equals(issue.getSeverity())).findFirst() + .ifPresent(issue -> { + if ("TARGET_NO_PERMISSION".equals(issue.getCode())) { + throw new BusinessException(403, 403, issue.getMessage()); + } + throw new BusinessException(issue.getMessage()); + }); + } + + private Object canonicalize(Object value) { + if (value instanceof Map map) { + Map sorted = new java.util.TreeMap<>(); + map.forEach((key, item) -> sorted.put(String.valueOf(key), canonicalize(item))); + return sorted; + } + if (value instanceof List list) { + return list.stream().map(this::canonicalize).toList(); + } + return value; + } + + /** + * 能力绑定校验场景。 + * + * @param publishValidation 是否执行发布级 MCP 工具解析 + * @param allowUnresolvedTarget 是否允许目标留待导入映射处理 + */ + private record ValidationMode(boolean publishValidation, boolean allowUnresolvedTarget) { + + private static final ValidationMode SAVE = new ValidationMode(false, false); + private static final ValidationMode PUBLISH = new ValidationMode(true, false); + private static final ValidationMode IMPORT_PREVIEW = new ValidationMode(false, true); + } + + /** + * 单次校验内目标查询的稳定缓存键。 + * + * @param type 能力类型 + * @param targetId 目标 ID + * @param resolveMcpTools 是否解析 MCP 工具清单 + */ + private record TargetCacheKey(SkillCapabilityType type, + BigInteger targetId, + boolean resolveMcpTools) { + } + + /** + * 校验结果及其已授权目标摘要。 + * + * @param result 结构化校验结果 + * @param targetsByIndex 按绑定序号记录的目标摘要 + */ + private record ValidatedBindings(SkillValidationResult result, + Map targetsByIndex) { + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityCandidate.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityCandidate.java new file mode 100644 index 00000000..10e472cf --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityCandidate.java @@ -0,0 +1,37 @@ +package tech.easyflow.skill.capability; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; + +/** + * 当前操作者可绑定的能力候选项。 + */ +public class SkillCapabilityCandidate { + + private String capabilityType; + private BigInteger targetId; + private String name; + private String description; + private String logicalRef; + private String revision; + private String status; + private List toolNames = new ArrayList<>(); + + public String getCapabilityType() { return capabilityType; } + public void setCapabilityType(String capabilityType) { this.capabilityType = capabilityType; } + public BigInteger getTargetId() { return targetId; } + public void setTargetId(BigInteger targetId) { this.targetId = targetId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public String getLogicalRef() { return logicalRef; } + public void setLogicalRef(String logicalRef) { this.logicalRef = logicalRef; } + public String getRevision() { return revision; } + public void setRevision(String revision) { this.revision = revision; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public List getToolNames() { return toolNames; } + public void setToolNames(List toolNames) { this.toolNames = toolNames == null ? new ArrayList<>() : new ArrayList<>(toolNames); } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTarget.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTarget.java new file mode 100644 index 00000000..6c57f54f --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTarget.java @@ -0,0 +1,30 @@ +package tech.easyflow.skill.capability; + +import java.util.ArrayList; +import java.util.List; + +/** + * 已授权能力目标的安全解析结果。 + */ +public class SkillCapabilityTarget { + + private String name; + private String description; + private String logicalRef; + private String revision; + private String status; + private List toolNames = new ArrayList<>(); + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public String getLogicalRef() { return logicalRef; } + public void setLogicalRef(String logicalRef) { this.logicalRef = logicalRef; } + public String getRevision() { return revision; } + public void setRevision(String revision) { this.revision = revision; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public List getToolNames() { return toolNames; } + public void setToolNames(List toolNames) { this.toolNames = toolNames == null ? new ArrayList<>() : new ArrayList<>(toolNames); } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTargetAccessService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTargetAccessService.java new file mode 100644 index 00000000..ace3d6d8 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTargetAccessService.java @@ -0,0 +1,48 @@ +package tech.easyflow.skill.capability; + +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.enums.SkillCapabilityType; + +import java.math.BigInteger; +import java.util.List; + +/** + * Skill 能力目标的统一授权与安全摘要服务。 + */ +public interface SkillCapabilityTargetAccessService { + + /** + * 解析并校验一个能力绑定目标。 + * + * @param binding 能力绑定 + * @param resolveMcpTools 是否实时解析 MCP 工具 + * @return 不含凭据的目标摘要 + */ + SkillCapabilityTarget requireUsableTarget(SkillCapabilityBinding binding, boolean resolveMcpTools); + + /** + * 查询当前操作者可绑定的目标。 + * + * @param capabilityType 能力类型 + * @param keyword 关键词 + * @return 可绑定目标 + */ + List listCandidates(SkillCapabilityType capabilityType, String keyword); + + /** + * 按逻辑引用尝试解析当前环境目标。 + * + * @param capabilityType 能力类型 + * @param logicalRef 逻辑引用 + * @return 当前用户有权使用的目标 ID,未匹配时为空 + */ + BigInteger resolveLogicalRef(SkillCapabilityType capabilityType, String logicalRef); + + /** + * 按需解析一个已授权 MCP 的工具清单。 + * + * @param targetId MCP ID + * @return MCP 候选详情及工具名 + */ + SkillCapabilityCandidate getMcpTools(BigInteger targetId); +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTargetAccessServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTargetAccessServiceImpl.java new file mode 100644 index 00000000..5c5de9eb --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTargetAccessServiceImpl.java @@ -0,0 +1,536 @@ +package tech.easyflow.skill.capability; + +import com.mybatisflex.core.query.QueryWrapper; +import io.modelcontextprotocol.spec.McpSchema; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import tech.easyflow.ai.entity.Mcp; +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.permission.McpAccessPermissionChecker; +import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper; +import tech.easyflow.ai.service.McpService; +import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.PluginService; +import tech.easyflow.ai.service.PluginVisibilityService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.enums.SkillCapabilityType; +import tech.easyflow.skill.security.SkillPortableTargetSanitizer; +import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * Skill 能力目标授权服务默认实现。 + */ +@Service +public class SkillCapabilityTargetAccessServiceImpl implements SkillCapabilityTargetAccessService { + + private static final Logger LOG = LoggerFactory.getLogger(SkillCapabilityTargetAccessServiceImpl.class); + private static final int MAX_CANDIDATES = 100; + private static final String UNRESOLVED_PREFIX = "unresolved:"; + + private final WorkflowService workflowService; + private final PluginItemService pluginItemService; + private final PluginService pluginService; + private final PluginVisibilityService pluginVisibilityService; + private final McpService mcpService; + private final McpAccessPermissionChecker mcpAccessPermissionChecker; + private final ResourceAccessService resourceAccessService; + private final WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper; + private final CategoryPermissionService categoryPermissionService; + + /** + * 创建能力目标授权服务。 + * + * @param workflowService 工作流服务 + * @param pluginItemService 插件工具项服务 + * @param pluginService 插件服务 + * @param pluginVisibilityService 插件可见性服务 + * @param mcpService MCP 服务 + * @param mcpAccessPermissionChecker MCP 查询与使用权限检查器 + * @param resourceAccessService 分类资源访问服务 + * @param workflowVisibilityQueryHelper 工作流可见性查询助手 + * @param categoryPermissionService 分类权限服务 + */ + public SkillCapabilityTargetAccessServiceImpl(WorkflowService workflowService, + PluginItemService pluginItemService, + PluginService pluginService, + PluginVisibilityService pluginVisibilityService, + McpService mcpService, + McpAccessPermissionChecker mcpAccessPermissionChecker, + ResourceAccessService resourceAccessService, + WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper, + CategoryPermissionService categoryPermissionService) { + this.workflowService = workflowService; + this.pluginItemService = pluginItemService; + this.pluginService = pluginService; + this.pluginVisibilityService = pluginVisibilityService; + this.mcpService = mcpService; + this.mcpAccessPermissionChecker = mcpAccessPermissionChecker; + this.resourceAccessService = resourceAccessService; + this.workflowVisibilityQueryHelper = workflowVisibilityQueryHelper; + this.categoryPermissionService = categoryPermissionService; + } + + /** + * {@inheritDoc} + */ + @Override + public SkillCapabilityTarget requireUsableTarget(SkillCapabilityBinding binding, boolean resolveMcpTools) { + if (binding == null || binding.getTargetId() == null) { + throw new BusinessException("能力绑定目标不能为空"); + } + SkillCapabilityType type = SkillCapabilityType.from(binding.getCapabilityType()); + return switch (type) { + case WORKFLOW -> requireWorkflow(binding.getTargetId()); + case PLUGIN_ITEM -> requirePluginItem(binding.getTargetId()); + case MCP -> requireMcp(binding.getTargetId(), resolveMcpTools); + }; + } + + /** + * {@inheritDoc} + */ + @Override + public List listCandidates(SkillCapabilityType capabilityType, String keyword) { + String normalizedKeyword = keyword == null ? "" : keyword.trim().toLowerCase(); + return switch (capabilityType) { + case WORKFLOW -> workflowCandidates(normalizedKeyword); + case PLUGIN_ITEM -> pluginCandidates(normalizedKeyword); + case MCP -> mcpCandidates(normalizedKeyword); + }; + } + + /** + * {@inheritDoc} + */ + @Override + public BigInteger resolveLogicalRef(SkillCapabilityType capabilityType, String logicalRef) { + if (capabilityType == SkillCapabilityType.MCP) { + // 显式未映射引用仍属于增强包 MCP 映射流程,不能绕过模块权限。 + mcpAccessPermissionChecker.assertCanUseMcp(); + } + if (!SkillPortableTargetSanitizer.isSafeLogicalRef(capabilityType, logicalRef)) { + return null; + } + if (logicalRef.startsWith(UNRESOLVED_PREFIX) + || "workflow:unmapped".equals(logicalRef) + || "plugin-item:unmapped/unmapped".equals(logicalRef) + || "mcp:unmapped".equals(logicalRef)) { + return null; + } + try { + return switch (capabilityType) { + case WORKFLOW -> resolveWorkflowRef(logicalRef); + case PLUGIN_ITEM -> resolvePluginItemRef(logicalRef); + case MCP -> resolveMcpRef(logicalRef); + }; + } catch (BusinessException exception) { + if (exception.getHttpStatus() == 401 || exception.getHttpStatus() == 403) { + throw exception; + } + return null; + } + } + + /** + * {@inheritDoc} + */ + @Override + public SkillCapabilityCandidate getMcpTools(BigInteger targetId) { + SkillCapabilityTarget target = requireMcp(targetId, true); + return toCandidate(SkillCapabilityType.MCP, targetId, target); + } + + private SkillCapabilityTarget requireWorkflow(BigInteger targetId) { + Workflow workflow = workflowService.getOne(QueryWrapper.create() + .eq(Workflow::getId, targetId) + .eq(Workflow::getTenantId, requireAccount().getTenantId())); + return toWorkflowTarget(workflow, targetId, true); + } + + private SkillCapabilityTarget toWorkflowTarget(Workflow workflow, BigInteger targetId, boolean assertPermission) { + if (workflow == null || PublishStatus.from(workflow.getPublishStatus()) != PublishStatus.PUBLISHED + || workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) { + throw new BusinessException(404, 404, "绑定工作流不存在、未发布或没有有效发布快照"); + } + if (assertPermission) { + resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE, + "无权限使用绑定工作流"); + } + SkillCapabilityTarget target = new SkillCapabilityTarget(); + target.setName(workflow.getTitle()); + target.setDescription(workflow.getDescription()); + String stableRef = firstNonBlank(workflow.getAlias(), workflow.getEnglishName()); + target.setLogicalRef(SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved( + SkillCapabilityType.WORKFLOW, stableRef == null ? null : "workflow:" + stableRef)); + target.setRevision(workflow.getPublishedAt() == null ? null : String.valueOf(workflow.getPublishedAt().getTime())); + target.setStatus("AVAILABLE"); + return target; + } + + private SkillCapabilityTarget requirePluginItem(BigInteger targetId) { + LoginAccount account = requireAccount(); + PluginItem item = pluginItemService.getOne(QueryWrapper.create() + .eq(PluginItem::getId, targetId) + .and("plugin_id IN (SELECT id FROM tb_plugin WHERE tenant_id = ?)", + account.getTenantId().longValue())); + if (item == null || !Integer.valueOf(1).equals(item.getStatus()) + || !Integer.valueOf(1).equals(item.getServiceStatus())) { + throw new BusinessException(404, 404, "绑定插件工具项不存在或未启用"); + } + Plugin plugin = pluginService.getOne(QueryWrapper.create() + .eq(Plugin::getId, item.getPluginId()) + .eq(Plugin::getTenantId, account.getTenantId().longValue())); + return toPluginTarget(item, plugin, true, false); + } + + private SkillCapabilityTarget toPluginTarget(PluginItem item, Plugin plugin, + boolean assertPermission, boolean alreadyPrepared) { + if (plugin == null) { + throw new BusinessException(404, 404, "绑定插件工具项所属插件不存在"); + } + LoginAccount account = requireAccount(); + if (!Objects.equals(plugin.getTenantId(), account.getTenantId().longValue())) { + throw new BusinessException(403, 403, "无权限使用绑定插件"); + } + if (assertPermission && !pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) { + throw new BusinessException(403, 403, "无权限使用绑定插件"); + } + Plugin prepared = alreadyPrepared ? plugin : pluginService.preparePluginForCurrentUser(plugin); + if (prepared != null && Boolean.FALSE.equals(prepared.getAvailable())) { + throw new BusinessException(firstNonBlank(prepared.getReasonMessage(), "绑定插件当前不可用")); + } + SkillCapabilityTarget target = new SkillCapabilityTarget(); + target.setName(plugin.getName() + " / " + item.getName()); + target.setDescription(item.getDescription()); + String pluginRef = firstNonBlank(plugin.getAlias()); + String itemRef = firstNonBlank(item.getEnglishName()); + String logicalRef = pluginRef == null || itemRef == null + ? null : "plugin-item:" + pluginRef + "/" + itemRef; + target.setLogicalRef(SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved( + SkillCapabilityType.PLUGIN_ITEM, logicalRef)); + target.setRevision(item.getSchemaHash()); + target.setStatus("AVAILABLE"); + return target; + } + + private SkillCapabilityTarget requireMcp(BigInteger targetId, boolean resolveTools) { + mcpAccessPermissionChecker.assertCanUseMcp(); + Mcp mcp = mcpService.getOne(QueryWrapper.create() + .eq(Mcp::getId, targetId) + .eq(Mcp::getTenantId, requireAccount().getTenantId())); + return toMcpTarget(mcp, targetId, resolveTools); + } + + private SkillCapabilityTarget toMcpTarget(Mcp mcp, BigInteger targetId, boolean resolveTools) { + LoginAccount account = requireAccount(); + if (mcp == null || !Boolean.TRUE.equals(mcp.getStatus())) { + throw new BusinessException(404, 404, "绑定 MCP 不存在或未启用"); + } + // MCP 尚未纳入 CategoryResourceType,显式限制到当前租户,防止使用 ID 绕过租户隔离。 + if (!Objects.equals(account.getTenantId(), mcp.getTenantId())) { + throw new BusinessException(403, 403, "无权限使用绑定 MCP"); + } + SkillCapabilityTarget target = new SkillCapabilityTarget(); + target.setName(mcp.getTitle()); + target.setDescription(mcp.getDescription()); + target.setLogicalRef(SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved( + SkillCapabilityType.MCP, mcp.getTitle() == null ? null : "mcp:" + mcp.getTitle())); + target.setRevision(mcp.getModified() == null ? null : String.valueOf(mcp.getModified().getTime())); + target.setStatus("AVAILABLE"); + if (resolveTools) { + try { + Mcp resolved = mcpService.getMcpTools(targetId.toString()); + if (resolved == null || resolved.getTools() == null) { + throw new BusinessException("MCP 当前未连接,无法解析工具清单"); + } + target.setToolNames(resolved.getTools().stream().map(McpSchema.Tool::name).sorted().toList()); + } catch (BusinessException exception) { + throw exception; + } catch (Exception exception) { + LOG.error("解析 Skill 绑定 MCP 工具清单失败,targetId={}", targetId, exception); + throw new BusinessException(502, 5021, + "MCP 工具清单解析失败,请检查服务连接状态", exception); + } + } + return target; + } + + private List workflowCandidates(String keyword) { + QueryWrapper query = QueryWrapper.create() + .eq(Workflow::getTenantId, requireAccount().getTenantId()) + .eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode()) + .orderBy("modified desc"); + workflowVisibilityQueryHelper.applyReadableAccess(query); + query.limit(MAX_CANDIDATES); + applyKeyword(query, keyword, "title", "description", "alias", "english_name"); + List result = new ArrayList<>(); + for (Workflow workflow : workflowService.list(query)) { + if (!resourceAccessService.canAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE)) { + continue; + } + SkillCapabilityTarget target; + try { + target = toWorkflowTarget(workflow, workflow.getId(), false); + } catch (BusinessException ignored) { + continue; + } + if (!matches(keyword, target.getName(), target.getDescription(), target.getLogicalRef())) { + continue; + } + result.add(toCandidate(SkillCapabilityType.WORKFLOW, workflow.getId(), target)); + if (result.size() >= MAX_CANDIDATES) { + break; + } + } + return result; + } + + private List pluginCandidates(String keyword) { + QueryWrapper query = QueryWrapper.create() + .eq(PluginItem::getStatus, 1) + .eq(PluginItem::getServiceStatus, 1) + .orderBy("created desc"); + applyPluginReadableAccess(query); + query.limit(MAX_CANDIDATES); + applyKeyword(query, keyword, "name", "description", "english_name"); + List items = pluginItemService.list(query); + Map plugins = loadPlugins(items); + Map preparedPlugins = new LinkedHashMap<>(); + for (Plugin plugin : plugins.values()) { + preparedPlugins.put(plugin.getId(), pluginService.preparePluginForCurrentUser(plugin)); + } + List result = new ArrayList<>(); + for (PluginItem item : items) { + Plugin plugin = preparedPlugins.get(item.getPluginId()); + if (plugin == null) { + continue; + } + try { + SkillCapabilityTarget target = toPluginTarget(item, plugin, false, true); + if (matches(keyword, target.getName(), target.getDescription(), target.getLogicalRef())) { + result.add(toCandidate(SkillCapabilityType.PLUGIN_ITEM, item.getId(), target)); + if (result.size() >= MAX_CANDIDATES) { + break; + } + } + } catch (BusinessException ignored) { + // 候选列表只展示当前可用项,具体不可用原因在已保存绑定的校验结果中返回。 + } + } + return result; + } + + private List mcpCandidates(String keyword) { + // 候选枚举在查询数据前完成模块权限校验,避免把无权限误装成空列表。 + mcpAccessPermissionChecker.assertCanUseMcp(); + QueryWrapper query = QueryWrapper.create().eq(Mcp::getStatus, true) + .eq(Mcp::getTenantId, requireAccount().getTenantId()) + .orderBy("modified desc").limit(MAX_CANDIDATES); + applyKeyword(query, keyword, "title", "description"); + List result = new ArrayList<>(); + for (Mcp mcp : mcpService.list(query)) { + try { + SkillCapabilityTarget target = toMcpTarget(mcp, mcp.getId(), false); + if (matches(keyword, target.getName(), target.getDescription(), target.getLogicalRef())) { + result.add(toCandidate(SkillCapabilityType.MCP, mcp.getId(), target)); + if (result.size() >= MAX_CANDIDATES) { + break; + } + } + } catch (BusinessException ignored) { + // 同租户且启用的 MCP 才能成为候选。 + } + } + return result; + } + + private Map loadPlugins(List items) { + List ids = items.stream().map(PluginItem::getPluginId).filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + for (Plugin plugin : pluginService.list(QueryWrapper.create() + .eq(Plugin::getTenantId, requireAccount().getTenantId().longValue()) + .in(Plugin::getId, ids))) { + result.put(plugin.getId(), plugin); + } + return result; + } + + private void applyPluginReadableAccess(QueryWrapper itemQuery) { + LoginAccount account = requireAccount(); + itemQuery.and("plugin_id IN (SELECT id FROM tb_plugin WHERE tenant_id = ?)", + account.getTenantId().longValue()); + RoleCategoryAccessSnapshot snapshot = categoryPermissionService.getCurrentAccess("PLUGIN"); + if (snapshot.isSuperAdmin() || !snapshot.isRestricted()) { + return; + } + if (snapshot.getAccountId() == null) { + itemQuery.and("1 = 0"); + return; + } + if (snapshot.getCategoryIds().isEmpty()) { + itemQuery.and("plugin_id IN (SELECT id FROM tb_plugin WHERE created_by = ?)", + snapshot.getAccountId()); + return; + } + String placeholders = String.join(",", java.util.Collections.nCopies( + snapshot.getCategoryIds().size(), "?")); + List arguments = new ArrayList<>(); + arguments.add(snapshot.getAccountId()); + arguments.addAll(snapshot.getCategoryIds()); + itemQuery.and("plugin_id IN (SELECT id FROM tb_plugin WHERE created_by = ? OR id IN " + + "(SELECT plugin_id FROM tb_plugin_category_mapping WHERE category_id IN (" + placeholders + ")))", + arguments.toArray()); + } + + private BigInteger resolveWorkflowRef(String logicalRef) { + if (!logicalRef.startsWith("workflow:")) { + return null; + } + String key = logicalRef.substring("workflow:".length()); + QueryWrapper query = QueryWrapper.create(); + query.eq(Workflow::getTenantId, requireAccount().getTenantId()); + query.and("(alias = ? OR english_name = ?)", key, key); + query.limit(2); + List matches = workflowService.list(query).stream() + .filter(workflow -> resourceAccessService.canAccess( + CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE)) + .toList(); + if (matches.size() != 1) { + return null; + } + toWorkflowTarget(matches.get(0), matches.get(0).getId(), false); + return matches.get(0).getId(); + } + + private BigInteger resolvePluginItemRef(String logicalRef) { + if (!logicalRef.startsWith("plugin-item:") || !logicalRef.substring("plugin-item:".length()).contains("/")) { + return null; + } + String value = logicalRef.substring("plugin-item:".length()); + int separator = value.indexOf('/'); + String pluginKey = value.substring(0, separator); + String itemKey = value.substring(separator + 1); + QueryWrapper pluginQuery = QueryWrapper.create(); + pluginQuery.eq(Plugin::getTenantId, requireAccount().getTenantId().longValue()) + .eq(Plugin::getAlias, pluginKey); + pluginQuery.limit(2); + List plugins = pluginService.list(pluginQuery).stream() + .filter(plugin -> pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) + .toList(); + if (plugins.size() != 1) { + return null; + } + QueryWrapper itemQuery = QueryWrapper.create().eq(PluginItem::getPluginId, plugins.get(0).getId()); + itemQuery.and("(english_name = ? OR name = ?)", itemKey, itemKey); + itemQuery.limit(2); + List items = pluginItemService.list(itemQuery); + if (items.size() != 1) { + return null; + } + toPluginTarget(items.get(0), plugins.get(0), false, false); + return items.get(0).getId(); + } + + private BigInteger resolveMcpRef(String logicalRef) { + if (!logicalRef.startsWith("mcp:")) { + return null; + } + String title = logicalRef.substring("mcp:".length()); + List matches = mcpService.list(QueryWrapper.create() + .eq(Mcp::getTenantId, requireAccount().getTenantId()) + .eq(Mcp::getTitle, title).limit(2)); + List usable = matches.stream().filter(mcp -> { + try { + toMcpTarget(mcp, mcp.getId(), false); + return true; + } catch (BusinessException exception) { + return false; + } + }).toList(); + return usable.size() == 1 ? usable.get(0).getId() : null; + } + + private SkillCapabilityCandidate toCandidate(SkillCapabilityType type, BigInteger id, SkillCapabilityTarget target) { + SkillCapabilityCandidate candidate = new SkillCapabilityCandidate(); + candidate.setCapabilityType(type.name()); + candidate.setTargetId(id); + candidate.setName(target.getName()); + candidate.setDescription(target.getDescription()); + candidate.setLogicalRef(target.getLogicalRef()); + candidate.setRevision(target.getRevision()); + candidate.setStatus(target.getStatus()); + candidate.setToolNames(target.getToolNames()); + return candidate; + } + + private boolean matches(String keyword, String... values) { + if (keyword == null || keyword.isBlank()) { + return true; + } + for (String value : values) { + if (value != null && value.toLowerCase(Locale.ROOT).contains(keyword)) { + return true; + } + } + return false; + } + + private void applyKeyword(QueryWrapper query, String keyword, String... columns) { + if (keyword == null || keyword.isBlank() || columns.length == 0) { + return; + } + String pattern = "%" + keyword.toLowerCase(Locale.ROOT) + "%"; + StringBuilder condition = new StringBuilder("("); + Object[] arguments = new Object[columns.length]; + for (int index = 0; index < columns.length; index++) { + if (index > 0) { + condition.append(" OR "); + } + condition.append("LOWER(").append(columns[index]).append(") LIKE ?"); + arguments[index] = pattern; + } + condition.append(')'); + query.and(condition.toString(), arguments); + } + + private LoginAccount requireAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException(401, 401, "未登录或登录态无效"); + } + return account; + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java index bf0b37b4..2d8726b0 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java @@ -39,6 +39,10 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl private String visibilityScope; private String sourceType; private String packageHash; + private String capabilityHash; + private String snapshotHash; + private Integer resourceCount; + private Integer capabilityCount; private Integer referenceCount; private Integer scriptCount; private Integer assetCount; @@ -67,6 +71,10 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl private List scripts; @Column(ignore = true) private List assets; + @Column(ignore = true) + private List resources; + @Column(ignore = true) + private List capabilityBindings; public BigInteger getId() { return id; } public void setId(BigInteger id) { this.id = id; } @@ -94,6 +102,14 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl public void setSourceType(String sourceType) { this.sourceType = sourceType; } public String getPackageHash() { return packageHash; } public void setPackageHash(String packageHash) { this.packageHash = packageHash; } + public String getCapabilityHash() { return capabilityHash; } + public void setCapabilityHash(String capabilityHash) { this.capabilityHash = capabilityHash; } + public String getSnapshotHash() { return snapshotHash; } + public void setSnapshotHash(String snapshotHash) { this.snapshotHash = snapshotHash; } + public Integer getResourceCount() { return resourceCount; } + public void setResourceCount(Integer resourceCount) { this.resourceCount = resourceCount; } + public Integer getCapabilityCount() { return capabilityCount; } + public void setCapabilityCount(Integer capabilityCount) { this.capabilityCount = capabilityCount; } public Integer getReferenceCount() { return referenceCount; } public void setReferenceCount(Integer referenceCount) { this.referenceCount = referenceCount; } public Integer getScriptCount() { return scriptCount; } @@ -132,4 +148,8 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl public void setScripts(List scripts) { this.scripts = scripts; } public List getAssets() { return assets; } public void setAssets(List assets) { this.assets = assets; } + public List getResources() { return resources; } + public void setResources(List resources) { this.resources = resources; } + public List getCapabilityBindings() { return capabilityBindings; } + public void setCapabilityBindings(List capabilityBindings) { this.capabilityBindings = capabilityBindings; } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillAssetContent.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillAssetContent.java deleted file mode 100644 index 70b3f4b6..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillAssetContent.java +++ /dev/null @@ -1,43 +0,0 @@ -package tech.easyflow.skill.entity; - -import com.mybatisflex.annotation.Id; -import com.mybatisflex.annotation.Table; - -import java.io.Serializable; -import java.util.Date; - -/** - * Skill asset 内容索引实体。 - */ -@Table("tb_skill_asset_content") -public class SkillAssetContent implements Serializable { - - private static final long serialVersionUID = 1L; - - @Id - private String contentRef; - private String contentHash; - private String filePath; - private String mediaType; - private Long size; - private Integer refCount; - private Date created; - private Date modified; - - public String getContentRef() { return contentRef; } - public void setContentRef(String contentRef) { this.contentRef = contentRef; } - public String getContentHash() { return contentHash; } - public void setContentHash(String contentHash) { this.contentHash = contentHash; } - public String getFilePath() { return filePath; } - public void setFilePath(String filePath) { this.filePath = filePath; } - public String getMediaType() { return mediaType; } - public void setMediaType(String mediaType) { this.mediaType = mediaType; } - public Long getSize() { return size; } - public void setSize(Long size) { this.size = size; } - public Integer getRefCount() { return refCount; } - public void setRefCount(Integer refCount) { this.refCount = refCount; } - public Date getCreated() { return created; } - public void setCreated(Date created) { this.created = created; } - public Date getModified() { return modified; } - public void setModified(Date modified) { this.modified = modified; } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillCapabilityBinding.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillCapabilityBinding.java new file mode 100644 index 00000000..310a526d --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillCapabilityBinding.java @@ -0,0 +1,102 @@ +package tech.easyflow.skill.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.handler.FastjsonTypeHandler; +import tech.easyflow.common.entity.DateEntity; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Skill 与平台能力的绑定实体。 + */ +@Table("tb_skill_capability_binding") +public class SkillCapabilityBinding extends DateEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id(keyType = KeyType.Generator, value = "snowFlakeId") + private BigInteger id; + @Column(tenantId = true) + private BigInteger tenantId; + private BigInteger skillId; + private String capabilityType; + private BigInteger targetId; + private String targetLogicalRef; + private String runtimeName; + private Boolean enabled; + private String selectionMode; + @Column(typeHandler = FastjsonTypeHandler.class) + private List selectedToolNamesJson = new ArrayList<>(); + private String executionMode; + private Boolean hitlEnabled; + @Column(typeHandler = FastjsonTypeHandler.class) + private Map hitlConfigJson = new LinkedHashMap<>(); + @Column(typeHandler = FastjsonTypeHandler.class) + private Map optionsJson = new LinkedHashMap<>(); + private Integer sortNo; + private Date created; + private BigInteger createdBy; + private Date modified; + private BigInteger modifiedBy; + + @Column(ignore = true) + private String targetName; + @Column(ignore = true) + private String targetStatus; + @Column(ignore = true) + private List resolvedToolNames = new ArrayList<>(); + + public BigInteger getId() { return id; } + public void setId(BigInteger id) { this.id = id; } + public BigInteger getTenantId() { return tenantId; } + public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; } + public BigInteger getSkillId() { return skillId; } + public void setSkillId(BigInteger skillId) { this.skillId = skillId; } + public String getCapabilityType() { return capabilityType; } + public void setCapabilityType(String capabilityType) { this.capabilityType = capabilityType; } + public BigInteger getTargetId() { return targetId; } + public void setTargetId(BigInteger targetId) { this.targetId = targetId; } + public String getTargetLogicalRef() { return targetLogicalRef; } + public void setTargetLogicalRef(String targetLogicalRef) { this.targetLogicalRef = targetLogicalRef; } + public String getRuntimeName() { return runtimeName; } + public void setRuntimeName(String runtimeName) { this.runtimeName = runtimeName; } + public Boolean getEnabled() { return enabled; } + public void setEnabled(Boolean enabled) { this.enabled = enabled; } + public String getSelectionMode() { return selectionMode; } + public void setSelectionMode(String selectionMode) { this.selectionMode = selectionMode; } + public List getSelectedToolNamesJson() { return selectedToolNamesJson; } + public void setSelectedToolNamesJson(List selectedToolNamesJson) { this.selectedToolNamesJson = selectedToolNamesJson == null ? new ArrayList<>() : new ArrayList<>(selectedToolNamesJson); } + public String getExecutionMode() { return executionMode; } + public void setExecutionMode(String executionMode) { this.executionMode = executionMode; } + public Boolean getHitlEnabled() { return hitlEnabled; } + public void setHitlEnabled(Boolean hitlEnabled) { this.hitlEnabled = hitlEnabled; } + public Map getHitlConfigJson() { return hitlConfigJson; } + public void setHitlConfigJson(Map hitlConfigJson) { this.hitlConfigJson = hitlConfigJson == null ? new LinkedHashMap<>() : hitlConfigJson; } + public Map getOptionsJson() { return optionsJson; } + public void setOptionsJson(Map optionsJson) { this.optionsJson = optionsJson == null ? new LinkedHashMap<>() : optionsJson; } + public Integer getSortNo() { return sortNo; } + public void setSortNo(Integer sortNo) { this.sortNo = sortNo; } + @Override public Date getCreated() { return created; } + @Override public void setCreated(Date created) { this.created = created; } + public BigInteger getCreatedBy() { return createdBy; } + public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; } + @Override public Date getModified() { return modified; } + @Override public void setModified(Date modified) { this.modified = modified; } + public BigInteger getModifiedBy() { return modifiedBy; } + public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; } + public String getTargetName() { return targetName; } + public void setTargetName(String targetName) { this.targetName = targetName; } + public String getTargetStatus() { return targetStatus; } + public void setTargetStatus(String targetStatus) { this.targetStatus = targetStatus; } + public List getResolvedToolNames() { return resolvedToolNames; } + public void setResolvedToolNames(List resolvedToolNames) { this.resolvedToolNames = resolvedToolNames == null ? new ArrayList<>() : new ArrayList<>(resolvedToolNames); } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillCategory.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillCategory.java index b0702fb9..49bc53f5 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillCategory.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillCategory.java @@ -9,6 +9,8 @@ import tech.easyflow.common.entity.DateEntity; import java.io.Serializable; import java.math.BigInteger; import java.util.Date; +import java.util.ArrayList; +import java.util.List; /** * Skill 分类实体。 @@ -32,6 +34,8 @@ public class SkillCategory extends DateEntity implements Serializable { private BigInteger createdBy; private Date modified; private BigInteger modifiedBy; + @Column(ignore = true) + private List children = new ArrayList<>(); public BigInteger getId() { return id; } public void setId(BigInteger id) { this.id = id; } @@ -57,4 +61,6 @@ public class SkillCategory extends DateEntity implements Serializable { @Override public void setModified(Date modified) { this.modified = modified; } public BigInteger getModifiedBy() { return modifiedBy; } public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; } + public List getChildren() { return children; } + public void setChildren(List children) { this.children = children == null ? new ArrayList<>() : children; } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillContent.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillContent.java new file mode 100644 index 00000000..1f248265 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillContent.java @@ -0,0 +1,198 @@ +package tech.easyflow.skill.entity; + +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.Table; + +import java.io.Serializable; +import java.util.Date; + +/** + * Skill 二进制内容索引实体。 + */ +@Table("tb_skill_content") +public class SkillContent implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 内容引用。 */ + @Id + private String contentRef; + /** 内容哈希。 */ + private String contentHash; + /** 文件存储返回的读取路径。 */ + private String filePath; + /** 可在写入前确定的稳定存储定位符。 */ + private String storageLocator; + /** 媒体类型。 */ + private String mediaType; + /** 内容字节数。 */ + private Long size; + /** 当前引用数。 */ + private Integer refCount; + /** 创建时间。 */ + private Date created; + /** 修改时间。 */ + private Date modified; + + /** + * 获取内容引用。 + * + * @return 内容引用 + */ + public String getContentRef() { + return contentRef; + } + + /** + * 设置内容引用。 + * + * @param contentRef 内容引用 + */ + public void setContentRef(String contentRef) { + this.contentRef = contentRef; + } + + /** + * 获取内容哈希。 + * + * @return 内容哈希 + */ + public String getContentHash() { + return contentHash; + } + + /** + * 设置内容哈希。 + * + * @param contentHash 内容哈希 + */ + public void setContentHash(String contentHash) { + this.contentHash = contentHash; + } + + /** + * 获取文件读取路径。 + * + * @return 文件读取路径 + */ + public String getFilePath() { + return filePath; + } + + /** + * 设置文件读取路径。 + * + * @param filePath 文件读取路径 + */ + public void setFilePath(String filePath) { + this.filePath = filePath; + } + + /** + * 获取稳定存储定位符。 + * + * @return 稳定存储定位符 + */ + public String getStorageLocator() { + return storageLocator; + } + + /** + * 设置稳定存储定位符。 + * + * @param storageLocator 稳定存储定位符 + */ + public void setStorageLocator(String storageLocator) { + this.storageLocator = storageLocator; + } + + /** + * 获取媒体类型。 + * + * @return 媒体类型 + */ + public String getMediaType() { + return mediaType; + } + + /** + * 设置媒体类型。 + * + * @param mediaType 媒体类型 + */ + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + /** + * 获取内容字节数。 + * + * @return 内容字节数 + */ + public Long getSize() { + return size; + } + + /** + * 设置内容字节数。 + * + * @param size 内容字节数 + */ + public void setSize(Long size) { + this.size = size; + } + + /** + * 获取当前引用数。 + * + * @return 当前引用数 + */ + public Integer getRefCount() { + return refCount; + } + + /** + * 设置当前引用数。 + * + * @param refCount 当前引用数 + */ + public void setRefCount(Integer refCount) { + this.refCount = refCount; + } + + /** + * 获取创建时间。 + * + * @return 创建时间 + */ + public Date getCreated() { + return created; + } + + /** + * 设置创建时间。 + * + * @param created 创建时间 + */ + public void setCreated(Date created) { + this.created = created; + } + + /** + * 获取修改时间。 + * + * @return 修改时间 + */ + public Date getModified() { + return modified; + } + + /** + * 设置修改时间。 + * + * @param modified 修改时间 + */ + public void setModified(Date modified) { + this.modified = modified; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillContentWriteIntent.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillContentWriteIntent.java new file mode 100644 index 00000000..f8df2c74 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillContentWriteIntent.java @@ -0,0 +1,200 @@ +package tech.easyflow.skill.entity; + +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.Table; + +import java.io.Serializable; +import java.util.Date; + +/** + * Skill 二进制内容写入意图实体。 + * + *

写入意图独立于正式内容索引提交,用于在进程异常退出后定位尚未激活的物理对象。

+ */ +@Table("tb_skill_content_write_intent") +public class SkillContentWriteIntent implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 内容引用。 */ + @Id + private String contentRef; + /** 写入预留令牌。 */ + private String reservationToken; + /** 内容哈希。 */ + private String contentHash; + /** 可在写入前确定的稳定存储定位符。 */ + private String storageLocator; + /** 媒体类型。 */ + private String mediaType; + /** 内容字节数。 */ + private Long size; + /** PENDING、WRITING 或 CLEANING 状态。 */ + private String state; + /** 创建时间。 */ + private Date created; + /** 修改时间。 */ + private Date modified; + + /** + * 获取内容引用。 + * + * @return 内容引用 + */ + public String getContentRef() { + return contentRef; + } + + /** + * 设置内容引用。 + * + * @param contentRef 内容引用 + */ + public void setContentRef(String contentRef) { + this.contentRef = contentRef; + } + + /** + * 获取写入预留令牌。 + * + * @return 写入预留令牌 + */ + public String getReservationToken() { + return reservationToken; + } + + /** + * 设置写入预留令牌。 + * + * @param reservationToken 写入预留令牌 + */ + public void setReservationToken(String reservationToken) { + this.reservationToken = reservationToken; + } + + /** + * 获取内容哈希。 + * + * @return 内容哈希 + */ + public String getContentHash() { + return contentHash; + } + + /** + * 设置内容哈希。 + * + * @param contentHash 内容哈希 + */ + public void setContentHash(String contentHash) { + this.contentHash = contentHash; + } + + /** + * 获取稳定存储定位符。 + * + * @return 稳定存储定位符 + */ + public String getStorageLocator() { + return storageLocator; + } + + /** + * 设置稳定存储定位符。 + * + * @param storageLocator 稳定存储定位符 + */ + public void setStorageLocator(String storageLocator) { + this.storageLocator = storageLocator; + } + + /** + * 获取媒体类型。 + * + * @return 媒体类型 + */ + public String getMediaType() { + return mediaType; + } + + /** + * 设置媒体类型。 + * + * @param mediaType 媒体类型 + */ + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + /** + * 获取内容字节数。 + * + * @return 内容字节数 + */ + public Long getSize() { + return size; + } + + /** + * 设置内容字节数。 + * + * @param size 内容字节数 + */ + public void setSize(Long size) { + this.size = size; + } + + /** + * 获取写入状态。 + * + * @return 写入状态 + */ + public String getState() { + return state; + } + + /** + * 设置写入状态。 + * + * @param state 写入状态 + */ + public void setState(String state) { + this.state = state; + } + + /** + * 获取创建时间。 + * + * @return 创建时间 + */ + public Date getCreated() { + return created; + } + + /** + * 设置创建时间。 + * + * @param created 创建时间 + */ + public void setCreated(Date created) { + this.created = created; + } + + /** + * 获取修改时间。 + * + * @return 修改时间 + */ + public Date getModified() { + return modified; + } + + /** + * 设置修改时间。 + * + * @param modified 修改时间 + */ + public void setModified(Date modified) { + this.modified = modified; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillImportStage.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillImportStage.java new file mode 100644 index 00000000..8e8b13f6 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillImportStage.java @@ -0,0 +1,49 @@ +package tech.easyflow.skill.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.Table; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.Date; + +/** + * Skill 导入临时包索引。 + */ +@Table("tb_skill_import_stage") +public class SkillImportStage implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + private String importToken; + @Column(tenantId = true) + private BigInteger tenantId; + private BigInteger accountId; + private String filePath; + private String originalName; + private String format; + private String status; + private Date expiresAt; + private Date created; + + public String getImportToken() { return importToken; } + public void setImportToken(String importToken) { this.importToken = importToken; } + public BigInteger getTenantId() { return tenantId; } + public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; } + public BigInteger getAccountId() { return accountId; } + public void setAccountId(BigInteger accountId) { this.accountId = accountId; } + public String getFilePath() { return filePath; } + public void setFilePath(String filePath) { this.filePath = filePath; } + public String getOriginalName() { return originalName; } + public void setOriginalName(String originalName) { this.originalName = originalName; } + public String getFormat() { return format; } + public void setFormat(String format) { this.format = format; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public Date getExpiresAt() { return expiresAt; } + public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; } + public Date getCreated() { return created; } + public void setCreated(Date created) { this.created = created; } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillResource.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillResource.java new file mode 100644 index 00000000..f8be26d6 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillResource.java @@ -0,0 +1,85 @@ +package tech.easyflow.skill.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.handler.FastjsonTypeHandler; +import tech.easyflow.common.entity.DateEntity; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Skill 通用资源实体,统一承载文本与二进制包内文件。 + */ +@Table("tb_skill_resource") +public class SkillResource extends DateEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id(keyType = KeyType.Generator, value = "snowFlakeId") + private BigInteger id; + @Column(tenantId = true) + private BigInteger tenantId; + private BigInteger skillId; + private String path; + private String normalizedPath; + private String kind; + private String language; + private String mediaType; + private Boolean isText; + private String textContent; + private String contentRef; + private String contentHash; + private Long size; + @Column(typeHandler = FastjsonTypeHandler.class) + private Map metadataJson = new LinkedHashMap<>(); + private Integer sortNo; + private Date created; + private BigInteger createdBy; + private Date modified; + private BigInteger modifiedBy; + + public BigInteger getId() { return id; } + public void setId(BigInteger id) { this.id = id; } + public BigInteger getTenantId() { return tenantId; } + public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; } + public BigInteger getSkillId() { return skillId; } + public void setSkillId(BigInteger skillId) { this.skillId = skillId; } + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public String getNormalizedPath() { return normalizedPath; } + public void setNormalizedPath(String normalizedPath) { this.normalizedPath = normalizedPath; } + public String getKind() { return kind; } + public void setKind(String kind) { this.kind = kind; } + public String getLanguage() { return language; } + public void setLanguage(String language) { this.language = language; } + public String getMediaType() { return mediaType; } + public void setMediaType(String mediaType) { this.mediaType = mediaType; } + public Boolean getIsText() { return isText; } + public void setIsText(Boolean text) { isText = text; } + public String getTextContent() { return textContent; } + public void setTextContent(String textContent) { this.textContent = textContent; } + public String getContentRef() { return contentRef; } + public void setContentRef(String contentRef) { this.contentRef = contentRef; } + public String getContentHash() { return contentHash; } + public void setContentHash(String contentHash) { this.contentHash = contentHash; } + public Long getSize() { return size; } + public void setSize(Long size) { this.size = size; } + public Map getMetadataJson() { return metadataJson; } + public void setMetadataJson(Map metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; } + public Integer getSortNo() { return sortNo; } + public void setSortNo(Integer sortNo) { this.sortNo = sortNo; } + @Override public Date getCreated() { return created; } + @Override public void setCreated(Date created) { this.created = created; } + public BigInteger getCreatedBy() { return createdBy; } + public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; } + @Override public Date getModified() { return modified; } + @Override public void setModified(Date modified) { this.modified = modified; } + public BigInteger getModifiedBy() { return modifiedBy; } + public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilityExecutionMode.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilityExecutionMode.java new file mode 100644 index 00000000..9de1de43 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilityExecutionMode.java @@ -0,0 +1,30 @@ +package tech.easyflow.skill.enums; + +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.util.Locale; + +/** + * Skill 能力执行模式配置。 + */ +public enum SkillCapabilityExecutionMode { + SYNC, + ASYNC; + + /** + * 解析执行模式,空值默认同步。 + * + * @param value 模式编码 + * @return 执行模式 + */ + public static SkillCapabilityExecutionMode fromOrDefault(String value) { + if (value == null || value.isBlank()) { + return SYNC; + } + try { + return valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + throw new BusinessException("不支持的能力执行模式"); + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilitySelectionMode.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilitySelectionMode.java new file mode 100644 index 00000000..6ac545d7 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilitySelectionMode.java @@ -0,0 +1,30 @@ +package tech.easyflow.skill.enums; + +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.util.Locale; + +/** + * MCP 工具选择模式。 + */ +public enum SkillCapabilitySelectionMode { + ALL, + SELECTED; + + /** + * 解析选择模式,空值默认全部。 + * + * @param value 模式编码 + * @return 选择模式 + */ + public static SkillCapabilitySelectionMode fromOrDefault(String value) { + if (value == null || value.isBlank()) { + return ALL; + } + try { + return valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + throw new BusinessException("不支持的 MCP 工具选择模式"); + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilityType.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilityType.java new file mode 100644 index 00000000..d15ce476 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilityType.java @@ -0,0 +1,31 @@ +package tech.easyflow.skill.enums; + +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.util.Locale; + +/** + * Skill 可绑定的平台能力类型。 + */ +public enum SkillCapabilityType { + WORKFLOW, + PLUGIN_ITEM, + MCP; + + /** + * 解析能力类型。 + * + * @param value 类型编码 + * @return 能力类型 + */ + public static SkillCapabilityType from(String value) { + if (value == null || value.isBlank()) { + throw new BusinessException("能力类型不能为空"); + } + try { + return valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + throw new BusinessException("不支持的能力类型"); + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileContent.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileContent.java index 36f27e43..c9780a9b 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileContent.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileContent.java @@ -10,8 +10,9 @@ public class SkillFileContent { private String content; private String language; private String mediaType; + private Boolean isText; private Long size; - private String downloadUrl; + private String contentHash; public String getPath() { return path; } public void setPath(String path) { this.path = path; } @@ -23,9 +24,10 @@ public class SkillFileContent { public void setLanguage(String language) { this.language = language; } public String getMediaType() { return mediaType; } public void setMediaType(String mediaType) { this.mediaType = mediaType; } + public Boolean getIsText() { return isText; } + public void setIsText(Boolean text) { isText = text; } public Long getSize() { return size; } public void setSize(Long size) { this.size = size; } - public String getDownloadUrl() { return downloadUrl; } - public void setDownloadUrl(String downloadUrl) { this.downloadUrl = downloadUrl; } + public String getContentHash() { return contentHash; } + public void setContentHash(String contentHash) { this.contentHash = contentHash; } } - diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileNode.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileNode.java index 61edc5fc..c6703e6c 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileNode.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileNode.java @@ -14,7 +14,9 @@ public class SkillFileNode { private String type; private String language; private String mediaType; + private Boolean isText; private Long size; + private String contentHash; private List children = new ArrayList<>(); public String getKey() { return key; } @@ -29,9 +31,12 @@ public class SkillFileNode { public void setLanguage(String language) { this.language = language; } public String getMediaType() { return mediaType; } public void setMediaType(String mediaType) { this.mediaType = mediaType; } + public Boolean getIsText() { return isText; } + public void setIsText(Boolean text) { isText = text; } public Long getSize() { return size; } public void setSize(Long size) { this.size = size; } + public String getContentHash() { return contentHash; } + public void setContentHash(String contentHash) { this.contentHash = contentHash; } public List getChildren() { return children; } public void setChildren(List children) { this.children = children == null ? new ArrayList<>() : children; } } - diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileRenameRequest.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileRenameRequest.java new file mode 100644 index 00000000..41a8a161 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileRenameRequest.java @@ -0,0 +1,23 @@ +package tech.easyflow.skill.file; + +import java.math.BigInteger; + +/** + * Skill 资源重命名请求。 + */ +public class SkillFileRenameRequest { + + private BigInteger skillId; + private String path; + private String newPath; + private String expectedContentHash; + + public BigInteger getSkillId() { return skillId; } + public void setSkillId(BigInteger skillId) { this.skillId = skillId; } + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public String getNewPath() { return newPath; } + public void setNewPath(String newPath) { this.newPath = newPath; } + public String getExpectedContentHash() { return expectedContentHash; } + public void setExpectedContentHash(String expectedContentHash) { this.expectedContentHash = expectedContentHash; } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileSaveRequest.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileSaveRequest.java index ff9957a7..a497dc67 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileSaveRequest.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileSaveRequest.java @@ -10,6 +10,7 @@ public class SkillFileSaveRequest { private BigInteger skillId; private String path; private String content; + private String expectedContentHash; public BigInteger getSkillId() { return skillId; } public void setSkillId(BigInteger skillId) { this.skillId = skillId; } @@ -17,5 +18,6 @@ public class SkillFileSaveRequest { public void setPath(String path) { this.path = path; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } + public String getExpectedContentHash() { return expectedContentHash; } + public void setExpectedContentHash(String expectedContentHash) { this.expectedContentHash = expectedContentHash; } } - diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileService.java index 3c15583e..53f254c8 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileService.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileService.java @@ -36,6 +36,22 @@ public interface SkillFileService { */ SkillFileContent saveContent(SkillFileSaveRequest request); + /** + * 创建 Skill 文本资源。 + * + * @param request 创建请求 + * @return 创建后的文件内容 + */ + SkillFileContent createTextFile(SkillFileSaveRequest request); + + /** + * 重命名 Skill 资源。 + * + * @param request 重命名请求 + * @return 重命名后的文件内容 + */ + SkillFileContent renameFile(SkillFileRenameRequest request); + /** * 删除逻辑文件。 * @@ -44,6 +60,15 @@ public interface SkillFileService { */ void deleteFile(BigInteger skillId, String path); + /** + * 按客户端读取到的内容 hash 删除逻辑文件。 + * + * @param skillId Skill ID + * @param path 逻辑路径 + * @param expectedContentHash 客户端读取到的内容 hash + */ + void deleteFile(BigInteger skillId, String path, String expectedContentHash); + /** * 上传 asset 文件。 * @@ -54,6 +79,30 @@ public interface SkillFileService { */ SkillFileContent uploadAsset(BigInteger skillId, String path, MultipartFile file); + /** + * 上传任意安全的二进制资源。 + * + * @param skillId Skill ID + * @param path 目标逻辑路径 + * @param file 上传文件 + * @return 保存后的资源内容 + */ + SkillFileContent uploadResource(BigInteger skillId, String path, MultipartFile file); + + /** + * 上传或按内容 hash 原子替换二进制资源。 + * + * @param skillId Skill ID + * @param path 目标逻辑路径 + * @param file 上传文件 + * @param expectedContentHash 已有路径的客户端内容 hash;新路径为空 + * @return 保存后的资源内容 + */ + SkillFileContent uploadResource(BigInteger skillId, + String path, + MultipartFile file, + String expectedContentHash); + /** * 打开 asset 输入流。 * @@ -62,5 +111,13 @@ public interface SkillFileService { * @return asset 输入流 */ InputStream openAsset(BigInteger skillId, String path); -} + /** + * 打开二进制资源输入流。 + * + * @param skillId Skill ID + * @param path 逻辑路径 + * @return 输入流,调用方负责关闭 + */ + InputStream openResource(BigInteger skillId, String path); +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileServiceImpl.java index 014f3186..881c8663 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileServiceImpl.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileServiceImpl.java @@ -1,71 +1,76 @@ package tech.easyflow.skill.file; +import com.easyagents.skill.exception.SkillException; +import com.easyagents.skill.model.SkillResourceKind; import com.easyagents.skill.model.SkillScriptLanguage; import com.easyagents.skill.util.SkillHashes; import com.easyagents.skill.util.SkillPaths; +import com.easyagents.skill.util.SkillResources; import com.mybatisflex.core.query.QueryWrapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.skill.entity.Skill; -import tech.easyflow.skill.entity.SkillAsset; -import tech.easyflow.skill.entity.SkillReference; -import tech.easyflow.skill.entity.SkillScript; -import tech.easyflow.skill.service.SkillAssetService; -import tech.easyflow.skill.service.SkillReferenceService; -import tech.easyflow.skill.service.SkillScriptService; +import tech.easyflow.skill.entity.SkillResource; +import tech.easyflow.skill.service.SkillResourceService; import tech.easyflow.skill.service.SkillService; import tech.easyflow.skill.store.DBSkillContentStore; import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.enums.ResourceAction; import tech.easyflow.system.service.ResourceAccessService; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.math.BigInteger; import java.net.URLConnection; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; +import java.text.Normalizer; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; /** - * Skill 逻辑文件服务实现。 + * 基于通用资源表的 Skill 文件工作台服务。 */ @Service public class SkillFileServiceImpl implements SkillFileService { + private static final Logger LOG = LoggerFactory.getLogger(SkillFileServiceImpl.class); private static final String DEFAULT_MEDIA_TYPE = "application/octet-stream"; + private static final long MAX_TEXT_RESOURCE_BYTES = 2L * 1024 * 1024; + private static final long MAX_BINARY_RESOURCE_BYTES = 50L * 1024 * 1024; + private static final List STANDARD_DIRECTORIES = List.of("references", "scripts", "assets"); private final SkillService skillService; - private final SkillReferenceService skillReferenceService; - private final SkillScriptService skillScriptService; - private final SkillAssetService skillAssetService; + private final SkillResourceService skillResourceService; private final DBSkillContentStore contentStore; private final ResourceAccessService resourceAccessService; /** - * 创建 Skill 逻辑文件服务。 + * 创建 Skill 文件服务。 * * @param skillService Skill 服务 - * @param skillReferenceService reference 服务 - * @param skillScriptService script 服务 - * @param skillAssetService asset 服务 - * @param contentStore asset 内容存储 + * @param skillResourceService 通用资源服务 + * @param contentStore 二进制内容仓库 * @param resourceAccessService 资源访问服务 */ public SkillFileServiceImpl(SkillService skillService, - SkillReferenceService skillReferenceService, - SkillScriptService skillScriptService, - SkillAssetService skillAssetService, + SkillResourceService skillResourceService, DBSkillContentStore contentStore, ResourceAccessService resourceAccessService) { this.skillService = skillService; - this.skillReferenceService = skillReferenceService; - this.skillScriptService = skillScriptService; - this.skillAssetService = skillAssetService; + this.skillResourceService = skillResourceService; this.contentStore = contentStore; this.resourceAccessService = resourceAccessService; } @@ -76,26 +81,21 @@ public class SkillFileServiceImpl implements SkillFileService { @Override public List tree(BigInteger skillId) { Skill skill = requireReadableSkill(skillId); + SkillFileNode skillFile = fileNode(SkillPaths.SKILL_FILE, SkillFileType.SKILL.name(), null, null, + true, (long) bytes(skill.getSkillContent()).length, SkillHashes.sha256Hex(bytes(skill.getSkillContent()))); + List flatResources = listResourceDescriptors(skillId).stream().map(this::fileNode).toList(); List roots = new ArrayList<>(); - roots.add(fileNode(SkillPaths.SKILL_FILE, "SKILL.md", SkillFileType.SKILL.name())); - roots.add(directoryNode(SkillPaths.REFERENCES_DIR, skill.getReferences().stream() - .map(item -> fileNode(item.getPath(), SkillPaths.fileName(item.getPath()), SkillFileType.REFERENCE.name())) - .toList())); - roots.add(directoryNode(SkillPaths.SCRIPTS_DIR, skill.getScripts().stream() - .map(item -> { - SkillFileNode node = fileNode(item.getPath(), SkillPaths.fileName(item.getPath()), SkillFileType.SCRIPT.name()); - node.setLanguage(item.getLanguage()); - return node; - }) - .toList())); - roots.add(directoryNode(SkillPaths.ASSETS_DIR, skill.getAssets().stream() - .map(item -> { - SkillFileNode node = fileNode(item.getPath(), SkillPaths.fileName(item.getPath()), SkillFileType.ASSET.name()); - node.setMediaType(item.getMediaType()); - node.setSize(item.getSize()); - return node; - }) - .toList())); + roots.add(skillFile); + List resourceRoots = new ArrayList<>(toNestedTree(flatResources)); + for (String directory : STANDARD_DIRECTORIES) { + SkillFileNode standardRoot = resourceRoots.stream() + .filter(node -> directory.equals(node.getPath()) && "DIRECTORY".equals(node.getType())) + .findFirst() + .orElseGet(() -> directoryNode(directory, directory)); + roots.add(standardRoot); + resourceRoots.remove(standardRoot); + } + roots.addAll(resourceRoots); return roots; } @@ -105,46 +105,20 @@ public class SkillFileServiceImpl implements SkillFileService { @Override public SkillFileContent getContent(BigInteger skillId, String path) { Skill skill = requireReadableSkill(skillId); - String normalizedPath = SkillPaths.normalize(path); + String normalizedPath = normalizePath(path); if (SkillPaths.SKILL_FILE.equals(normalizedPath)) { - SkillFileContent content = new SkillFileContent(); - content.setPath(SkillPaths.SKILL_FILE); - content.setType(SkillFileType.SKILL.name()); - content.setContent(skill.getSkillContent()); - content.setSize((long) bytes(skill.getSkillContent()).length); - return content; + byte[] bytes = bytes(skill.getSkillContent()); + SkillFileContent result = new SkillFileContent(); + result.setPath(normalizedPath); + result.setType(SkillFileType.SKILL.name()); + result.setContent(skill.getSkillContent()); + result.setMediaType("text/markdown"); + result.setIsText(true); + result.setSize((long) bytes.length); + result.setContentHash(SkillHashes.sha256Hex(bytes)); + return result; } - String topDir = SkillPaths.firstSegment(normalizedPath); - if (SkillPaths.REFERENCES_DIR.equals(topDir)) { - SkillReference reference = requireReference(skillId, normalizedPath); - SkillFileContent content = new SkillFileContent(); - content.setPath(reference.getPath()); - content.setType(SkillFileType.REFERENCE.name()); - content.setContent(reference.getContent()); - content.setSize(reference.getSize()); - return content; - } - if (SkillPaths.SCRIPTS_DIR.equals(topDir)) { - SkillScript script = requireScript(skillId, normalizedPath); - SkillFileContent content = new SkillFileContent(); - content.setPath(script.getPath()); - content.setType(SkillFileType.SCRIPT.name()); - content.setContent(script.getContent()); - content.setLanguage(script.getLanguage()); - content.setSize(script.getSize()); - return content; - } - if (SkillPaths.ASSETS_DIR.equals(topDir)) { - SkillAsset asset = requireAsset(skillId, normalizedPath); - SkillFileContent content = new SkillFileContent(); - content.setPath(asset.getPath()); - content.setType(SkillFileType.ASSET.name()); - content.setMediaType(asset.getMediaType()); - content.setSize(asset.getSize()); - content.setDownloadUrl("/api/v1/skill/file/asset"); - return content; - } - throw new BusinessException("不支持的 Skill 文件路径"); + return toContent(requireResource(skillId, normalizedPath)); } /** @@ -153,29 +127,125 @@ public class SkillFileServiceImpl implements SkillFileService { @Override @Transactional(rollbackFor = Exception.class) public SkillFileContent saveContent(SkillFileSaveRequest request) { + validateSaveRequest(request); + Skill skill = requireManageSkill(request.getSkillId()); + String normalizedPath = normalizePath(request.getPath()); + String content = request.getContent() == null ? "" : request.getContent(); + if (SkillPaths.SKILL_FILE.equals(normalizedPath)) { + Skill update = detachedContentUpdate(skill, content); + skillService.updateDraftIfContentMatches(update, request.getExpectedContentHash()); + return getContent(skill.getId(), normalizedPath); + } + SkillResource existing = requireResource(skill.getId(), normalizedPath); + assertExpectedHash(request.getExpectedContentHash(), existing.getContentHash()); + saveTextResource(skill, existing, normalizedPath, content, request.getExpectedContentHash()); + return getContent(skill.getId(), normalizedPath); + } + + /** + * 构造与当前 MyBatis 会话实体隔离的 SKILL.md 更新对象。 + * + *

文件保存入口已经读取并锁定 Skill。若直接修改该实体,同一事务中的后续查询可能从 + * MyBatis 一级缓存取得同一对象,使乐观并发校验误把新内容当作数据库旧版本。

+ * + * @param source 当前持久化 Skill + * @param content 新的 SKILL.md 内容 + * @return 保留管理配置且与持久化实体隔离的更新对象 + */ + private Skill detachedContentUpdate(Skill source, String content) { + Skill update = new Skill(); + update.setId(source.getId()); + update.setCategoryId(source.getCategoryId()); + update.setDisplayName(source.getDisplayName()); + update.setMetadataJson(source.getMetadataJson() == null + ? null : new LinkedHashMap<>(source.getMetadataJson())); + update.setSkillContent(content); + update.setEnabled(source.getEnabled()); + update.setVisibilityScope(source.getVisibilityScope()); + return update; + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public SkillFileContent createTextFile(SkillFileSaveRequest request) { + validateSaveRequest(request); + Skill skill = requireManageSkill(request.getSkillId()); + String normalizedPath = normalizePath(request.getPath()); + if (SkillPaths.SKILL_FILE.equals(normalizedPath)) { + throw new BusinessException("SKILL.md 已存在,不能重复创建"); + } + if (findResource(skill.getId(), normalizedPath) != null) { + throw conflict("Skill 资源路径已存在:" + normalizedPath); + } + assertNoCanonicalCollision(skill.getId(), normalizedPath, null); + saveTextResource(skill, null, normalizedPath, request.getContent() == null ? "" : request.getContent(), null); + return getContent(skill.getId(), normalizedPath); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public SkillFileContent renameFile(SkillFileRenameRequest request) { if (request == null || request.getSkillId() == null) { throw new BusinessException("Skill ID 不能为空"); } - Skill skill = requireManageSkill(request.getSkillId()); - String normalizedPath = SkillPaths.normalize(request.getPath()); - String content = request.getContent() == null ? "" : request.getContent(); - if (SkillPaths.SKILL_FILE.equals(normalizedPath)) { - skill.setSkillContent(content); - skillService.updateDraft(skill); - return getContent(skill.getId(), SkillPaths.SKILL_FILE); + requireManageSkill(request.getSkillId()); + String sourcePath = normalizePath(request.getPath()); + String targetPath = normalizePath(request.getNewPath()); + if (SkillPaths.SKILL_FILE.equals(sourcePath) || SkillPaths.SKILL_FILE.equals(targetPath)) { + throw new BusinessException("SKILL.md 不允许重命名"); } - String topDir = SkillPaths.firstSegment(normalizedPath); - if (SkillPaths.REFERENCES_DIR.equals(topDir)) { - saveReference(skill, normalizedPath, content); - refreshSkillCounts(skill.getId()); - return getContent(skill.getId(), normalizedPath); + if (findResource(request.getSkillId(), targetPath) != null) { + throw conflict("目标资源路径已存在:" + targetPath); } - if (SkillPaths.SCRIPTS_DIR.equals(topDir)) { - saveScript(skill, normalizedPath, content); - refreshSkillCounts(skill.getId()); - return getContent(skill.getId(), normalizedPath); + SkillResource resource = requireResource(request.getSkillId(), sourcePath); + assertExpectedHash(request.getExpectedContentHash(), resource.getContentHash()); + assertNoCanonicalCollision(request.getSkillId(), targetPath, resource.getId()); + String releasedContentRef = null; + boolean targetText = shouldStoreAsText(targetPath); + boolean sourceText = Boolean.TRUE.equals(resource.getIsText()); + if (targetText && !sourceText) { + releasedContentRef = resource.getContentRef(); + String textContent = readStrictUtf8Content(resource, targetPath); + byte[] contentBytes = bytes(textContent); + resource.setIsText(true); + resource.setTextContent(textContent); + resource.setContentRef(null); + resource.setContentHash(SkillHashes.sha256Hex(contentBytes)); + resource.setSize((long) contentBytes.length); + } else if (!targetText && sourceText) { + byte[] contentBytes = bytes(resource.getTextContent()); + String contentRef = contentStore.put(contentBytes); + resource.setIsText(false); + resource.setTextContent(null); + resource.setContentRef(contentRef); + resource.setContentHash(contentRef.substring("sha256:".length())); + resource.setSize((long) contentBytes.length); } - throw new BusinessException("仅支持保存 SKILL.md、references/*.md 和 scripts/*.py|*.js|*.sh"); + boolean text = Boolean.TRUE.equals(resource.getIsText()); + resource.setPath(targetPath); + resource.setNormalizedPath(targetPath); + resource.setKind(SkillResources.classify(targetPath).name()); + resource.setLanguage(resolveLanguage(targetPath, resource.getKind())); + resource.setMediaType(resolveMediaType(targetPath, resource.getKind(), text)); + try { + if (!skillResourceService.update(resource, tenantResourceQuery(resource.getSkillId(), resource.getId()) + .eq(SkillResource::getContentHash, request.getExpectedContentHash()))) { + throw conflict("文件已被其他操作更新,请重新加载后再重命名"); + } + } catch (DuplicateKeyException exception) { + throw conflict("目标资源路径已存在或发生大小写冲突:" + targetPath); + } + if (releasedContentRef != null) { + contentStore.release(releasedContentRef); + } + skillService.refreshPackageState(request.getSkillId()); + return getContent(request.getSkillId(), targetPath); } /** @@ -184,22 +254,30 @@ public class SkillFileServiceImpl implements SkillFileService { @Override @Transactional(rollbackFor = Exception.class) public void deleteFile(BigInteger skillId, String path) { + deleteFile(skillId, path, null); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteFile(BigInteger skillId, String path, String expectedContentHash) { requireManageSkill(skillId); - String normalizedPath = SkillPaths.normalize(path); + String normalizedPath = normalizePath(path); if (SkillPaths.SKILL_FILE.equals(normalizedPath)) { throw new BusinessException("SKILL.md 不允许删除"); } - String topDir = SkillPaths.firstSegment(normalizedPath); - if (SkillPaths.REFERENCES_DIR.equals(topDir)) { - skillReferenceService.remove(QueryWrapper.create().eq(SkillReference::getSkillId, skillId).eq(SkillReference::getPath, normalizedPath)); - } else if (SkillPaths.SCRIPTS_DIR.equals(topDir)) { - skillScriptService.remove(QueryWrapper.create().eq(SkillScript::getSkillId, skillId).eq(SkillScript::getPath, normalizedPath)); - } else if (SkillPaths.ASSETS_DIR.equals(topDir)) { - skillAssetService.remove(QueryWrapper.create().eq(SkillAsset::getSkillId, skillId).eq(SkillAsset::getPath, normalizedPath)); - } else { - throw new BusinessException("不支持的 Skill 文件路径"); + SkillResource resource = requireResource(skillId, normalizedPath); + assertExpectedHash(expectedContentHash, resource.getContentHash()); + if (!skillResourceService.remove(tenantResourceQuery(skillId, resource.getId()) + .eq(SkillResource::getContentHash, expectedContentHash))) { + throw conflict("文件已被其他操作更新,请重新加载后再删除"); } - refreshSkillCounts(skillId); + if (resource.getContentRef() != null) { + contentStore.release(resource.getContentRef()); + } + skillService.refreshPackageState(skillId); } /** @@ -208,35 +286,89 @@ public class SkillFileServiceImpl implements SkillFileService { @Override @Transactional(rollbackFor = Exception.class) public SkillFileContent uploadAsset(BigInteger skillId, String path, MultipartFile file) { + return uploadResource(skillId, path, file); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public SkillFileContent uploadResource(BigInteger skillId, String path, MultipartFile file) { + return uploadResource(skillId, path, file, null); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public SkillFileContent uploadResource(BigInteger skillId, + String path, + MultipartFile file, + String expectedContentHash) { Skill skill = requireManageSkill(skillId); - if (file == null || file.isEmpty()) { - throw new BusinessException("asset 文件不能为空"); - } - String normalizedPath = normalizeAssetPath(path, file.getOriginalFilename()); - try { - String contentRef = contentStore.put(file.getBytes()); - String hash = contentRef.substring("sha256:".length()); - SkillAsset asset = findAsset(skillId, normalizedPath); - if (asset == null) { - asset = new SkillAsset(); - asset.setTenantId(skill.getTenantId()); - asset.setSkillId(skillId); - asset.setPath(normalizedPath); - } - asset.setName(SkillPaths.fileName(normalizedPath)); - asset.setMediaType(detectMediaType(normalizedPath)); - asset.setContentRef(contentRef); - asset.setContentHash(hash); - asset.setSize(file.getSize()); - if (asset.getId() == null) { - skillAssetService.save(asset); + validateUpload(file); + String normalizedPath = normalizeUploadPath(path, file.getOriginalFilename()); + if (shouldStoreAsText(normalizedPath)) { + SkillResource resource = findResource(skillId, normalizedPath); + if (resource == null) { + assertNoCanonicalCollision(skillId, normalizedPath, null); } else { - skillAssetService.updateById(asset); + assertExpectedHash(expectedContentHash, resource.getContentHash()); } - refreshSkillCounts(skillId); + saveTextResource(skill, resource, normalizedPath, + readStrictUtf8(file, normalizedPath), expectedContentHash); return getContent(skillId, normalizedPath); - } catch (IOException e) { - throw new BusinessException("读取 asset 文件失败"); + } + String mediaType = detectMediaType(normalizedPath); + String newContentRef = null; + try { + newContentRef = contentStore.put(file, mediaType); + SkillResource resource = findResource(skillId, normalizedPath); + if (resource == null) { + assertNoCanonicalCollision(skillId, normalizedPath, null); + } else { + assertExpectedHash(expectedContentHash, resource.getContentHash()); + } + String oldContentRef = resource == null ? null : resource.getContentRef(); + if (resource == null) { + resource = new SkillResource(); + resource.setTenantId(skill.getTenantId()); + resource.setSkillId(skillId); + resource.setPath(normalizedPath); + resource.setNormalizedPath(normalizedPath); + } + resource.setKind(SkillResources.classify(normalizedPath).name()); + resource.setLanguage(null); + resource.setMediaType(mediaType); + resource.setIsText(false); + resource.setTextContent(null); + resource.setContentRef(newContentRef); + resource.setContentHash(newContentRef.substring("sha256:".length())); + resource.setSize(file.getSize()); + boolean saved; + try { + saved = resource.getId() == null + ? skillResourceService.save(resource) + : skillResourceService.update(resource, tenantResourceQuery(skillId, resource.getId()) + .eq(SkillResource::getContentHash, expectedContentHash)); + } catch (DuplicateKeyException exception) { + throw conflict("Skill 资源路径已存在或发生大小写冲突:" + normalizedPath); + } + if (!saved) { + if (resource.getId() != null) { + throw conflict("文件已被其他操作更新,请重新加载后再替换"); + } + throw new BusinessException(500, 500, "保存 Skill 二进制资源失败,请稍后重试"); + } + if (oldContentRef != null) { + contentStore.release(oldContentRef); + } + skillService.refreshPackageState(skillId); + return getContent(skillId, normalizedPath); + } catch (RuntimeException exception) { + throw exception; } } @@ -245,216 +377,483 @@ public class SkillFileServiceImpl implements SkillFileService { */ @Override public InputStream openAsset(BigInteger skillId, String path) { + return openResource(skillId, path); + } + + /** + * {@inheritDoc} + */ + @Override + public InputStream openResource(BigInteger skillId, String path) { requireReadableSkill(skillId); - SkillAsset asset = requireAsset(skillId, SkillPaths.normalize(path)); - return contentStore.open(asset.getContentRef()); + SkillResource resource = requireResource(skillId, normalizePath(path)); + if (resource.getContentRef() == null) { + throw new BusinessException("该 Skill 资源不是二进制文件"); + } + return contentStore.open(resource.getContentRef()); + } + + private void saveTextResource(Skill skill, + SkillResource resource, + String path, + String content, + String expectedContentHash) { + if (!shouldStoreAsText(path)) { + throw new BusinessException("该资源路径按二进制文件管理,请使用上传功能:" + path); + } + String oldContentRef = resource == null ? null : resource.getContentRef(); + if (resource == null) { + resource = new SkillResource(); + resource.setTenantId(skill.getTenantId()); + resource.setSkillId(skill.getId()); + resource.setPath(path); + resource.setNormalizedPath(path); + } + byte[] contentBytes = bytes(content); + SkillResourceKind kind = SkillResources.classify(path); + resource.setKind(kind.name()); + resource.setLanguage(resolveLanguage(path, kind.name())); + resource.setMediaType(resolveMediaType(path, kind.name(), true)); + resource.setIsText(true); + resource.setTextContent(content); + resource.setContentRef(null); + resource.setContentHash(SkillHashes.sha256Hex(contentBytes)); + resource.setSize((long) contentBytes.length); + boolean saved; + try { + if (resource.getId() == null) { + saved = skillResourceService.save(resource); + } else { + QueryWrapper updateQuery = tenantResourceQuery(skill.getId(), resource.getId()) + .eq(SkillResource::getContentHash, expectedContentHash); + saved = skillResourceService.update(resource, updateQuery); + } + } catch (DuplicateKeyException exception) { + throw conflict("Skill 资源路径已存在或发生大小写冲突:" + path); + } + if (!saved) { + if (resource.getId() != null) { + throw conflict("文件已被其他操作更新,请重新加载后合并内容"); + } + throw new BusinessException(500, 500, "保存 Skill 文本资源失败,请稍后重试"); + } + if (oldContentRef != null) { + contentStore.release(oldContentRef); + } + skillService.refreshPackageState(skill.getId()); } private Skill requireReadableSkill(BigInteger skillId) { Skill skill = requireSkill(skillId); - resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill"); - return skillService.getDetail(skillId); + resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ, + "无权限查看该 Skill"); + return skill; } private Skill requireManageSkill(BigInteger skillId) { - Skill skill = skillService.getDetail(skillId); - resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限管理该 Skill"); + Skill skill = requireSkill(skillId, true); + resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, + "无权限管理该 Skill"); return skill; } private Skill requireSkill(BigInteger skillId) { + return requireSkill(skillId, false); + } + + private Skill requireSkill(BigInteger skillId, boolean forUpdate) { if (skillId == null) { throw new BusinessException("Skill ID 不能为空"); } - Skill skill = skillService.getById(skillId); + tech.easyflow.common.entity.LoginAccount account = tech.easyflow.common.satoken.util.SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException(401, 401, "未登录或登录态无效"); + } + QueryWrapper query = QueryWrapper.create() + .eq(Skill::getId, skillId) + .eq(Skill::getTenantId, account.getTenantId()); + if (forUpdate) { + query.forUpdate(); + } + Skill skill = skillService.getOne(query); if (skill == null) { - throw new BusinessException("Skill 不存在"); + throw new BusinessException(404, 404, "Skill 不存在"); } return skill; } - private void saveReference(Skill skill, String path, String content) { - if (!SkillPaths.hasExtension(path, ".md")) { - throw new BusinessException("reference 仅支持 .md 文件"); - } - SkillReference reference = findReference(skill.getId(), path); - if (reference == null) { - reference = new SkillReference(); - reference.setTenantId(skill.getTenantId()); - reference.setSkillId(skill.getId()); - reference.setPath(path); - } - byte[] bytes = bytes(content); - reference.setName(SkillPaths.fileName(path)); - reference.setContent(content); - reference.setContentHash(SkillHashes.sha256Hex(bytes)); - reference.setSize((long) bytes.length); - if (reference.getId() == null) { - skillReferenceService.save(reference); - } else { - skillReferenceService.updateById(reference); - } + private List listResources(BigInteger skillId) { + return skillResourceService.list(QueryWrapper.create() + .eq(SkillResource::getTenantId, currentTenantId()) + .eq(SkillResource::getSkillId, skillId) + .orderBy("sort_no asc, normalized_path asc")); } - private void saveScript(Skill skill, String path, String content) { - SkillScriptLanguage language = SkillScriptLanguage.fromPath(path); - if (language == SkillScriptLanguage.UNKNOWN) { - throw new BusinessException("script 仅支持 .py、.js、.sh 文件"); - } - SkillScript script = findScript(skill.getId(), path); - if (script == null) { - script = new SkillScript(); - script.setTenantId(skill.getTenantId()); - script.setSkillId(skill.getId()); - script.setPath(path); - } - byte[] bytes = bytes(content); - script.setLanguage(language.name()); - script.setContent(content); - script.setContentHash(SkillHashes.sha256Hex(bytes)); - script.setSize((long) bytes.length); - if (script.getId() == null) { - skillScriptService.save(script); - } else { - skillScriptService.updateById(script); - } + private List listResourceDescriptors(BigInteger skillId) { + return skillResourceService.listDescriptors(skillId, currentTenantId()); } - private void refreshSkillCounts(BigInteger skillId) { - Skill skill = skillService.getDetail(skillId); - Skill update = new Skill(); - update.setId(skillId); - update.setReferenceCount(skill.getReferences() == null ? 0 : skill.getReferences().size()); - update.setScriptCount(skill.getScripts() == null ? 0 : skill.getScripts().size()); - update.setAssetCount(skill.getAssets() == null ? 0 : skill.getAssets().size()); - skillService.updateById(update); - } - - private SkillReference findReference(BigInteger skillId, String path) { - List records = skillReferenceService.list(QueryWrapper.create() - .eq(SkillReference::getSkillId, skillId) - .eq(SkillReference::getPath, path)); + private SkillResource findResource(BigInteger skillId, String path) { + List records = skillResourceService.list(QueryWrapper.create() + .eq(SkillResource::getTenantId, currentTenantId()) + .eq(SkillResource::getSkillId, skillId) + .eq(SkillResource::getNormalizedPath, path) + .limit(1)); return records.isEmpty() ? null : records.get(0); } - private SkillReference requireReference(BigInteger skillId, String path) { - SkillReference reference = findReference(skillId, path); - if (reference == null) { - throw new BusinessException("reference 文件不存在"); + private QueryWrapper tenantResourceQuery(BigInteger skillId, BigInteger resourceId) { + return QueryWrapper.create() + .eq(SkillResource::getTenantId, currentTenantId()) + .eq(SkillResource::getSkillId, skillId) + .eq(SkillResource::getId, resourceId); + } + + private BigInteger currentTenantId() { + tech.easyflow.common.entity.LoginAccount account = tech.easyflow.common.satoken.util.SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException(401, 401, "未登录或登录态无效"); } - return reference; + return account.getTenantId(); } - private SkillScript findScript(BigInteger skillId, String path) { - List records = skillScriptService.list(QueryWrapper.create() - .eq(SkillScript::getSkillId, skillId) - .eq(SkillScript::getPath, path)); - return records.isEmpty() ? null : records.get(0); - } - - private SkillScript requireScript(BigInteger skillId, String path) { - SkillScript script = findScript(skillId, path); - if (script == null) { - throw new BusinessException("script 文件不存在"); + private SkillResource requireResource(BigInteger skillId, String path) { + SkillResource resource = findResource(skillId, path); + if (resource == null) { + throw new BusinessException(404, 404, "Skill 资源不存在:" + path); } - return script; + return resource; } - private SkillAsset findAsset(BigInteger skillId, String path) { - List records = skillAssetService.list(QueryWrapper.create() - .eq(SkillAsset::getSkillId, skillId) - .eq(SkillAsset::getPath, path)); - return records.isEmpty() ? null : records.get(0); + private SkillFileContent toContent(SkillResource resource) { + SkillFileContent content = new SkillFileContent(); + content.setPath(resource.getNormalizedPath()); + content.setType(resource.getKind()); + content.setContent(resource.getTextContent()); + content.setLanguage(resource.getLanguage()); + content.setMediaType(resource.getMediaType()); + content.setIsText(resource.getIsText()); + content.setSize(resource.getSize()); + content.setContentHash(resource.getContentHash()); + return content; } - private SkillAsset requireAsset(BigInteger skillId, String path) { - SkillAsset asset = findAsset(skillId, path); - if (asset == null) { - throw new BusinessException("asset 文件不存在"); + private SkillFileNode fileNode(SkillResource resource) { + return fileNode(resource.getNormalizedPath(), resource.getKind(), resource.getLanguage(), + resource.getMediaType(), resource.getIsText(), resource.getSize(), resource.getContentHash()); + } + + private SkillFileNode fileNode(String path, String type, String language, + String mediaType, Boolean isText, Long size, String contentHash) { + SkillFileNode node = new SkillFileNode(); + node.setKey(path); + node.setPath(path); + node.setName(SkillPaths.fileName(path)); + node.setType(type); + node.setLanguage(language); + node.setMediaType(mediaType); + node.setIsText(isText); + node.setSize(size); + node.setContentHash(contentHash); + return node; + } + + private List toNestedTree(List flatFiles) { + Map nodes = new LinkedHashMap<>(); + List roots = new ArrayList<>(); + for (SkillFileNode file : flatFiles) { + String[] segments = file.getPath().split("/"); + String current = ""; + SkillFileNode parent = null; + for (int index = 0; index < segments.length; index++) { + current = current.isEmpty() ? segments[index] : current + "/" + segments[index]; + boolean leaf = index == segments.length - 1; + String segmentName = segments[index]; + SkillFileNode node = leaf ? file : nodes.computeIfAbsent(current, key -> directoryNode(key, segmentName)); + nodes.putIfAbsent(current, node); + if (parent == null) { + if (!roots.contains(node)) { + roots.add(node); + } + } else if (!parent.getChildren().contains(node)) { + parent.getChildren().add(node); + } + parent = node; + } } - return asset; + sortTree(roots); + return roots; } - private String normalizeAssetPath(String path, String originalFilename) { + private SkillFileNode directoryNode(String path, String name) { + SkillFileNode node = new SkillFileNode(); + node.setKey(path); + node.setPath(path); + node.setName(name + "/"); + node.setType("DIRECTORY"); + return node; + } + + private void sortTree(List nodes) { + nodes.sort(Comparator.comparing((SkillFileNode node) -> !"DIRECTORY".equals(node.getType())) + .thenComparing(SkillFileNode::getName)); + nodes.forEach(node -> sortTree(node.getChildren())); + } + + private void validateSaveRequest(SkillFileSaveRequest request) { + if (request == null || request.getSkillId() == null) { + throw new BusinessException("Skill ID 不能为空"); + } + if (request.getPath() == null || request.getPath().isBlank()) { + throw new BusinessException("Skill 资源路径不能为空"); + } + if (bytes(request.getContent()).length > MAX_TEXT_RESOURCE_BYTES) { + throw new BusinessException(413, 4131, "Skill 文本资源超过 2 MiB 限制"); + } + } + + private void assertExpectedHash(String expectedHash, String actualHash) { + if (expectedHash == null || expectedHash.isBlank()) { + throw conflict("缺少文件版本,请重新加载后再保存"); + } + if (!expectedHash.equals(actualHash)) { + throw conflict("文件已被其他操作更新,请重新加载后合并内容"); + } + } + + private void validateUpload(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BusinessException("Skill 资源文件不能为空"); + } + if (file.getSize() > MAX_BINARY_RESOURCE_BYTES) { + throw new BusinessException(413, 4131, "Skill 二进制资源超过 50 MiB 限制"); + } + } + + /** + * 使用 M18 的统一路径、类型和媒体规则判断资源的规范存储表示。 + * + * @param path 规范化资源路径 + * @return 应以内联严格 UTF-8 文本保存时返回 true + */ + private boolean shouldStoreAsText(String path) { + SkillResourceKind kind = SkillResources.classify(path); + return SkillResources.isText(path, kind, detectMediaType(path)); + } + + /** + * 将上传的脚本文件按安全上限严格解码为 UTF-8 文本。 + * + * @param file 上传文件 + * @param path 目标路径 + * @return 脚本文本 + */ + private String readStrictUtf8(MultipartFile file, String path) { + if (file.getSize() > MAX_TEXT_RESOURCE_BYTES) { + throw new BusinessException(413, 4131, "Skill 脚本超过 2 MiB 限制:" + path); + } + try (InputStream inputStream = file.getInputStream()) { + return readStrictUtf8(inputStream, path); + } catch (IOException exception) { + throw new BusinessException(500, 500, "读取 Skill 脚本上传内容失败", exception); + } + } + + /** + * 将已有二进制资源转换为脚本文本,供跨目录重命名安全收敛表示。 + * + * @param resource 源资源 + * @param targetPath 目标脚本路径 + * @return 严格 UTF-8 文本 + */ + private String readStrictUtf8Content(SkillResource resource, String targetPath) { + if (resource.getContentRef() == null || resource.getSize() == null + || resource.getSize() > MAX_TEXT_RESOURCE_BYTES) { + throw new BusinessException("二进制资源不能转换为脚本:" + targetPath); + } + try (InputStream inputStream = contentStore.open(resource.getContentRef())) { + return readStrictUtf8(inputStream, targetPath); + } catch (IOException exception) { + throw new BusinessException(500, 500, "读取待转换的 Skill 资源失败", exception); + } + } + + /** + * 在读取过程中执行大小限制,并拒绝非法 UTF-8 字节序列。 + * + * @param inputStream 内容流 + * @param path 资源路径 + * @return 解码后的文本 + */ + private String readStrictUtf8(InputStream inputStream, String path) { + try { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + long total = 0; + int length; + while ((length = inputStream.read(buffer)) >= 0) { + if (length == 0) { + continue; + } + total += length; + if (total > MAX_TEXT_RESOURCE_BYTES) { + throw new BusinessException(413, 4131, "Skill 脚本超过 2 MiB 限制:" + path); + } + output.write(buffer, 0, length); + } + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(output.toByteArray())) + .toString(); + } catch (CharacterCodingException exception) { + throw new BusinessException("Skill 脚本必须使用严格 UTF-8 编码:" + path); + } catch (IOException exception) { + throw new BusinessException(500, 500, "读取 Skill 脚本内容失败", exception); + } + } + + private String normalizeUploadPath(String path, String originalFilename) { String effectivePath = path; if (effectivePath == null || effectivePath.isBlank()) { - effectivePath = SkillPaths.ASSETS_DIR + "/" + (originalFilename == null ? "asset.bin" : originalFilename); + effectivePath = SkillPaths.ASSETS_DIR + "/" + (originalFilename == null ? "resource.bin" : originalFilename); } - String normalized = SkillPaths.normalize(effectivePath); - if (!SkillPaths.ASSETS_DIR.equals(SkillPaths.firstSegment(normalized))) { - throw new BusinessException("asset 必须位于 assets/ 目录下"); + String normalized = normalizePath(effectivePath); + if (SkillPaths.SKILL_FILE.equals(normalized)) { + throw new BusinessException("SKILL.md 只能通过文本编辑器保存"); } return normalized; } - private SkillFileNode directoryNode(String name, List flatChildren) { - SkillFileNode node = new SkillFileNode(); - node.setKey(name); - node.setPath(name); - node.setName(name + "/"); - node.setType("DIRECTORY"); - node.setChildren(toNestedChildren(name, flatChildren)); - return node; + private String normalizePath(String path) { + String normalized; + try { + normalized = SkillPaths.normalize(path); + } catch (SkillException exception) { + throw new BusinessException("Skill 资源路径不合法:" + exception.getMessage()); + } + if (normalized.length() > 512) { + throw new BusinessException("Skill 资源路径超过 512 个字符"); + } + if (normalized.split("/").length > 16) { + throw new BusinessException("Skill 资源路径层级不能超过 16 层"); + } + return normalized; } - private List toNestedChildren(String root, List flatChildren) { - Map nodes = new LinkedHashMap<>(); - for (SkillFileNode child : flatChildren) { - String[] segments = child.getPath().substring(root.length() + 1).split("/"); - String currentPath = root; - for (int i = 0; i < segments.length; i++) { - currentPath = currentPath + "/" + segments[i]; - boolean leaf = i == segments.length - 1; - if (leaf) { - nodes.put(currentPath, child); - } else { - String segmentName = segments[i]; - nodes.computeIfAbsent(currentPath, key -> { - SkillFileNode directory = new SkillFileNode(); - directory.setKey(key); - directory.setPath(key); - directory.setName(segmentName + "/"); - directory.setType("DIRECTORY"); - return directory; - }); - } + private String resolveLanguage(String path, String kind) { + if (SkillResourceKind.SCRIPT.name().equals(kind)) { + SkillScriptLanguage language = SkillScriptLanguage.fromPath(path); + return language == SkillScriptLanguage.UNKNOWN ? null : language.name(); + } + return SkillResourceKind.REFERENCE.name().equals(kind) && isMarkdownPath(path) + ? "MARKDOWN" : null; + } + + /** + * 根据重命名后的路径、语义类型和文本表示重新计算媒体类型。 + * + * @param path 资源路径 + * @param kind 资源语义类型 + * @param text 是否为文本 + * @return 媒体类型 + */ + private String resolveMediaType(String path, String kind, boolean text) { + if (!text) { + return detectMediaType(path); + } + String lowerPath = path.toLowerCase(Locale.ROOT); + if (isMarkdownPath(path)) { + return "text/markdown"; + } + if (lowerPath.endsWith(".json")) { + return "application/json"; + } + if (lowerPath.endsWith(".yaml") || lowerPath.endsWith(".yml")) { + return "application/yaml"; + } + if (lowerPath.endsWith(".xml")) { + return "application/xml"; + } + if (lowerPath.endsWith(".csv")) { + return "text/csv"; + } + if (lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")) { + return "text/html"; + } + if (lowerPath.endsWith(".js") || lowerPath.endsWith(".mjs") || lowerPath.endsWith(".cjs")) { + return "application/javascript"; + } + String detected = detectMediaType(path); + return DEFAULT_MEDIA_TYPE.equals(detected) ? "text/plain" : detected; + } + + /** + * 判断路径是否为 Markdown 文档。 + * + * @param path 资源路径 + * @return Markdown 扩展名时返回 true + */ + private boolean isMarkdownPath(String path) { + String lowerPath = path.toLowerCase(Locale.ROOT); + return lowerPath.endsWith(".md") || lowerPath.endsWith(".markdown"); + } + + private String detectMediaType(String path) { + String lowerPath = path.toLowerCase(Locale.ROOT); + if (lowerPath.endsWith(".png")) { + return "image/png"; + } + if (lowerPath.endsWith(".jpg") || lowerPath.endsWith(".jpeg")) { + return "image/jpeg"; + } + if (lowerPath.endsWith(".gif")) { + return "image/gif"; + } + if (lowerPath.endsWith(".webp")) { + return "image/webp"; + } + if (lowerPath.endsWith(".avif")) { + return "image/avif"; + } + if (lowerPath.endsWith(".pdf")) { + return "application/pdf"; + } + if (lowerPath.endsWith(".svg") || lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") + || lowerPath.endsWith(".js") || lowerPath.endsWith(".mjs")) { + return DEFAULT_MEDIA_TYPE; + } + String detected = URLConnection.guessContentTypeFromName(path); + if (detected == null) { + return DEFAULT_MEDIA_TYPE; + } + String normalized = detected.toLowerCase(Locale.ROOT); + if (normalized.equals("text/html") || normalized.equals("application/xhtml+xml") + || normalized.equals("image/svg+xml") || normalized.equals("application/javascript")) { + return DEFAULT_MEDIA_TYPE; + } + return normalized; + } + + private void assertNoCanonicalCollision(BigInteger skillId, String path, BigInteger excludeId) { + String expectedKey = collisionKey(path); + for (SkillResource resource : listResourceDescriptors(skillId)) { + if ((excludeId == null || !excludeId.equals(resource.getId())) + && expectedKey.equals(collisionKey(resource.getNormalizedPath()))) { + throw conflict("Skill 资源路径与已有文件冲突:" + path); } } - List roots = new ArrayList<>(); - for (SkillFileNode node : nodes.values()) { - String parentPath = parentPath(node.getPath()); - if (root.equals(parentPath)) { - roots.add(node); - } else { - SkillFileNode parent = nodes.get(parentPath); - if (parent != null) { - parent.getChildren().add(node); - } - } - } - return roots; } - private SkillFileNode fileNode(String path, String name, String type) { - SkillFileNode node = new SkillFileNode(); - node.setKey(path); - node.setPath(path); - node.setName(name); - node.setType(type); - return node; + private String collisionKey(String path) { + return Normalizer.normalize(path, Normalizer.Form.NFKC).toLowerCase(Locale.ROOT); } - private String parentPath(String path) { - int index = path.lastIndexOf('/'); - return index < 0 ? "" : path.substring(0, index); + private BusinessException conflict(String message) { + return new BusinessException(409, 4091, message); } private byte[] bytes(String content) { return (content == null ? "" : content).getBytes(StandardCharsets.UTF_8); } - - private String detectMediaType(String path) { - String mediaType = URLConnection.guessContentTypeFromName(path); - return mediaType == null ? DEFAULT_MEDIA_TYPE : mediaType; - } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/EasyFlowBundleReader.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/EasyFlowBundleReader.java new file mode 100644 index 00000000..0d091460 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/EasyFlowBundleReader.java @@ -0,0 +1,492 @@ +package tech.easyflow.skill.imports; + +import com.easyagents.skill.model.SkillPackageLimits; +import com.easyagents.skill.exception.SkillException; +import com.easyagents.skill.exception.SkillPackageException; +import com.easyagents.skill.util.SkillPaths; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipFile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipOutputStream; +import java.util.zip.CRC32; + +/** + * 将 EasyFlow Bundle 安全转换为标准 Skill ZIP,并提取平台 manifest。 + */ +@Component +public class EasyFlowBundleReader { + + private static final Logger LOG = LoggerFactory.getLogger(EasyFlowBundleReader.class); + + private final EasyFlowSkillManifestCodec manifestCodec; + + /** + * 创建 EasyFlow Bundle 读取器。 + * + * @param manifestCodec manifest 编解码器 + */ + public EasyFlowBundleReader(EasyFlowSkillManifestCodec manifestCodec) { + this.manifestCodec = manifestCodec; + } + + /** + * 判断 ZIP 是否包含 EasyFlow manifest。 + * + * @param inputStream ZIP 输入流 + * @return 包含时为 true + */ + public boolean containsManifest(InputStream inputStream) { + SkillPackageLimits limits = SkillPackageLimits.defaults(); + Path packageFile = null; + try { + packageFile = copyCompressedPackage(inputStream, limits.getMaxCompressedPackageBytes()); + try (ZipFile zip = new ZipFile(packageFile)) { + Enumeration entries = zip.getEntries(); + int count = 0; + long declaredTotalBytes = 0; + long actualTotalBytes = 0; + boolean containsManifest = false; + while (entries.hasMoreElements()) { + ZipArchiveEntry entry = entries.nextElement(); + if (++count > limits.getMaxEntryCount() + 1) { + throw new BusinessException("EasyFlow Skill 包文件数量超过限制"); + } + String fullPath = validateCentralEntry(zip, entry, limits); + if (entry.isDirectory()) { + continue; + } + declaredTotalBytes = safeAdd( + declaredTotalBytes, entry.getSize(), limits.getMaxTotalUncompressedBytes()); + long singleLimit = EasyFlowSkillManifestCodec.MANIFEST_PATH.equals(fullPath) + ? EasyFlowSkillManifestCodec.MAX_MANIFEST_BYTES : limits.getMaxBinaryFileBytes(); + long actualSize = readAndVerifyEntry(zip, entry, fullPath, singleLimit); + actualTotalBytes = safeAdd( + actualTotalBytes, actualSize, limits.getMaxTotalUncompressedBytes()); + containsManifest |= EasyFlowSkillManifestCodec.MANIFEST_PATH.equals(fullPath); + } + return containsManifest; + } + } catch (ZipException exception) { + throw invalidZip(exception); + } catch (IOException exception) { + ZipException zipException = findZipException(exception); + if (zipException != null) { + throw invalidZip(zipException); + } + throw new BusinessException(500, 500, "读取 Skill 包格式失败", exception); + } finally { + deleteQuietly(packageFile); + } + } + + /** + * 提取 manifest,并将 skills/ 前缀下的标准包内容流式写到临时 ZIP。 + * + * @param inputStream EasyFlow Bundle 输入流 + * @return 可自动清理的准备结果 + */ + public PreparedBundle prepare(InputStream inputStream) { + SkillPackageLimits limits = SkillPackageLimits.defaults(); + Path packageFile = null; + Path standardZip = null; + try { + packageFile = copyCompressedPackage(inputStream, limits.getMaxCompressedPackageBytes()); + standardZip = Files.createTempFile("easyflow-bundle-standard-", ".zip"); + byte[] manifestBytes = null; + long declaredTotalBytes = 0; + long actualTotalBytes = 0; + int entryCount = 0; + Set paths = new HashSet<>(); + Set collisionKeys = new HashSet<>(); + try (ZipFile input = new ZipFile(packageFile); + ZipOutputStream output = new ZipOutputStream( + Files.newOutputStream(standardZip, StandardOpenOption.TRUNCATE_EXISTING), + StandardCharsets.UTF_8)) { + Enumeration entries = input.getEntries(); + byte[] buffer = new byte[8192]; + while (entries.hasMoreElements()) { + ZipArchiveEntry entry = entries.nextElement(); + if (++entryCount > limits.getMaxEntryCount() + 1) { + throw new BusinessException("EasyFlow Skill 包文件数量超过限制"); + } + String fullPath = validateCentralEntry(input, entry, limits); + if (entry.isDirectory()) { + continue; + } + declaredTotalBytes = safeAdd( + declaredTotalBytes, entry.getSize(), limits.getMaxTotalUncompressedBytes()); + if (EasyFlowSkillManifestCodec.MANIFEST_PATH.equals(fullPath)) { + if (manifestBytes != null) { + throw new BusinessException("EasyFlow Skill 包包含重复 manifest"); + } + try (InputStream entryInput = input.getInputStream(entry)) { + manifestBytes = readLimited(entryInput, EasyFlowSkillManifestCodec.MAX_MANIFEST_BYTES); + } + verifyCrc(entry, crc32(manifestBytes), fullPath); + actualTotalBytes = safeAdd(actualTotalBytes, manifestBytes.length, + limits.getMaxTotalUncompressedBytes()); + if (manifestBytes.length != entry.getSize()) { + throw new BusinessException("EasyFlow Skill manifest 实际大小与目录信息不一致"); + } + continue; + } + if (!fullPath.startsWith("skills/")) { + throw new BusinessException("EasyFlow Skill 包根目录只能包含 manifest 和 skills/"); + } + String relativePath = normalizePackagePath(fullPath.substring("skills/".length())); + if (!paths.add(relativePath) || !collisionKeys.add(SkillPaths.collisionKey(relativePath))) { + throw new BusinessException("EasyFlow Skill 包存在重复或大小写冲突路径:" + relativePath); + } + ZipEntry outputEntry = new ZipEntry(relativePath); + outputEntry.setTime(0L); + output.putNextEntry(outputEntry); + long entryBytes = 0; + CRC32 crc = new CRC32(); + try (InputStream entryInput = input.getInputStream(entry)) { + int length; + while ((length = entryInput.read(buffer)) >= 0) { + entryBytes += length; + if (entryBytes > limits.getMaxBinaryFileBytes()) { + throw new BusinessException("EasyFlow Skill 包单文件解压大小超过限制"); + } + actualTotalBytes = safeAdd(actualTotalBytes, length, + limits.getMaxTotalUncompressedBytes()); + crc.update(buffer, 0, length); + output.write(buffer, 0, length); + } + } + if (entryBytes != entry.getSize()) { + throw new BusinessException("EasyFlow Skill 包条目实际大小与目录信息不一致"); + } + verifyCrc(entry, crc.getValue(), fullPath); + output.closeEntry(); + } + output.finish(); + } + if (manifestBytes == null) { + throw new BusinessException("EasyFlow Skill 包缺少 easyflow-manifest.json"); + } + return new PreparedBundle(standardZip, manifestCodec.decode(manifestBytes)); + } catch (RuntimeException | IOException exception) { + deleteQuietly(standardZip); + if (exception instanceof BusinessException businessException) { + throw businessException; + } + if (exception instanceof SkillPackageException skillPackageException) { + throw skillPackageException; + } + ZipException zipException = findZipException(exception); + if (zipException != null) { + throw invalidZip(zipException); + } + LOG.error("解析 EasyFlow Skill Bundle 失败", exception); + throw new BusinessException(500, 500, "解析 EasyFlow Skill Bundle 失败", exception); + } + finally { + deleteQuietly(packageFile); + } + } + + private Path copyCompressedPackage(InputStream input, long limit) throws IOException { + if (input == null) { + throw new BusinessException("Skill 包输入流不能为空"); + } + Path target = Files.createTempFile("easyflow-bundle-compressed-", ".zip"); + try (OutputStream output = Files.newOutputStream(target, StandardOpenOption.TRUNCATE_EXISTING)) { + byte[] buffer = new byte[8192]; + long total = 0; + int length; + while ((length = input.read(buffer)) >= 0) { + total += length; + if (total > limit) { + throw new BusinessException(413, 4131, + "Skill 包压缩文件超过 " + limit + " 字节限制"); + } + output.write(buffer, 0, length); + } + return target; + } catch (RuntimeException | IOException exception) { + deleteQuietly(target); + throw exception; + } + } + + /** + * 校验外层 ZIP 中央目录元数据并返回规范化路径。 + * + * @param zip ZIP 文件 + * @param entry ZIP 条目 + * @param limits 包安全限制 + * @return 规范化包内路径 + * @throws BusinessException 条目类型、路径、大小或压缩比不安全 + * @throws SkillPackageException 原始文件名或 CRC 元数据不合法 + */ + private String validateCentralEntry(ZipFile zip, ZipArchiveEntry entry, SkillPackageLimits limits) { + if (!zip.canReadEntryData(entry)) { + throw new BusinessException("Skill 包包含加密或不支持的压缩条目"); + } + if (entry.isUnixSymlink()) { + throw new BusinessException("Skill 包不允许包含符号链接"); + } + String path = strictUtf8EntryName(entry); + if (entry.isDirectory()) { + while (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + if (path.isBlank()) { + throw new BusinessException("Skill 包包含非法空目录路径"); + } + } + String normalized = normalizePackagePath(path); + if (normalized.length() > limits.getMaxPathLength() + || normalized.split("/").length > limits.getMaxPathDepth()) { + throw new BusinessException("Skill 包路径长度或层级超过限制"); + } + long size = entry.getSize(); + long compressedSize = entry.getCompressedSize(); + if (size < 0 || compressedSize < 0) { + throw new BusinessException("Skill 包条目缺少可靠大小信息"); + } + if (!entry.isDirectory() && entry.getCrc() < 0) { + throw packageError("UNKNOWN_ENTRY_CRC", normalized, + "EasyFlow Skill 包条目缺少中央目录 CRC"); + } + double ratio = size == 0 ? 0D : (double) size / Math.max(1L, compressedSize); + if (ratio > limits.getMaxCompressionRatio()) { + throw new BusinessException("Skill 包条目压缩比超过安全限制"); + } + return normalized; + } + + /** + * 严格按 UTF-8 解码 ZIP 中央目录的原始文件名字节,并拒绝 Unicode extra field 造成的歧义。 + * + * @param entry ZIP 条目 + * @return 唯一的 UTF-8 文件名 + * @throws SkillPackageException 原始文件名字节非法或与解析结果不一致 + */ + private String strictUtf8EntryName(ZipArchiveEntry entry) { + byte[] rawName = entry.getRawName(); + if (rawName == null) { + throw packageError("INVALID_UTF8_ENTRY_NAME", entry.getName(), + "EasyFlow Skill 包条目缺少原始文件名字节"); + } + try { + String decodedName = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(rawName)) + .toString(); + if (!decodedName.equals(entry.getName())) { + throw packageError("INVALID_UTF8_ENTRY_NAME", entry.getName(), + "EasyFlow Skill 包条目文件名必须具有唯一 UTF-8 表示"); + } + return decodedName; + } catch (CharacterCodingException exception) { + throw new SkillPackageException("INVALID_UTF8_ENTRY_NAME", entry.getName(), + "EasyFlow Skill 包条目文件名不是合法 UTF-8", exception); + } + } + + /** + * 流式读取单个外层 ZIP 条目,并同时校验实际大小与中央目录 CRC。 + * + * @param zip ZIP 文件 + * @param entry ZIP 条目 + * @param path 已校验路径 + * @param sizeLimit 单文件解压上限 + * @return 实际解压字节数 + * @throws IOException 条目读取失败 + * @throws SkillPackageException CRC 不匹配 + */ + private long readAndVerifyEntry(ZipFile zip, + ZipArchiveEntry entry, + String path, + long sizeLimit) throws IOException { + CRC32 crc = new CRC32(); + byte[] buffer = new byte[8192]; + long actualSize = 0; + try (InputStream input = zip.getInputStream(entry)) { + int length; + while ((length = input.read(buffer)) >= 0) { + actualSize += length; + if (actualSize > sizeLimit) { + throw new BusinessException(413, 4131, "EasyFlow Skill 包单文件解压大小超过限制"); + } + crc.update(buffer, 0, length); + } + } + if (actualSize != entry.getSize()) { + throw packageError("ENTRY_SIZE_MISMATCH", path, + "EasyFlow Skill 包条目实际大小与中央目录不一致"); + } + verifyCrc(entry, crc.getValue(), path); + return actualSize; + } + + /** + * 校验 ZIP 条目 CRC-32。 + * + * @param entry ZIP 条目 + * @param actualCrc 实际内容 CRC-32 + * @param path 包内路径 + * @throws SkillPackageException CRC 与中央目录不一致 + */ + private void verifyCrc(ZipArchiveEntry entry, long actualCrc, String path) { + if (entry.getCrc() != actualCrc) { + throw packageError("CRC_MISMATCH", path, + "EasyFlow Skill 包条目 CRC 与实际内容不一致"); + } + } + + /** + * 计算字节内容的 CRC-32。 + * + * @param bytes 内容字节 + * @return CRC-32 + */ + private long crc32(byte[] bytes) { + CRC32 crc = new CRC32(); + crc.update(bytes); + return crc.getValue(); + } + + /** + * 创建带稳定错误码和包内路径的 Skill 包异常。 + * + * @param code 稳定错误码 + * @param path 包内路径 + * @param message 错误信息 + * @return Skill 包异常 + */ + private SkillPackageException packageError(String code, String path, String message) { + return new SkillPackageException(code, path, message); + } + + private long safeAdd(long current, long value, long limit) { + if (value < 0 || current > limit - value) { + throw new BusinessException("EasyFlow Skill 包解压总大小超过限制"); + } + return current + value; + } + + private String normalizePackagePath(String path) { + try { + return SkillPaths.normalize(path); + } catch (SkillException exception) { + throw new BusinessException("Skill 包路径不合法:" + exception.getMessage()); + } + } + + private byte[] readLimited(InputStream input, long limit) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + long total = 0; + int length; + while ((length = input.read(buffer)) >= 0) { + total += length; + if (total > limit) { + throw new BusinessException(413, 4131, "EasyFlow Skill manifest 超过 1 MiB 限制"); + } + output.write(buffer, 0, length); + } + return output.toByteArray(); + } + + private BusinessException invalidZip(ZipException exception) { + return new BusinessException(400, 4001, "Skill 包不是有效的 ZIP 文件", exception); + } + + /** + * 沿异常链查找被 Commons Compress 包装的 ZIP 格式异常。 + * + * @param exception 外层读取异常 + * @return ZIP 格式异常;不存在时返回 {@code null} + */ + private ZipException findZipException(Throwable exception) { + Throwable current = exception; + for (int depth = 0; current != null && depth < 32; depth++) { + if (current instanceof ZipException zipException) { + return zipException; + } + if (current == current.getCause()) { + break; + } + current = current.getCause(); + } + return null; + } + + private void deleteQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException exception) { + LOG.warn("清理 EasyFlow Bundle 临时标准包失败,path={}", path, exception); + } + } + + /** + * EasyFlow Bundle 准备结果。 + */ + public final class PreparedBundle implements AutoCloseable { + + private final Path standardZip; + private final Map manifest; + + private PreparedBundle(Path standardZip, Map manifest) { + this.standardZip = standardZip; + this.manifest = manifest; + } + + /** + * 打开转换后的标准 ZIP。 + * + * @return 输入流 + * @throws IOException 临时文件无法读取 + */ + public InputStream openStandardZip() throws IOException { + return Files.newInputStream(standardZip); + } + + /** + * 获取已做基础版本校验的 manifest。 + * + * @return manifest + */ + public Map getManifest() { + return manifest; + } + + /** + * 删除转换临时文件。 + */ + @Override + public void close() { + deleteQuietly(standardZip); + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/EasyFlowSkillManifestCodec.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/EasyFlowSkillManifestCodec.java new file mode 100644 index 00000000..693b103e --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/EasyFlowSkillManifestCodec.java @@ -0,0 +1,573 @@ +package tech.easyflow.skill.imports; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.capability.SkillCapabilityTarget; +import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.enums.SkillCapabilityExecutionMode; +import tech.easyflow.skill.enums.SkillCapabilitySelectionMode; +import tech.easyflow.skill.enums.SkillCapabilityType; +import tech.easyflow.skill.security.SkillCredentialValueGuard; +import tech.easyflow.skill.security.SkillPortableTargetSanitizer; +import tech.easyflow.skill.security.SkillSensitiveConfigSanitizer; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * EasyFlow Skill Bundle manifest 的版本化白名单编解码器。 + */ +@Component +public class EasyFlowSkillManifestCodec { + + private static final Set ROOT_FIELDS = Set.of("schemaVersion", "skills"); + private static final Set SKILL_FIELDS = Set.of("packageRoot", "packageHash", "capabilities"); + private static final Set BINDING_FIELDS = Set.of( + "bindingKey", "capabilityType", "runtimeName", "enabled", "selectionMode", + "selectedToolNames", "executionMode", "hitlEnabled", "hitlConfig", "options", "sortNo", + "targetLogicalRef", "targetStatus", "targetName", "targetRevision"); + private static final Pattern RUNTIME_NAME_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_-]{0,63}$"); + private static final Pattern MCP_TOOL_NAME_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_.-]{0,127}$"); + + /** EasyFlow Bundle manifest 固定路径。 */ + public static final String MANIFEST_PATH = "easyflow-manifest.json"; + /** manifest 最大字节数。 */ + public static final long MAX_MANIFEST_BYTES = 1024L * 1024; + /** 单个增强包最大 Skill 数。 */ + public static final int MAX_SKILLS = 100; + /** packageRoot 最大字符数。 */ + public static final int MAX_PACKAGE_ROOT_LENGTH = 128; + /** 单个 Skill 最大能力绑定数。 */ + public static final int MAX_BINDINGS_PER_SKILL = 200; + /** 单个增强包最大能力绑定总数。 */ + public static final int MAX_TOTAL_BINDINGS = 1_000; + + private final ObjectMapper objectMapper; + private final SkillCapabilityTargetAccessService targetAccessService; + + /** + * 创建 manifest 编解码器。 + * + * @param objectMapper JSON 映射器 + * @param targetAccessService 能力目标授权服务 + */ + public EasyFlowSkillManifestCodec(ObjectMapper objectMapper, + SkillCapabilityTargetAccessService targetAccessService) { + this.objectMapper = objectMapper; + this.targetAccessService = targetAccessService; + } + + /** + * 将 Skill 列表编码为不含凭据的 manifest。 + * + * @param skills Skill 详情 + * @return UTF-8 JSON + */ + public byte[] encode(List skills) { + if (skills == null || skills.size() > MAX_SKILLS) { + throw new BusinessException("单个 EasyFlow Skill Bundle 最多包含 " + MAX_SKILLS + " 个 Skill"); + } + Map manifest = new LinkedHashMap<>(); + manifest.put("schemaVersion", "1.0"); + List> skillItems = new ArrayList<>(); + int totalBindings = 0; + for (int skillIndex = 0; skillIndex < skills.size(); skillIndex++) { + Skill skill = skills.get(skillIndex); + String skillPath = "skills[" + skillIndex + "]"; + if (skill == null) { + throw new SkillManifestValidationException("SKILL_EMPTY", skillPath, + "EasyFlow Skill manifest 的 Skill 不能为空"); + } + String packageRoot = boundedRequiredString( + skill.getName(), skillPath + ".packageRoot", MAX_PACKAGE_ROOT_LENGTH); + String packageHash = boundedRequiredString( + skill.getPackageHash(), skillPath + ".packageHash", 128); + validatePortableMetadata(packageRoot, skillPath + ".packageRoot"); + validatePortableMetadata(packageHash, skillPath + ".packageHash"); + Map item = new LinkedHashMap<>(); + item.put("packageRoot", packageRoot); + item.put("packageHash", packageHash); + List> bindings = new ArrayList<>(); + List sourceBindings = skill.getCapabilityBindings() == null + ? List.of() : skill.getCapabilityBindings(); + if (sourceBindings.size() > MAX_BINDINGS_PER_SKILL + || (totalBindings += sourceBindings.size()) > MAX_TOTAL_BINDINGS) { + throw new BusinessException("EasyFlow Skill Bundle 能力绑定数量超过限制"); + } + for (int index = 0; index < sourceBindings.size(); index++) { + bindings.add(bindingManifest(skillIndex, packageRoot, index, sourceBindings.get(index))); + } + item.put("capabilities", bindings); + skillItems.add(item); + } + manifest.put("skills", skillItems); + validateCredentialFreeTree(manifest, ""); + validateSkills(skillItems); + try { + byte[] bytes = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(manifest); + if (bytes.length > MAX_MANIFEST_BYTES) { + throw new BusinessException("EasyFlow Skill manifest 超过 1 MiB 限制"); + } + return bytes; + } catch (JsonProcessingException exception) { + throw new BusinessException(500, 500, "生成 EasyFlow Skill manifest 失败", exception); + } + } + + /** + * 解码并校验 manifest 基础版本结构。 + * + * @param bytes manifest 字节 + * @return manifest 对象 + */ + public Map decode(byte[] bytes) { + if (bytes == null || bytes.length == 0 || bytes.length > MAX_MANIFEST_BYTES) { + if (bytes != null && bytes.length > MAX_MANIFEST_BYTES) { + throw new BusinessException(413, 4131, "EasyFlow Skill manifest 超过 1 MiB 限制"); + } + throw new BusinessException("EasyFlow Skill manifest 不能为空"); + } + try { + Map manifest = objectMapper.readerFor(new TypeReference>() { }) + .with(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + .readValue(bytes); + if (manifest == null) { + throw new BusinessException("EasyFlow Skill manifest 根对象不能为空"); + } + // 在枚举解析和错误消息构造前先覆盖整个平台 manifest 字符串面,避免敏感值回显。 + validateCredentialFreeTree(manifest, ""); + assertOnlyFields(manifest, ROOT_FIELDS, "根对象"); + if (!"1.0".equals(String.valueOf(manifest.get("schemaVersion")))) { + throw new BusinessException("不支持的 EasyFlow Skill manifest 版本"); + } + if (!(manifest.get("skills") instanceof List skills)) { + throw new BusinessException("EasyFlow Skill manifest 缺少 skills 列表"); + } + validateSkills(skills); + return manifest; + } catch (java.io.IOException exception) { + throw new BusinessException("EasyFlow Skill manifest JSON 格式不正确"); + } + } + + private void validateSkills(List skills) { + if (skills.size() > MAX_SKILLS) { + throw new BusinessException("EasyFlow Skill manifest 最多包含 " + MAX_SKILLS + " 个 Skill"); + } + java.util.Set roots = new java.util.HashSet<>(); + java.util.Set bindingKeys = new java.util.HashSet<>(); + int totalBindings = 0; + for (int skillIndex = 0; skillIndex < skills.size(); skillIndex++) { + String skillPath = "skills[" + skillIndex + "]"; + Object source = skills.get(skillIndex); + if (!(source instanceof Map skill)) { + throw new SkillManifestValidationException("SKILL_INVALID", skillPath, + "EasyFlow Skill manifest 的 Skill 项格式不正确"); + } + assertOnlyFields(skill, SKILL_FIELDS, skillPath); + String packageRoot = boundedRequiredString( + skill.get("packageRoot"), skillPath + ".packageRoot", MAX_PACKAGE_ROOT_LENGTH); + validatePortableMetadata(packageRoot, skillPath + ".packageRoot"); + validatePortableMetadata(boundedRequiredString( + skill.get("packageHash"), skillPath + ".packageHash", 128), + skillPath + ".packageHash"); + if (!roots.add(packageRoot)) { + throw new SkillManifestValidationException("PACKAGE_ROOT_DUPLICATE", + skillPath + ".packageRoot", "EasyFlow Skill manifest 存在重复 packageRoot"); + } + Object capabilitiesValue = skill.get("capabilities"); + if (!(capabilitiesValue instanceof List capabilities)) { + throw new SkillManifestValidationException("CAPABILITIES_REQUIRED", + skillPath + ".capabilities", "EasyFlow Skill manifest 缺少 capabilities 列表"); + } + if (capabilities.size() > MAX_BINDINGS_PER_SKILL) { + throw new BusinessException("单个 Skill 的能力绑定不能超过 " + MAX_BINDINGS_PER_SKILL + " 个"); + } + totalBindings += capabilities.size(); + if (totalBindings > MAX_TOTAL_BINDINGS) { + throw new BusinessException("EasyFlow Skill manifest 能力绑定总数不能超过 " + + MAX_TOTAL_BINDINGS + " 个"); + } + for (int bindingIndex = 0; bindingIndex < capabilities.size(); bindingIndex++) { + Object bindingValue = capabilities.get(bindingIndex); + String bindingPath = skillPath + ".capabilities[" + bindingIndex + "]"; + if (!(bindingValue instanceof Map binding)) { + throw new SkillManifestValidationException("CAPABILITY_INVALID", bindingPath, + "EasyFlow Skill manifest 的能力绑定格式不正确"); + } + assertOnlyFields(binding, BINDING_FIELDS, bindingPath); + String bindingKey = boundedRequiredString( + binding.get("bindingKey"), bindingPath + ".bindingKey", 256); + validatePortableMetadata(bindingKey, bindingPath + ".bindingKey"); + if (!bindingKeys.add(bindingKey)) { + throw new SkillManifestValidationException("BINDING_KEY_DUPLICATE", + bindingPath + ".bindingKey", "EasyFlow Skill manifest 存在重复 bindingKey"); + } + SkillCapabilityType type = parseCapabilityType(binding.get("capabilityType"), bindingPath); + String runtimeName = boundedRequiredString( + binding.get("runtimeName"), bindingPath + ".runtimeName", 64); + if (!RUNTIME_NAME_PATTERN.matcher(runtimeName).matches()) { + throw new SkillManifestValidationException("RUNTIME_NAME_INVALID", + bindingPath + ".runtimeName", "EasyFlow Skill manifest 的运行时名称格式不正确"); + } + String logicalRef = boundedRequiredString( + binding.get("targetLogicalRef"), bindingPath + ".targetLogicalRef", 512); + validateLogicalRef(type, logicalRef, bindingPath + ".targetLogicalRef"); + validateSelectionMode(boundedOptionalString( + binding.get("selectionMode"), bindingPath + ".selectionMode", 16), bindingPath); + validateExecutionMode(boundedOptionalString( + binding.get("executionMode"), bindingPath + ".executionMode", 16), bindingPath); + validatePortableMetadata(boundedOptionalString( + binding.get("targetStatus"), bindingPath + ".targetStatus", 32), + bindingPath + ".targetStatus"); + validatePortableMetadata(boundedOptionalString( + binding.get("targetName"), bindingPath + ".targetName", 256), + bindingPath + ".targetName"); + validatePortableMetadata(boundedOptionalString( + binding.get("targetRevision"), bindingPath + ".targetRevision", 256), + bindingPath + ".targetRevision"); + validateBoolean(binding.get("enabled"), bindingPath + ".enabled"); + validateBoolean(binding.get("hitlEnabled"), bindingPath + ".hitlEnabled"); + validateInteger(binding.get("sortNo"), bindingPath + ".sortNo"); + validateStringList(binding.get("selectedToolNames"), bindingPath + ".selectedToolNames"); + validateSafeConfig(binding.get("hitlConfig"), true, bindingPath + ".hitlConfig"); + validateSafeConfig(binding.get("options"), false, bindingPath + ".options"); + } + } + } + + private void assertOnlyFields(Map source, Set allowed, String path) { + for (Object key : source.keySet()) { + if (!(key instanceof String field) || !allowed.contains(field)) { + throw new SkillManifestValidationException("FIELD_NOT_ALLOWED", path, + "EasyFlow Skill manifest 包含未允许字段"); + } + } + } + + private void validateBoolean(Object value, String field) { + if (value != null && !(value instanceof Boolean)) { + throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 类型不正确"); + } + } + + private void validateInteger(Object value, String field) { + if (value != null && (!(value instanceof Number number) + || number.doubleValue() != number.longValue() + || number.longValue() < Integer.MIN_VALUE || number.longValue() > Integer.MAX_VALUE)) { + throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 类型不正确"); + } + } + + private void validateStringList(Object value, String field) { + if (value == null) { + return; + } + if (!(value instanceof List values) || values.size() > 200) { + throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 类型不正确或超过限制"); + } + for (int index = 0; index < values.size(); index++) { + Object item = values.get(index); + if (!(item instanceof String text) || !MCP_TOOL_NAME_PATTERN.matcher(text).matches()) { + throw new SkillManifestValidationException("MCP_TOOL_NAME_INVALID", + field + "[" + index + "]", "EasyFlow Skill manifest 包含非法工具名"); + } + } + } + + private void validateLogicalRef(SkillCapabilityType type, String logicalRef, String path) { + if (!SkillPortableTargetSanitizer.isSafeLogicalRef(type, logicalRef)) { + throw new SkillManifestValidationException("TARGET_LOGICAL_REF_INVALID", path, + "EasyFlow Skill manifest 的目标逻辑引用格式不正确"); + } + } + + /** + * 校验 manifest 可移植元数据不含本机路径或认证材料。 + * + * @param value 元数据值 + * @param field 字段名 + */ + private void validatePortableMetadata(String value, String field) { + if (SkillCredentialValueGuard.containsCredential(value)) { + throw new SkillManifestValidationException("SENSITIVE_VALUE_DETECTED", field, + "EasyFlow Skill manifest 不能包含认证凭据"); + } + if (!SkillPortableTargetSanitizer.isSafePortableMetadata(value)) { + throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 包含不安全内容"); + } + } + + private void validateSafeConfig(Object value, boolean hitl, String field) { + if (value == null) { + return; + } + if (!(value instanceof Map raw)) { + throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 类型不正确"); + } + Map source = new LinkedHashMap<>(); + for (Map.Entry entry : raw.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 包含非法键"); + } + source.put(key, entry.getValue()); + } + Map safe = hitl + ? SkillSensitiveConfigSanitizer.sanitizeHitl(source) + : SkillSensitiveConfigSanitizer.sanitizeOptions(source); + if (!safe.equals(source)) { + throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 包含未允许或敏感配置"); + } + if (hitl) { + for (Map.Entry entry : safe.entrySet()) { + int maxLength = "confirmLabel".equals(entry.getKey()) || "cancelLabel".equals(entry.getKey()) + ? 128 : 2_000; + if (!(entry.getValue() instanceof String text) || text.length() > maxLength) { + throw new SkillManifestValidationException("HITL_CONFIG_VALUE_INVALID", + field + "." + entry.getKey(), + "EasyFlow Skill manifest 的 HITL 配置字段类型或长度不正确"); + } + if (SkillCredentialValueGuard.containsCredential(text)) { + throw new SkillManifestValidationException("SENSITIVE_VALUE_DETECTED", + field + "." + entry.getKey(), + "EasyFlow Skill manifest 的 HITL 配置不能包含认证凭据"); + } + } + return; + } + validateOptionValue(safe, "timeoutMs", field); + validateOptionValue(safe, "retryCount", field); + for (String key : List.of("async", "readOnly")) { + if (safe.containsKey(key) && !(safe.get(key) instanceof Boolean)) { + throw new SkillManifestValidationException("CAPABILITY_OPTION_VALUE_INVALID", + field + "." + key, "EasyFlow Skill manifest 的能力选项类型不正确"); + } + } + } + + /** + * 校验数值型能力选项。 + * + * @param options 能力选项 + * @param key 选项键 + * @param path options 字段路径 + */ + private void validateOptionValue(Map options, + String key, + String path) { + if (!options.containsKey(key)) { + return; + } + Object value = options.get(key); + // manifest 解码只负责结构、类型和安全边界;运行时值域由能力预览校验统一返回结构化问题。 + boolean valid = value instanceof Number number + && number.doubleValue() == number.longValue() + && number.longValue() >= Integer.MIN_VALUE + && number.longValue() <= Integer.MAX_VALUE; + if (!valid) { + throw new SkillManifestValidationException("CAPABILITY_OPTION_VALUE_INVALID", + path + "." + key, "EasyFlow Skill manifest 的能力选项数值不正确"); + } + } + + /** + * 解析能力类型并将不可信输入转换为稳定错误。 + * + * @param value 原始类型值 + * @param bindingPath 能力绑定路径 + * @return 能力类型 + */ + private SkillCapabilityType parseCapabilityType(Object value, String bindingPath) { + String path = bindingPath + ".capabilityType"; + String type = boundedRequiredString(value, path, 32); + try { + return SkillCapabilityType.from(type); + } catch (BusinessException exception) { + throw new SkillManifestValidationException("CAPABILITY_TYPE_INVALID", path, + "EasyFlow Skill manifest 的能力类型不受支持"); + } + } + + /** + * 校验 MCP 工具选择模式且不回显原始值。 + * + * @param value 模式值 + * @param bindingPath 能力绑定路径 + */ + private void validateSelectionMode(String value, String bindingPath) { + if (value == null || value.isBlank()) { + return; + } + try { + SkillCapabilitySelectionMode.fromOrDefault(value); + } catch (BusinessException exception) { + throw new SkillManifestValidationException("MCP_SELECTION_MODE_INVALID", + bindingPath + ".selectionMode", "EasyFlow Skill manifest 的 MCP 工具选择模式不受支持"); + } + } + + /** + * 校验能力执行模式且不回显原始值。 + * + * @param value 模式值 + * @param bindingPath 能力绑定路径 + */ + private void validateExecutionMode(String value, String bindingPath) { + if (value == null || value.isBlank()) { + return; + } + try { + SkillCapabilityExecutionMode.fromOrDefault(value); + } catch (BusinessException exception) { + throw new SkillManifestValidationException("EXECUTION_MODE_INVALID", + bindingPath + ".executionMode", "EasyFlow Skill manifest 的执行模式不受支持"); + } + } + + /** + * 递归校验 manifest 中实际会携带的全部字符串值。 + * + * @param value 当前值 + * @param path 当前字段路径 + */ + private void validateCredentialFreeTree(Object value, String path) { + Deque pending = new ArrayDeque<>(); + pending.push(new ManifestNode(value, path)); + while (!pending.isEmpty()) { + ManifestNode node = pending.pop(); + if (node.value() instanceof String text) { + if (SkillCredentialValueGuard.containsCredential(text)) { + throw new SkillManifestValidationException("SENSITIVE_VALUE_DETECTED", + node.path().isBlank() ? "manifest" : node.path(), + "EasyFlow Skill manifest 不能包含认证凭据"); + } + continue; + } + if (node.value() instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + if (entry.getKey() instanceof String key) { + String childPath = node.path().isBlank() ? key : node.path() + "." + key; + pending.push(new ManifestNode(entry.getValue(), childPath)); + } + } + continue; + } + if (node.value() instanceof List list) { + for (int index = list.size() - 1; index >= 0; index--) { + pending.push(new ManifestNode(list.get(index), node.path() + "[" + index + "]")); + } + } + } + } + + /** + * manifest 迭代扫描节点。 + * + * @param value 当前值 + * @param path 当前路径 + */ + private record ManifestNode(Object value, String path) { + } + + private String boundedRequiredString(Object value, String field, int maxLength) { + String result = boundedOptionalString(value, field, maxLength); + if (result == null || result.isBlank()) { + throw new BusinessException("EasyFlow Skill manifest 缺少 " + field); + } + return result; + } + + private String boundedOptionalString(Object value, String field, int maxLength) { + if (value == null) { + return null; + } + if (!(value instanceof String result) || result.length() > maxLength) { + throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 超过限制或类型不正确"); + } + return result; + } + + private Map bindingManifest(int skillIndex, + String packageRoot, + int index, + SkillCapabilityBinding binding) { + String bindingPath = "skills[" + skillIndex + "].capabilities[" + index + "]"; + if (binding == null) { + throw new SkillManifestValidationException("CAPABILITY_EMPTY", bindingPath, + "EasyFlow Skill manifest 的能力绑定不能为空"); + } + Map credentialSurface = new LinkedHashMap<>(); + credentialSurface.put("capabilityType", binding.getCapabilityType()); + credentialSurface.put("runtimeName", binding.getRuntimeName()); + credentialSurface.put("selectionMode", binding.getSelectionMode()); + credentialSurface.put("selectedToolNames", binding.getSelectedToolNamesJson()); + credentialSurface.put("executionMode", binding.getExecutionMode()); + validateCredentialFreeTree(credentialSurface, bindingPath); + + SkillCapabilityType type = parseCapabilityType(binding.getCapabilityType(), bindingPath); + Map item = new LinkedHashMap<>(); + item.put("bindingKey", packageRoot + ":" + index); + item.put("capabilityType", type.name()); + item.put("runtimeName", binding.getRuntimeName()); + item.put("enabled", binding.getEnabled()); + item.put("selectionMode", binding.getSelectionMode()); + item.put("selectedToolNames", binding.getSelectedToolNamesJson()); + item.put("executionMode", binding.getExecutionMode()); + item.put("hitlEnabled", binding.getHitlEnabled()); + Map safeHitl = SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson()); + Map safeOptions = SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson()); + validateSafeConfig(safeHitl, true, bindingPath + ".hitlConfig"); + validateSafeConfig(safeOptions, false, bindingPath + ".options"); + item.put("hitlConfig", safeHitl); + item.put("options", safeOptions); + item.put("sortNo", binding.getSortNo()); + String fallbackRef = SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved( + type, binding.getTargetLogicalRef()); + if (binding.getTargetId() == null || !Boolean.TRUE.equals(binding.getEnabled())) { + item.put("targetLogicalRef", fallbackRef); + item.put("targetStatus", binding.getTargetId() == null ? "UNRESOLVED" : "DISABLED"); + return item; + } + try { + SkillCapabilityTarget target = targetAccessService.requireUsableTarget(binding, false); + item.put("targetLogicalRef", SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved( + type, target.getLogicalRef())); + putSafeMetadata(item, "targetName", target.getName()); + putSafeMetadata(item, "targetRevision", target.getRevision()); + item.put("targetStatus", "AVAILABLE"); + } catch (BusinessException exception) { + // 备份导出必须可用;目标会在导入映射或再次发布时重新校验。 + item.put("targetLogicalRef", fallbackRef); + putSafeMetadata(item, "targetName", binding.getTargetName()); + item.put("targetStatus", exception.getHttpStatus() == 403 ? "NO_PERMISSION" : "UNAVAILABLE"); + } + return item; + } + + /** + * 仅在目标元数据安全且非空时写入 manifest。 + * + * @param target 目标字段映射 + * @param field 字段名 + * @param value 原始元数据 + */ + private void putSafeMetadata(Map target, String field, String value) { + String safeValue = SkillPortableTargetSanitizer.safePortableMetadataOrNull(value); + if (safeValue != null) { + target.put(field, safeValue); + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportArtifact.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportArtifact.java new file mode 100644 index 00000000..c6be0772 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportArtifact.java @@ -0,0 +1,80 @@ +package tech.easyflow.skill.imports; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * 已完整构建并校验的 Skill 导出临时产物。 + */ +public final class SkillExportArtifact implements AutoCloseable { + + private static final Logger LOG = LoggerFactory.getLogger(SkillExportArtifact.class); + + private final Path path; + private final String fileName; + private final String mediaType; + + /** + * 创建导出产物。 + * + * @param path 临时文件 + * @param fileName 下载文件名 + * @param mediaType 媒体类型 + */ + public SkillExportArtifact(Path path, String fileName, String mediaType) { + this.path = path; + this.fileName = fileName; + this.mediaType = mediaType; + } + + /** + * 获取下载文件名。 + * + * @return 文件名 + */ + public String getFileName() { + return fileName; + } + + /** + * 获取媒体类型。 + * + * @return 媒体类型 + */ + public String getMediaType() { + return mediaType; + } + + /** + * 将已完成产物传输到响应流。 + * + * @param outputStream 输出流 + */ + public void transferTo(OutputStream outputStream) { + try (java.io.InputStream input = Files.newInputStream(path)) { + input.transferTo(outputStream); + outputStream.flush(); + } catch (IOException exception) { + LOG.error("输出 Skill 导出文件失败,path={}", path, exception); + throw new BusinessException(500, 500, "输出 Skill 导出文件失败", exception); + } + } + + /** + * 清理临时产物。 + */ + @Override + public void close() { + try { + Files.deleteIfExists(path); + } catch (IOException exception) { + LOG.warn("清理 Skill 导出临时产物失败,path={}", path, exception); + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportRequest.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportRequest.java new file mode 100644 index 00000000..22ea8735 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportRequest.java @@ -0,0 +1,19 @@ +package tech.easyflow.skill.imports; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; + +/** + * Skill 导出请求。 + */ +public class SkillExportRequest { + + private List ids = new ArrayList<>(); + private String format; + + public List getIds() { return ids; } + public void setIds(List ids) { this.ids = ids == null ? new ArrayList<>() : new ArrayList<>(ids); } + public String getFormat() { return format; } + public void setFormat(String format) { this.format = format; } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportService.java index e51ee6b3..a7022800 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportService.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportService.java @@ -1,6 +1,5 @@ package tech.easyflow.skill.imports; -import java.io.OutputStream; import java.math.BigInteger; import java.util.Collection; @@ -10,11 +9,11 @@ import java.util.Collection; public interface SkillExportService { /** - * 导出一个或多个 Skill 为标准 zip 包。 + * 在写入 HTTP 响应前完整构建导出临时产物。 * * @param skillIds Skill ID 集合 - * @param outputStream zip 输出流 + * @param format 导出格式 + * @return 可自动清理的导出产物 */ - void exportZip(Collection skillIds, OutputStream outputStream); + SkillExportArtifact prepare(Collection skillIds, SkillImportFormat format); } - diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportServiceImpl.java index b5f0fa19..1eb25ebd 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportServiceImpl.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportServiceImpl.java @@ -1,106 +1,349 @@ package tech.easyflow.skill.imports; -import com.easyagents.skill.util.SkillPaths; +import com.easyagents.skill.codec.SkillPackageWriteOptions; +import com.easyagents.skill.codec.ZipSkillPackageCodec; +import com.easyagents.skill.exception.SkillPackageException; +import com.easyagents.skill.model.SkillPackage; +import com.easyagents.skill.model.SkillPackageLayout; +import com.easyagents.skill.model.SkillPackageLimits; +import com.easyagents.skill.validation.SkillValidationIssue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.skill.entity.Skill; -import tech.easyflow.skill.entity.SkillAsset; -import tech.easyflow.skill.entity.SkillReference; -import tech.easyflow.skill.entity.SkillScript; import tech.easyflow.skill.service.SkillService; import tech.easyflow.skill.store.DBSkillContentStore; +import tech.easyflow.skill.support.SkillModelConverter; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.math.BigInteger; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; import java.util.Collection; -import java.util.LinkedHashSet; +import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; +import java.util.zip.Deflater; /** - * Skill zip 导出服务实现。 + * 标准 Skill ZIP 与 EasyFlow Bundle 安全导出服务。 */ @Service public class SkillExportServiceImpl implements SkillExportService { + private static final Logger LOG = LoggerFactory.getLogger(SkillExportServiceImpl.class); + private final SkillService skillService; private final DBSkillContentStore contentStore; + private final EasyFlowSkillManifestCodec manifestCodec; /** * 创建 Skill 导出服务。 * * @param skillService Skill 服务 - * @param contentStore Skill asset 内容存储 + * @param contentStore 二进制内容仓库 + * @param manifestCodec EasyFlow manifest 编解码器 */ - public SkillExportServiceImpl(SkillService skillService, DBSkillContentStore contentStore) { + public SkillExportServiceImpl(SkillService skillService, + DBSkillContentStore contentStore, + EasyFlowSkillManifestCodec manifestCodec) { this.skillService = skillService; this.contentStore = contentStore; + this.manifestCodec = manifestCodec; } /** * {@inheritDoc} */ @Override - public void exportZip(Collection skillIds, OutputStream outputStream) { + public SkillExportArtifact prepare(Collection skillIds, SkillImportFormat format) { if (skillIds == null || skillIds.isEmpty()) { throw new BusinessException("请选择要导出的 Skill"); } - try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8)) { - Set folderNames = new LinkedHashSet<>(); - for (BigInteger skillId : skillIds) { - Skill skill = skillService.getDetail(skillId); - writeSkill(zipOutputStream, folderNames, skill); + SkillImportFormat effectiveFormat = format == null ? SkillImportFormat.STANDARD : format; + List skills = loadAuthorizedSkills( + skillIds, effectiveFormat == SkillImportFormat.EASYFLOW); + Path standardPackage = null; + Path finalPackage = null; + try { + standardPackage = Files.createTempFile("easyflow-skill-standard-", ".zip"); + encodeStandard(skills, standardPackage); + finalPackage = effectiveFormat == SkillImportFormat.STANDARD + ? standardPackage : buildEasyFlowBundle(skills, standardPackage); + if (!finalPackage.equals(standardPackage)) { + deleteQuietly(standardPackage); } - } catch (IOException e) { - throw new BusinessException("导出 Skill 失败"); + String fileStem = skills.size() == 1 ? safeFileStem(skills.get(0).getName()) : "skills"; + return new SkillExportArtifact(finalPackage, + fileStem + (effectiveFormat == SkillImportFormat.EASYFLOW ? ".efskill" : ".zip"), + effectiveFormat == SkillImportFormat.EASYFLOW + ? "application/vnd.easyflow.skill+zip" : "application/zip"); + } catch (BusinessException exception) { + deleteQuietly(finalPackage != null && !finalPackage.equals(standardPackage) ? finalPackage : null); + deleteQuietly(standardPackage); + throw exception; + } catch (SkillPackageException exception) { + deleteQuietly(finalPackage != null && !finalPackage.equals(standardPackage) ? finalPackage : null); + deleteQuietly(standardPackage); + throw mapPackageException(exception, effectiveFormat, skillIds); + } catch (Exception exception) { + deleteQuietly(finalPackage != null && !finalPackage.equals(standardPackage) ? finalPackage : null); + deleteQuietly(standardPackage); + LOG.error("导出 Skill 包失败,format={}, skillIds={}", effectiveFormat, skillIds, exception); + throw new BusinessException(500, 500, "导出 Skill 包失败,请稍后重试", exception); } } - private void writeSkill(ZipOutputStream zipOutputStream, Set folderNames, Skill skill) throws IOException { - String folder = uniqueFolderName(folderNames, skill.getName()); - writeText(zipOutputStream, folder + "/" + SkillPaths.SKILL_FILE, skill.getSkillContent()); - if (skill.getReferences() != null) { - for (SkillReference reference : skill.getReferences()) { - writeText(zipOutputStream, folder + "/" + reference.getPath(), reference.getContent()); + private List loadAuthorizedSkills(Collection skillIds, boolean includeCapabilities) { + List skills = new ArrayList<>(); + Set uniqueIds = new HashSet<>(); + for (BigInteger skillId : skillIds) { + if (skillId != null && uniqueIds.add(skillId)) { + // 标准 ZIP 不读取平台能力目标;两个入口都在服务端逐项执行 Skill READ 权限校验。 + skills.add(includeCapabilities + ? skillService.getDetail(skillId) + : skillService.getPackageDetail(skillId)); } } - if (skill.getScripts() != null) { - for (SkillScript script : skill.getScripts()) { - writeText(zipOutputStream, folder + "/" + script.getPath(), script.getContent()); - } + if (skills.isEmpty()) { + throw new BusinessException("请选择有效的 Skill"); } - if (skill.getAssets() != null) { - for (SkillAsset asset : skill.getAssets()) { - zipOutputStream.putNextEntry(new ZipEntry(folder + "/" + asset.getPath())); - zipOutputStream.write(contentStore.readAllBytes(asset.getContentRef())); - zipOutputStream.closeEntry(); + return skills; + } + + private void encodeStandard(List skills, Path target) throws IOException { + List agentSkills = skills.stream() + .map(SkillModelConverter::toAgentSkill) + .toList(); + SkillPackage skillPackage = new SkillPackage( + agentSkills.size() == 1 ? SkillPackageLayout.SINGLE_DIRECTORY : SkillPackageLayout.MULTI_DIRECTORY, + agentSkills); + try (OutputStream output = Files.newOutputStream(target, StandardOpenOption.TRUNCATE_EXISTING)) { + new ZipSkillPackageCodec(contentStore).encode(skillPackage, output, SkillPackageWriteOptions.defaults()); + } + ensureStandardDirectoryEntries(target); + } + + /** + * 为标准包中的每个 Skill 根目录补齐可移植的标准空目录项。 + * + * @param packagePath 已由标准编解码器生成的 ZIP 路径 + * @throws IOException 读取、重写或替换 ZIP 失败时抛出 + */ + private void ensureStandardDirectoryEntries(Path packagePath) throws IOException { + Path rewritten = Files.createTempFile("easyflow-skill-directories-", ".zip"); + Set entryNames = new HashSet<>(); + List skillRoots = new ArrayList<>(); + try { + try (ZipInputStream input = new ZipInputStream( + Files.newInputStream(packagePath), StandardCharsets.UTF_8); + ZipOutputStream output = new ZipOutputStream( + Files.newOutputStream(rewritten, StandardOpenOption.TRUNCATE_EXISTING), + StandardCharsets.UTF_8)) { + ZipEntry entry; + byte[] buffer = new byte[8192]; + while ((entry = input.getNextEntry()) != null) { + String entryName = entry.getName(); + entryNames.add(entryName); + if (entryName.endsWith("/SKILL.md")) { + skillRoots.add(entryName.substring(0, entryName.length() - "SKILL.md".length())); + } else if ("SKILL.md".equals(entryName)) { + skillRoots.add(""); + } + ZipEntry copied = new ZipEntry(entryName); + copied.setTime(0L); + output.putNextEntry(copied); + if (!entry.isDirectory()) { + int length; + while ((length = input.read(buffer)) >= 0) { + if (length > 0) { + output.write(buffer, 0, length); + } + } + } + output.closeEntry(); + } + for (String root : skillRoots) { + for (String directory : List.of("references/", "scripts/", "assets/")) { + String directoryPath = root + directory; + if (entryNames.add(directoryPath)) { + writeDirectoryEntry(output, directoryPath); + } + } + } + output.finish(); } + Files.move(rewritten, packagePath, StandardCopyOption.REPLACE_EXISTING); + } finally { + deleteQuietly(rewritten); } } - private void writeText(ZipOutputStream zipOutputStream, String path, String content) throws IOException { - zipOutputStream.putNextEntry(new ZipEntry(path)); - zipOutputStream.write((content == null ? "" : content).getBytes(StandardCharsets.UTF_8)); - zipOutputStream.closeEntry(); - } - - private String uniqueFolderName(Set folderNames, String name) { - String base = sanitizeFolderName(name); - String candidate = base; - int index = 2; - while (!folderNames.add(candidate)) { - candidate = base + "-" + index++; + private Path buildEasyFlowBundle(List skills, Path standardPackage) throws IOException { + SkillPackageLimits limits = SkillPackageLimits.defaults(); + Path bundle = Files.createTempFile("easyflow-skill-bundle-", ".efskill"); + try { + byte[] manifestBytes = manifestCodec.encode(skills); + long totalBytes = addExportBytes(0, manifestBytes.length, limits.getMaxTotalUncompressedBytes()); + int entryCount = 1; + try (ZipOutputStream output = new ZipOutputStream( + Files.newOutputStream(bundle, StandardOpenOption.TRUNCATE_EXISTING), StandardCharsets.UTF_8)) { + // 禁用二次高比率压缩,保证成功导出的外层 Bundle 能通过同一导入压缩比门禁。 + output.setLevel(Deflater.NO_COMPRESSION); + writeEntry(output, EasyFlowSkillManifestCodec.MANIFEST_PATH, manifestBytes); + try (ZipInputStream input = new ZipInputStream( + Files.newInputStream(standardPackage), StandardCharsets.UTF_8)) { + ZipEntry entry; + byte[] buffer = new byte[8192]; + while ((entry = input.getNextEntry()) != null) { + if (++entryCount > limits.getMaxEntryCount() + 1) { + throw exportLimit("EasyFlow Skill 包文件数量超过限制"); + } + String targetPath = "skills/" + entry.getName(); + if (targetPath.length() > limits.getMaxPathLength() + || targetPath.split("/").length > limits.getMaxPathDepth()) { + throw exportLimit("EasyFlow Skill 包路径长度或层级超过限制"); + } + ZipEntry targetEntry = new ZipEntry(targetPath); + targetEntry.setTime(0L); + output.putNextEntry(targetEntry); + if (!entry.isDirectory()) { + int length; + while ((length = input.read(buffer)) >= 0) { + if (length == 0) { + continue; + } + totalBytes = addExportBytes( + totalBytes, length, limits.getMaxTotalUncompressedBytes()); + output.write(buffer, 0, length); + } + } + output.closeEntry(); + } + } + output.finish(); + } + if (Files.size(bundle) > limits.getMaxCompressedPackageBytes()) { + throw exportLimit("EasyFlow Skill 包压缩文件超过限制"); + } + return bundle; + } catch (RuntimeException | IOException exception) { + deleteQuietly(bundle); + throw exception; } - return candidate; } - private String sanitizeFolderName(String value) { - String sanitized = value == null ? "skill" : value.trim().replaceAll("[\\\\/:*?\"<>|\\s]+", "-"); - sanitized = sanitized.replaceAll("^-+", "").replaceAll("-+$", ""); - return sanitized.isBlank() ? "skill" : sanitized; + private long addExportBytes(long current, long increment, long limit) { + if (increment < 0 || current > limit - increment) { + throw exportLimit("EasyFlow Skill 包解压总大小超过限制"); + } + return current + increment; + } + + private BusinessException exportLimit(String message) { + return new BusinessException(413, 4131, message); + } + + private BusinessException mapPackageException(SkillPackageException exception, + SkillImportFormat format, + Collection skillIds) { + List codes = new ArrayList<>(); + codes.add(exception.getCode() == null ? "SKILL_PACKAGE_FAILED" : exception.getCode()); + if (exception.getReport() != null) { + exception.getReport().getIssues().stream() + .map(SkillValidationIssue::getCode) + .forEach(codes::add); + } + if (codes.stream().anyMatch(code -> Set.of( + "ZIP_IO_ERROR", "CONTENT_STORE_ERROR", "CONTENT_NOT_FOUND", "SKILL_CONTENT_STORE_ERROR", + "SKILL_CONTENT_ROLLBACK_ERROR", "CONTENT_REF_MISMATCH", + "RESOURCE_SIZE_MISMATCH", "RESOURCE_HASH_MISMATCH", "CRC_MISMATCH") + .contains(code))) { + LOG.error("导出 Skill 包内部失败,format={}, skillIds={}, code={}, path={}", + format, skillIds, exception.getCode(), exception.getPath(), exception); + return new BusinessException(500, 500, "导出 Skill 包失败,请稍后重试", exception); + } + if (codes.stream().anyMatch(code -> code != null && (code.endsWith("_LIMIT") + || code.contains("SIZE_LIMIT")))) { + return new BusinessException(413, 4131, + "Skill 包超过导出限制:" + firstPackageMessage(exception), exception); + } + return new BusinessException(400, 4001, + "Skill 包不符合导出规范:" + firstPackageMessage(exception), exception); + } + + /** + * 读取结构化报告中的首个可执行错误消息。 + * + * @param exception M18 包异常 + * @return 错误消息 + */ + private String firstPackageMessage(SkillPackageException exception) { + if (exception.getReport() != null) { + return exception.getReport().getIssues().stream() + .map(SkillValidationIssue::getMessage) + .filter(message -> message != null && !message.isBlank()) + .findFirst() + .orElse("Skill 包校验失败"); + } + return exception.getMessage() == null || exception.getMessage().isBlank() + ? "Skill 包校验失败" : exception.getMessage(); + } + + private void writeEntry(ZipOutputStream output, String path, byte[] bytes) throws IOException { + ZipEntry entry = new ZipEntry(path); + entry.setTime(0L); + output.putNextEntry(entry); + output.write(bytes); + output.closeEntry(); + } + + /** + * 写入确定时间戳的 ZIP 目录项。 + * + * @param output ZIP 输出流 + * @param path 以斜杠结尾的目录路径 + * @throws IOException 写入目录项失败时抛出 + */ + private void writeDirectoryEntry(ZipOutputStream output, String path) throws IOException { + ZipEntry entry = new ZipEntry(path.endsWith("/") ? path : path + "/"); + entry.setTime(0L); + output.putNextEntry(entry); + output.closeEntry(); + } + + private String safeFileStem(String value) { + if (value == null || value.isBlank()) { + return "skill"; + } + String normalized = java.text.Normalizer.normalize(value, java.text.Normalizer.Form.NFKC) + .toLowerCase(java.util.Locale.ROOT) + .replaceAll("[^a-z0-9_-]+", "-") + .replaceAll("^-+|-+$", ""); + if (normalized.isBlank()) { + return "skill"; + } + return normalized.substring(0, Math.min(normalized.length(), 80)); + } + + private void deleteQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException exception) { + LOG.warn("清理 Skill 导出临时文件失败,path={}", path, exception); + } } } - diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportCapabilityMapping.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportCapabilityMapping.java new file mode 100644 index 00000000..1bc41db5 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportCapabilityMapping.java @@ -0,0 +1,35 @@ +package tech.easyflow.skill.imports; + +import java.math.BigInteger; + +/** + * EasyFlow Bundle 能力目标映射项。 + */ +public class SkillImportCapabilityMapping { + + private String bindingKey; + private String packageRoot; + private String capabilityType; + private String targetLogicalRef; + private String targetName; + private String status; + private BigInteger targetId; + private boolean disabled; + + public String getBindingKey() { return bindingKey; } + public void setBindingKey(String bindingKey) { this.bindingKey = bindingKey; } + public String getPackageRoot() { return packageRoot; } + public void setPackageRoot(String packageRoot) { this.packageRoot = packageRoot; } + public String getCapabilityType() { return capabilityType; } + public void setCapabilityType(String capabilityType) { this.capabilityType = capabilityType; } + public String getTargetLogicalRef() { return targetLogicalRef; } + public void setTargetLogicalRef(String targetLogicalRef) { this.targetLogicalRef = targetLogicalRef; } + public String getTargetName() { return targetName; } + public void setTargetName(String targetName) { this.targetName = targetName; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public BigInteger getTargetId() { return targetId; } + public void setTargetId(BigInteger targetId) { this.targetId = targetId; } + public boolean isDisabled() { return disabled; } + public void setDisabled(boolean disabled) { this.disabled = disabled; } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportCapabilityOverride.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportCapabilityOverride.java new file mode 100644 index 00000000..4fa88547 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportCapabilityOverride.java @@ -0,0 +1,15 @@ +package tech.easyflow.skill.imports; + +import java.math.BigInteger; + +/** + * 导入确认阶段的窄能力映射请求。 + * + * @param bindingKey manifest 中的能力绑定键 + * @param targetId 当前环境目标 ID,禁用时为空 + * @param disabled 是否保持未映射并禁用 + */ +public record SkillImportCapabilityOverride(String bindingKey, + BigInteger targetId, + boolean disabled) { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportConfirmRequest.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportConfirmRequest.java new file mode 100644 index 00000000..6794bfcd --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportConfirmRequest.java @@ -0,0 +1,30 @@ +package tech.easyflow.skill.imports; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Skill 导入确认请求。 + */ +public class SkillImportConfirmRequest { + + private String importToken; + private BigInteger categoryId; + private String conflictStrategy; + private Map renames = new LinkedHashMap<>(); + private List capabilityMappings = new ArrayList<>(); + + public String getImportToken() { return importToken; } + public void setImportToken(String importToken) { this.importToken = importToken; } + public BigInteger getCategoryId() { return categoryId; } + public void setCategoryId(BigInteger categoryId) { this.categoryId = categoryId; } + public String getConflictStrategy() { return conflictStrategy; } + public void setConflictStrategy(String conflictStrategy) { this.conflictStrategy = conflictStrategy; } + public Map getRenames() { return renames; } + public void setRenames(Map renames) { this.renames = renames == null ? new LinkedHashMap<>() : new LinkedHashMap<>(renames); } + public List getCapabilityMappings() { return capabilityMappings; } + public void setCapabilityMappings(List capabilityMappings) { this.capabilityMappings = capabilityMappings == null ? new ArrayList<>() : new ArrayList<>(capabilityMappings); } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportConflictStrategy.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportConflictStrategy.java new file mode 100644 index 00000000..cd4e009a --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportConflictStrategy.java @@ -0,0 +1,31 @@ +package tech.easyflow.skill.imports; + +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.util.Locale; + +/** + * Skill 导入同名冲突策略。 + */ +public enum SkillImportConflictStrategy { + REJECT, + RENAME, + OVERWRITE; + + /** + * 解析冲突策略,空值默认拒绝。 + * + * @param value 策略编码 + * @return 冲突策略 + */ + public static SkillImportConflictStrategy fromOrDefault(String value) { + if (value == null || value.isBlank()) { + return REJECT; + } + try { + return valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + throw new BusinessException("不支持的 Skill 导入冲突策略:" + value); + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportFormat.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportFormat.java new file mode 100644 index 00000000..c306faed --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportFormat.java @@ -0,0 +1,30 @@ +package tech.easyflow.skill.imports; + +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.util.Locale; + +/** + * Skill 导入导出格式。 + */ +public enum SkillImportFormat { + STANDARD, + EASYFLOW; + + /** + * 解析格式编码。 + * + * @param value 格式编码 + * @return 格式 + */ + public static SkillImportFormat from(String value) { + if (value == null || value.isBlank()) { + return STANDARD; + } + try { + return valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + throw new BusinessException("不支持的 Skill 包格式:" + value); + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreview.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreview.java index 5eae7885..942b1e84 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreview.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreview.java @@ -2,6 +2,8 @@ package tech.easyflow.skill.imports; import java.util.ArrayList; import java.util.List; +import java.util.Date; +import tech.easyflow.skill.validation.SkillValidationIssue; /** * Skill 导入预览结果。 @@ -9,6 +11,11 @@ import java.util.List; public class SkillImportPreview { private List skills = new ArrayList<>(); + private String importToken; + private String format; + private Date expiresAt; + private List capabilityMappings = new ArrayList<>(); + private List issues = new ArrayList<>(); /** * 获取导入 Skill 预览项。 @@ -27,5 +34,15 @@ public class SkillImportPreview { public void setSkills(List skills) { this.skills = skills == null ? new ArrayList<>() : skills; } -} + public String getImportToken() { return importToken; } + public void setImportToken(String importToken) { this.importToken = importToken; } + public String getFormat() { return format; } + public void setFormat(String format) { this.format = format; } + public Date getExpiresAt() { return expiresAt; } + public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; } + public List getCapabilityMappings() { return capabilityMappings; } + public void setCapabilityMappings(List capabilityMappings) { this.capabilityMappings = capabilityMappings == null ? new ArrayList<>() : new ArrayList<>(capabilityMappings); } + public List getIssues() { return issues; } + public void setIssues(List issues) { this.issues = issues == null ? new ArrayList<>() : new ArrayList<>(issues); } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreviewFile.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreviewFile.java new file mode 100644 index 00000000..e06dff1d --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreviewFile.java @@ -0,0 +1,103 @@ +package tech.easyflow.skill.imports; + +/** + * Skill 导入预览中的逻辑文件摘要,不包含文件正文、存储引用或物理路径。 + */ +public class SkillImportPreviewFile { + + private String path; + private String kind; + private String mediaType; + private boolean text; + private long size; + + /** + * 获取 Skill 根目录内的规范相对路径。 + * + * @return 逻辑相对路径 + */ + public String getPath() { + return path; + } + + /** + * 设置 Skill 根目录内的规范相对路径。 + * + * @param path 逻辑相对路径 + */ + public void setPath(String path) { + this.path = path; + } + + /** + * 获取文件语义类型。 + * + * @return 文件语义类型 + */ + public String getKind() { + return kind; + } + + /** + * 设置文件语义类型。 + * + * @param kind 文件语义类型 + */ + public void setKind(String kind) { + this.kind = kind; + } + + /** + * 获取媒体类型。 + * + * @return 媒体类型 + */ + public String getMediaType() { + return mediaType; + } + + /** + * 设置媒体类型。 + * + * @param mediaType 媒体类型 + */ + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + /** + * 判断文件是否为严格 UTF-8 文本。 + * + * @return 文本文件时为 true + */ + public boolean isText() { + return text; + } + + /** + * 设置文本标记。 + * + * @param text 是否为严格 UTF-8 文本 + */ + public void setText(boolean text) { + this.text = text; + } + + /** + * 获取文件字节数。 + * + * @return 文件字节数 + */ + public long getSize() { + return size; + } + + /** + * 设置文件字节数。 + * + * @param size 文件字节数 + */ + public void setSize(long size) { + this.size = size; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreviewItem.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreviewItem.java index 5110eaf9..27302c35 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreviewItem.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreviewItem.java @@ -1,5 +1,8 @@ package tech.easyflow.skill.imports; +import java.util.ArrayList; +import java.util.List; + /** * Skill 导入预览项。 */ @@ -12,6 +15,12 @@ public class SkillImportPreviewItem { private int scriptCount; private int assetCount; private boolean conflict; + private Boolean overwriteAllowed; + private String conflictReason; + private String packageRoot; + private int resourceCount; + private String packageHash; + private List files = new ArrayList<>(); /** * 获取包内 Skill ID。 @@ -43,5 +52,56 @@ public class SkillImportPreviewItem { public void setAssetCount(int assetCount) { this.assetCount = assetCount; } public boolean isConflict() { return conflict; } public void setConflict(boolean conflict) { this.conflict = conflict; } -} + /** + * 获取当前用户是否允许覆盖同名 Skill。 + * + * @return 存在冲突时的覆盖许可;无冲突时为空 + */ + public Boolean getOverwriteAllowed() { return overwriteAllowed; } + + /** + * 设置当前用户是否允许覆盖同名 Skill。 + * + * @param overwriteAllowed 覆盖许可 + */ + public void setOverwriteAllowed(Boolean overwriteAllowed) { this.overwriteAllowed = overwriteAllowed; } + + /** + * 获取禁止覆盖的原因编码。 + * + * @return 原因编码;允许覆盖或无冲突时为空 + */ + public String getConflictReason() { return conflictReason; } + + /** + * 设置禁止覆盖的原因编码。 + * + * @param conflictReason 原因编码 + */ + public void setConflictReason(String conflictReason) { this.conflictReason = conflictReason; } + public String getPackageRoot() { return packageRoot; } + public void setPackageRoot(String packageRoot) { this.packageRoot = packageRoot; } + public int getResourceCount() { return resourceCount; } + public void setResourceCount(int resourceCount) { this.resourceCount = resourceCount; } + public String getPackageHash() { return packageHash; } + public void setPackageHash(String packageHash) { this.packageHash = packageHash; } + + /** + * 获取包内逻辑文件摘要。 + * + * @return 按规范路径排序的文件摘要 + */ + public List getFiles() { + return files; + } + + /** + * 设置包内逻辑文件摘要。 + * + * @param files 文件摘要 + */ + public void setFiles(List files) { + this.files = files == null ? new ArrayList<>() : new ArrayList<>(files); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportService.java index d768aca7..5e63ae04 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportService.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportService.java @@ -2,9 +2,8 @@ package tech.easyflow.skill.imports; import tech.easyflow.skill.entity.Skill; -import java.io.InputStream; -import java.math.BigInteger; import java.util.List; +import org.springframework.web.multipart.MultipartFile; /** * Skill zip 导入服务。 @@ -12,21 +11,26 @@ import java.util.List; public interface SkillImportService { /** - * 预览 zip 中的 Skill 包。 + * 上传并创建可单次确认的导入预览。 * - * @param inputStream zip 输入流 - * @return 导入预览 + * @param file 标准 ZIP 或 .efskill + * @return 导入预览与 importToken */ - SkillImportPreview preview(InputStream inputStream); + SkillImportPreview preview(MultipartFile file); /** - * 确认导入 zip 中的 Skill 包。 + * 使用单次 importToken 确认导入。 * - * @param inputStream zip 输入流 - * @param categoryId 目标分类 ID,可为空 - * @param overwriteDraft 是否覆盖同名草稿 - * @return 已保存 Skill 列表 + * @param request 导入确认请求 + * @return 已保存 Skill */ - List importZip(InputStream inputStream, BigInteger categoryId, boolean overwriteDraft); -} + List confirm(SkillImportConfirmRequest request); + /** + * 取消导入预览并清理临时包。 + * + * @param importToken 导入令牌 + */ + void cancel(String importToken); + +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportServiceImpl.java index ade3b3cd..8c3f87d1 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportServiceImpl.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportServiceImpl.java @@ -1,52 +1,173 @@ package tech.easyflow.skill.imports; +import com.easyagents.skill.codec.SkillPackageReadOptions; +import com.easyagents.skill.codec.SkillPackageReadResult; import com.easyagents.skill.codec.ZipSkillPackageCodec; +import com.easyagents.skill.exception.SkillPackageException; +import com.easyagents.skill.model.SkillDocument; +import com.easyagents.skill.model.SkillPackageLimits; +import com.easyagents.skill.store.SkillContentStage; +import com.easyagents.skill.store.SkillContentStore; +import com.easyagents.skill.util.SkillFrontmatter; import com.easyagents.skill.util.SkillHashes; +import com.easyagents.skill.util.SkillResources; import com.mybatisflex.core.query.QueryWrapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import tech.easyflow.ai.enums.PublishStatus; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.capability.SkillCapabilityBindingService; +import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService; import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.entity.SkillImportStage; +import tech.easyflow.skill.enums.SkillCapabilityType; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.skill.security.SkillSensitiveConfigSanitizer; import tech.easyflow.skill.service.SkillService; import tech.easyflow.skill.store.DBSkillContentStore; import tech.easyflow.skill.support.SkillModelConverter; +import tech.easyflow.skill.validation.SkillValidationIssue; +import tech.easyflow.skill.validation.SkillValidationResult; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.ResourceAccessService; +import java.io.IOException; import java.io.InputStream; import java.math.BigInteger; -import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Set; /** - * Skill zip 导入服务实现。 + * 标准 ZIP 与 EasyFlow Bundle 的 token 化导入服务。 */ @Service public class SkillImportServiceImpl implements SkillImportService { + private static final Logger LOG = LoggerFactory.getLogger(SkillImportServiceImpl.class); + private static final int MAX_SKILLS = 100; + private static final int MAX_BINDINGS_PER_SKILL = 200; + private static final int MAX_TOTAL_BINDINGS = 1_000; + private static final int NAME_CONFLICT_ERROR_CODE = 4092; + private static final String NAME_UNAVAILABLE_REASON = "NAME_UNAVAILABLE"; + private final SkillService skillService; + private final SkillCapabilityBindingService capabilityBindingService; + private final SkillCapabilityTargetAccessService targetAccessService; private final DBSkillContentStore contentStore; + private final FileStorageService fileStorageService; + private final SkillImportStageStore stageStore; + private final EasyFlowBundleReader bundleReader; + private final ResourceAccessService resourceAccessService; /** * 创建 Skill 导入服务。 * * @param skillService Skill 服务 - * @param contentStore Skill asset 内容存储 + * @param capabilityBindingService 能力绑定服务 + * @param targetAccessService 目标映射服务 + * @param contentStore 二进制内容仓库 + * @param fileStorageService 文件存储 + * @param stageStore 导入临时包仓库 + * @param bundleReader EasyFlow Bundle 读取器 + * @param resourceAccessService Skill 资源权限服务 */ - public SkillImportServiceImpl(SkillService skillService, DBSkillContentStore contentStore) { + public SkillImportServiceImpl(SkillService skillService, + SkillCapabilityBindingService capabilityBindingService, + SkillCapabilityTargetAccessService targetAccessService, + DBSkillContentStore contentStore, + @Qualifier("default") FileStorageService fileStorageService, + SkillImportStageStore stageStore, + EasyFlowBundleReader bundleReader, + ResourceAccessService resourceAccessService) { this.skillService = skillService; + this.capabilityBindingService = capabilityBindingService; + this.targetAccessService = targetAccessService; this.contentStore = contentStore; + this.fileStorageService = fileStorageService; + this.stageStore = stageStore; + this.bundleReader = bundleReader; + this.resourceAccessService = resourceAccessService; + } + + /** + * 仅供同包测试直接校验标准 ZIP 的只读预览转换,不属于正式导入服务契约。 + * + * @param inputStream 标准 ZIP 输入流 + * @return 不含导入令牌的预览 + */ + SkillImportPreview previewStandardForTest(InputStream inputStream) { + SkillPackageReadResult decoded = new ZipSkillPackageCodec(new PreviewContentStore()) + .decode(inputStream, SkillPackageReadOptions.reportOnly()); + return buildPreview(decoded, SkillImportFormat.STANDARD, null, null); } /** * {@inheritDoc} */ @Override - public SkillImportPreview preview(InputStream inputStream) { - List importedSkills = new ZipSkillPackageCodec().importZip(inputStream); - SkillImportPreview preview = new SkillImportPreview(); - preview.setSkills(importedSkills.stream().map(this::toPreviewItem).toList()); - return preview; + public SkillImportPreview preview(MultipartFile file) { + validateUpload(file); + LoginAccount account = requireAccount(); + String path = null; + SkillImportFormat format = file.getOriginalFilename().toLowerCase(java.util.Locale.ROOT).endsWith(".efskill") + ? SkillImportFormat.EASYFLOW : SkillImportFormat.STANDARD; + try { + path = fileStorageService.save(file, "skill-imports/" + account.getTenantId()); + if (path == null || path.isBlank()) { + throw new BusinessException(500, 500, "Skill 导入临时包存储失败,请稍后重试"); + } + format = detectFormat(path, file.getOriginalFilename()); + PreviewDecode decoded = decodePreview(path, format); + SkillImportPreview preview = buildPreview(decoded.readResult, format, null, decoded.manifest); + SkillImportStage stage = stageStore.create(path, file.getOriginalFilename(), format); + preview.setImportToken(stage.getImportToken()); + preview.setExpiresAt(stage.getExpiresAt()); + return preview; + } catch (SkillPackageException exception) { + cleanupUnregisteredPath(path); + return failedPreview(format, exception); + } catch (BusinessException exception) { + cleanupUnregisteredPath(path); + // 授权失败属于调用方权限问题,不能伪装成可修复的包结构校验问题。 + if (exception.getHttpStatus() == 401 || exception.getHttpStatus() == 403) { + throw exception; + } + if (exception.getHttpStatus() < 500) { + if (exception instanceof SkillManifestValidationException validationException) { + return failedPreview(format, validationException.getValidationCode(), + validationException.getMessage(), validationException.getPath()); + } + return failedPreview(format, + format == SkillImportFormat.EASYFLOW + ? "EASYFLOW_BUNDLE_INVALID" : "STANDARD_PACKAGE_INVALID", + exception.getMessage(), + format == SkillImportFormat.EASYFLOW + ? EasyFlowSkillManifestCodec.MANIFEST_PATH : null); + } + throw exception; + } catch (RuntimeException exception) { + cleanupUnregisteredPath(path); + throw exception; + } } /** @@ -54,49 +175,847 @@ public class SkillImportServiceImpl implements SkillImportService { */ @Override @Transactional(rollbackFor = Exception.class) - public List importZip(InputStream inputStream, BigInteger categoryId, boolean overwriteDraft) { - List importedSkills = new ZipSkillPackageCodec(contentStore).importZip(inputStream); - List savedSkills = new ArrayList<>(); - for (com.easyagents.skill.model.Skill imported : importedSkills) { - Skill existing = findByName(imported.getName()); - if (existing != null) { - if (!overwriteDraft || !PublishStatus.DRAFT.getCode().equals(existing.getPublishStatus())) { - throw new BusinessException("Skill 已存在,且不允许覆盖:" + imported.getName()); - } - Skill replacement = SkillModelConverter.fromAgentSkill(imported); - replacement.setId(existing.getId()); - replacement.setCategoryId(categoryId); - replacement.setSourceType("ZIP"); - replacement.setPackageHash(SkillHashes.sha256Hex(imported.getSkillContent().getBytes(StandardCharsets.UTF_8))); - savedSkills.add(skillService.updateDraft(replacement)); - } else { - Skill skill = SkillModelConverter.fromAgentSkill(imported); - skill.setCategoryId(categoryId); - skill.setSourceType("ZIP"); - skill.setPackageHash(SkillHashes.sha256Hex(imported.getSkillContent().getBytes(StandardCharsets.UTF_8))); - savedSkills.add(skillService.saveDraft(skill)); - } + public List confirm(SkillImportConfirmRequest request) { + if (request == null) { + throw new BusinessException("Skill 导入确认参数不能为空"); + } + SkillImportStage stage = stageStore.consume(request.getImportToken()); + scheduleStageCleanup(stage); + SkillImportFormat format = SkillImportFormat.from(stage.getFormat()); + DecodedImport decoded = decodeForImport(stage.getFilePath(), format); + try { + return saveDecoded(decoded, request, format); + } finally { + decoded.close(); } - return savedSkills; } - private SkillImportPreviewItem toPreviewItem(com.easyagents.skill.model.Skill skill) { + /** + * {@inheritDoc} + */ + @Override + public void cancel(String importToken) { + stageStore.cancel(importToken); + } + + private PreviewDecode decodePreview(String path, SkillImportFormat format) { + if (format == SkillImportFormat.STANDARD) { + try (InputStream input = fileStorageService.readStream(path)) { + SkillPackageReadResult result = new ZipSkillPackageCodec(new PreviewContentStore()) + .decode(input, SkillPackageReadOptions.reportOnly()); + return new PreviewDecode(result, null); + } catch (IOException exception) { + LOG.error("读取标准 Skill 导入临时包失败,path={}", path, exception); + throw new BusinessException(500, 500, "读取 Skill 导入临时包失败", exception); + } + } + try (InputStream input = fileStorageService.readStream(path); + EasyFlowBundleReader.PreparedBundle prepared = bundleReader.prepare(input); + InputStream standard = prepared.openStandardZip()) { + SkillPackageReadResult result = new ZipSkillPackageCodec(new PreviewContentStore()) + .decode(standard, SkillPackageReadOptions.reportOnly()); + return new PreviewDecode(result, prepared.getManifest()); + } catch (IOException exception) { + LOG.error("读取 EasyFlow Skill 导入临时包失败,path={}", path, exception); + throw new BusinessException(500, 500, "读取 EasyFlow Skill 导入临时包失败", exception); + } + } + + private DecodedImport decodeForImport(String path, SkillImportFormat format) { + try { + InputStream stored = fileStorageService.readStream(path); + if (format == SkillImportFormat.STANDARD) { + try (stored) { + SkillPackageReadResult result = new ZipSkillPackageCodec(contentStore) + .decode(stored, SkillPackageReadOptions.defaults()); + return new DecodedImport(result, null, null); + } + } + EasyFlowBundleReader.PreparedBundle prepared; + try (stored) { + prepared = bundleReader.prepare(stored); + } + try (InputStream standard = prepared.openStandardZip()) { + SkillPackageReadResult result = new ZipSkillPackageCodec(contentStore) + .decode(standard, SkillPackageReadOptions.defaults()); + return new DecodedImport(result, prepared.getManifest(), prepared); + } catch (RuntimeException | IOException exception) { + prepared.close(); + throw exception; + } + } catch (IOException exception) { + LOG.error("读取 Skill 导入临时包失败,path={}", path, exception); + throw new BusinessException(500, 500, "读取 Skill 导入临时包失败", exception); + } + } + + private List saveDecoded(DecodedImport decoded, + SkillImportConfirmRequest request, + SkillImportFormat format) { + return saveSkills(decoded.readResult, request, format, decoded.manifest); + } + + private List saveSkills(SkillPackageReadResult decoded, + SkillImportConfirmRequest request, + SkillImportFormat format, + Map manifest) { + if (decoded.getSkillPackage().getSkills().size() > MAX_SKILLS) { + throw new BusinessException("单次最多导入 " + MAX_SKILLS + " 个 Skill"); + } + assertImportable(decoded); + validateConfirmRequest(request); + if (format == SkillImportFormat.EASYFLOW) { + validateManifestAgainstPackage(parseManifest(manifest), decoded.getSkillPackage().getSkills()); + } + SkillImportConflictStrategy strategy = SkillImportConflictStrategy.fromOrDefault(request.getConflictStrategy()); + List saved = new ArrayList<>(); + Map byPackageRoot = new LinkedHashMap<>(); + Set lookupNames = new LinkedHashSet<>(); + decoded.getSkillPackage().getSkills().forEach(skill -> lookupNames.add(skill.getName())); + lookupNames.addAll(request.getRenames().values()); + Map existingByName = findByNames(lookupNames); + for (com.easyagents.skill.model.Skill imported : decoded.getSkillPackage().getSkills()) { + com.easyagents.skill.model.Skill effective = applyRename(imported, request, strategy, existingByName); + Skill entity = SkillModelConverter.fromAgentSkill(effective); + entity.setCategoryId(request.getCategoryId()); + entity.setSourceType(format == SkillImportFormat.EASYFLOW ? "EASYFLOW_BUNDLE" : "STANDARD_ZIP"); + Skill existing = existingByName.get(entity.getName()); + Skill result; + if (existing == null) { + result = saveNewDraft(entity); + } else if (!resourceAccessService.canAccess( + CategoryResourceType.SKILL, existing, ResourceAction.MANAGE)) { + // 权限判断必须早于发布状态判断,避免利用确认结果探测私有 Skill 状态。 + throw nameUnavailable(entity.getName()); + } else if (strategy == SkillImportConflictStrategy.OVERWRITE) { + if (PublishStatus.from(existing.getPublishStatus()) != PublishStatus.DRAFT) { + throw new BusinessException("仅允许覆盖草稿状态的 Skill:" + entity.getName()); + } + entity.setId(existing.getId()); + // 服务层在 SELECT FOR UPDATE 后重验 DRAFT,封闭预览与确认之间的发布竞态。 + result = overwriteExistingDraft(entity); + } else { + throw new BusinessException(409, 4092, + "Skill 已存在,请选择重命名或覆盖:" + entity.getName()); + } + saved.add(result); + byPackageRoot.put(imported.getPackageRoot(), result); + existingByName.put(result.getName(), result); + } + if (format == SkillImportFormat.EASYFLOW) { + applyManifestBindings(manifest, request, byPackageRoot); + saved = saved.stream().map(skill -> skillService.getDetail(skill.getId())).toList(); + } + return saved; + } + + private com.easyagents.skill.model.Skill applyRename(com.easyagents.skill.model.Skill imported, + SkillImportConfirmRequest request, + SkillImportConflictStrategy strategy, + Map existingByName) { + Skill existing = existingByName.get(imported.getName()); + if (existing == null || strategy != SkillImportConflictStrategy.RENAME) { + return imported; + } + String renamed = request.getRenames().get(imported.getPackageRoot()); + if (renamed == null) { + renamed = request.getRenames().get(imported.getName()); + } + if (renamed == null || !renamed.matches("[a-z0-9]+(?:-[a-z0-9]+)*")) { + throw new BusinessException("请为名称不可用的 Skill 提供规范连字符名称:" + imported.getName()); + } + if (existingByName.containsKey(renamed)) { + throw nameUnavailable(renamed); + } + SkillDocument document = SkillFrontmatter.parseDocument(imported.getSkillContent()); + Map values = new LinkedHashMap<>(document.getFrontmatter().getValues()); + values.put("name", renamed); + com.easyagents.skill.model.Skill renamedSkill = com.easyagents.skill.factory.SkillFactory.createWithResources( + null, SkillFrontmatter.serialize(values, document.getMarkdownBody()), imported.getResources()); + renamedSkill.setPackageRoot(renamed); + return renamedSkill; + } + + private void applyManifestBindings(Map manifest, + SkillImportConfirmRequest request, + Map skills) { + List manifestSkills = parseManifest(manifest); + Set knownBindingKeys = new LinkedHashSet<>(); + manifestSkills.forEach(skill -> skill.bindings.forEach(binding -> knownBindingKeys.add(binding.bindingKey))); + Map overrides = new HashMap<>(); + for (SkillImportCapabilityOverride mapping : request.getCapabilityMappings()) { + validateCapabilityMapping(mapping); + if (!knownBindingKeys.contains(mapping.bindingKey())) { + throw new BusinessException("能力映射引用了未知 bindingKey:" + mapping.bindingKey()); + } + if (overrides.put(mapping.bindingKey(), mapping) != null) { + throw new BusinessException("能力映射包含重复 bindingKey:" + mapping.bindingKey()); + } + } + Map resolvedTargets = new HashMap<>(); + for (ManifestSkill manifestSkill : manifestSkills) { + Skill skill = skills.get(manifestSkill.packageRoot); + if (skill == null) { + throw new BusinessException("EasyFlow manifest 引用了包内不存在的 Skill:" + manifestSkill.packageRoot); + } + List bindings = new ArrayList<>(); + for (ManifestBinding source : manifestSkill.bindings) { + SkillImportCapabilityOverride override = overrides.get(source.bindingKey); + BigInteger targetId = override == null ? null : override.targetId(); + boolean disabled = override != null && override.disabled(); + if (targetId == null && !disabled) { + targetId = resolveTargetCached(resolvedTargets, source.type, source.targetLogicalRef); + } + if (targetId == null && !disabled) { + throw new BusinessException("能力目标尚未映射:" + source.targetLogicalRef); + } + SkillCapabilityBinding binding = source.toBinding(); + binding.setTargetId(targetId); + binding.setTargetLogicalRef(source.targetLogicalRef); + binding.setEnabled(!disabled && source.enabled); + bindings.add(binding); + } + capabilityBindingService.replaceBindings(skill.getId(), bindings); + } + } + + private SkillImportPreview buildPreview(SkillPackageReadResult decoded, + SkillImportFormat format, + SkillImportStage stage, + Map manifest) { + SkillImportPreview preview = new SkillImportPreview(); + preview.setFormat(format.name()); + if (stage != null) { + preview.setImportToken(stage.getImportToken()); + preview.setExpiresAt(stage.getExpiresAt()); + } + Map conflicts = findByNames(decoded.getSkillPackage().getSkills().stream() + .map(com.easyagents.skill.model.Skill::getName).collect(java.util.stream.Collectors.toSet())); + preview.setSkills(decoded.getSkillPackage().getSkills().stream() + .map(skill -> toPreviewItem(skill, conflicts.get(skill.getName()))) + .toList()); + List issues = decoded.getValidationReport().getIssues().stream().map(source -> { + SkillValidationIssue issue = SkillValidationIssue.of(source.getSeverity().name(), source.getCode(), + source.getMessage(), source.getPath()); + issue.setLine(source.getLine()); + issue.setColumn(source.getColumn()); + issue.setSuggestion(source.getSuggestion()); + return issue; + }).collect(java.util.stream.Collectors.toCollection(ArrayList::new)); + if (format == SkillImportFormat.EASYFLOW) { + List manifestSkills = parseManifest(manifest); + validateManifestAgainstPackage(manifestSkills, decoded.getSkillPackage().getSkills()); + List mappings = buildCapabilityMappings(manifestSkills); + preview.setCapabilityMappings(mappings); + issues.addAll(validatePreviewBindings(manifestSkills, mappings)); + } + preview.setIssues(issues); + return preview; + } + + /** + * 校验增强包中可由 manifest 和当前已解析目标确定的能力配置。 + * + * @param skills manifest Skill 项 + * @param mappings 已完成自动解析的映射项 + * @return 带 Skill 根路径的结构化问题 + */ + private List validatePreviewBindings(List skills, + List mappings) { + Map targetIds = new HashMap<>(); + for (SkillImportCapabilityMapping mapping : mappings) { + targetIds.put(mapping.getBindingKey(), mapping.getTargetId()); + } + List issues = new ArrayList<>(); + for (ManifestSkill skill : skills) { + if (skill.bindings.isEmpty()) { + continue; + } + List bindings = new ArrayList<>(); + for (ManifestBinding source : skill.bindings) { + SkillCapabilityBinding binding = source.toBinding(); + binding.setTargetId(targetIds.get(source.bindingKey)); + binding.setTargetLogicalRef(source.targetLogicalRef); + bindings.add(binding); + } + SkillValidationResult result = capabilityBindingService.validateImportBindings(bindings); + for (SkillValidationIssue issue : result.getIssues()) { + issues.add(prefixPreviewIssue(skill.packageRoot, issue)); + } + } + return issues; + } + + /** + * 为能力校验问题补充包内 Skill 定位路径。 + * + * @param packageRoot Skill 包根路径 + * @param source 原始能力校验问题 + * @return 可在导入预览中定位的问题副本 + */ + private SkillValidationIssue prefixPreviewIssue(String packageRoot, SkillValidationIssue source) { + String suffix = source.getPath() == null || source.getPath().isBlank() + ? "capabilities" : source.getPath(); + SkillValidationIssue issue = SkillValidationIssue.of(source.getSeverity(), source.getCode(), + source.getMessage(), "skills[" + packageRoot + "]." + suffix); + issue.setLine(source.getLine()); + issue.setColumn(source.getColumn()); + issue.setSuggestion(source.getSuggestion()); + return issue; + } + + private SkillImportPreviewItem toPreviewItem(com.easyagents.skill.model.Skill skill, Skill existing) { SkillImportPreviewItem item = new SkillImportPreviewItem(); - item.setPackageId(skill.getId()); + item.setPackageId(skill.getPackageRoot()); + item.setPackageRoot(skill.getPackageRoot()); item.setName(skill.getName()); item.setDescription(skill.getDescription()); item.setReferenceCount(skill.getReferences().size()); item.setScriptCount(skill.getScripts().size()); item.setAssetCount(skill.getAssets().size()); - item.setConflict(findByName(skill.getName()) != null); + item.setResourceCount(skill.getResources().size()); + item.setPackageHash(calculateSkillPackageHash(skill)); + item.setConflict(existing != null); + if (existing != null) { + boolean manageable = resourceAccessService.canAccess( + CategoryResourceType.SKILL, existing, ResourceAction.MANAGE); + if (!manageable) { + item.setOverwriteAllowed(false); + item.setConflictReason(NAME_UNAVAILABLE_REASON); + } else { + boolean draft = PublishStatus.from(existing.getPublishStatus()) == PublishStatus.DRAFT; + item.setOverwriteAllowed(draft); + item.setConflictReason(draft ? null : "NOT_DRAFT"); + } + } + List files = new ArrayList<>(); + SkillImportPreviewFile skillFile = new SkillImportPreviewFile(); + skillFile.setPath("SKILL.md"); + skillFile.setKind("SKILL"); + skillFile.setMediaType("text/markdown"); + skillFile.setText(true); + skillFile.setSize((skill.getSkillContent() == null ? "" : skill.getSkillContent()) + .getBytes(java.nio.charset.StandardCharsets.UTF_8).length); + files.add(skillFile); + SkillResources.canonicalResources(skill).stream() + .sorted(java.util.Comparator.comparing(com.easyagents.skill.model.SkillResource::getPath)) + .forEach(resource -> { + SkillImportPreviewFile file = new SkillImportPreviewFile(); + file.setPath(resource.getPath()); + file.setKind(resource.getKind().name()); + file.setMediaType(resource.getMediaType()); + file.setText(resource.isText()); + file.setSize(resource.getSize()); + files.add(file); + }); + item.setFiles(files); return item; } - private Skill findByName(String name) { - if (name == null || name.isBlank()) { - return null; + /** + * 保存全新导入草稿,并将预查后的并发同名冲突归一为稳定结果。 + * + * @param skill 待保存 Skill + * @return 已保存草稿 + * @throws BusinessException 名称不可用或保存失败时抛出 + */ + private Skill saveNewDraft(Skill skill) { + try { + return skillService.saveDraft(skill); + } catch (DuplicateKeyException exception) { + throw nameUnavailable(skill.getName()); + } catch (BusinessException exception) { + if (exception.getHttpStatus() == 409 && exception.getErrorCode() == NAME_CONFLICT_ERROR_CODE) { + throw nameUnavailable(skill.getName()); + } + throw exception; } - List skills = skillService.list(QueryWrapper.create().eq(Skill::getName, name)); - return skills.isEmpty() ? null : skills.get(0); + } + + /** + * 覆盖有权管理的草稿,权限或资源竞态统一按名称不可用拒绝。 + * + * @param skill 待覆盖 Skill + * @return 已更新草稿 + * @throws BusinessException 名称不可用或覆盖失败时抛出 + */ + private Skill overwriteExistingDraft(Skill skill) { + try { + return skillService.overwriteImportedDraft(skill); + } catch (BusinessException exception) { + if (exception.getHttpStatus() == 403 || exception.getHttpStatus() == 404) { + throw nameUnavailable(skill.getName()); + } + throw exception; + } + } + + /** + * 构建不暴露私有资源存在性、权限或发布状态的名称冲突异常。 + * + * @param name 导入包声明的 Skill 名称 + * @return 稳定的 HTTP 409 业务异常 + */ + private BusinessException nameUnavailable(String name) { + return new BusinessException(409, NAME_CONFLICT_ERROR_CODE, "Skill 名称不可用:" + name); + } + + private List buildCapabilityMappings(List skills) { + List mappings = new ArrayList<>(); + Map resolvedTargets = new HashMap<>(); + for (ManifestSkill skill : skills) { + for (ManifestBinding binding : skill.bindings) { + BigInteger targetId = resolveTargetCached(resolvedTargets, binding.type, binding.targetLogicalRef); + SkillImportCapabilityMapping mapping = new SkillImportCapabilityMapping(); + mapping.setBindingKey(binding.bindingKey); + mapping.setPackageRoot(skill.packageRoot); + mapping.setCapabilityType(binding.type.name()); + mapping.setTargetLogicalRef(binding.targetLogicalRef); + mapping.setTargetName(binding.targetName); + mapping.setTargetId(targetId); + mapping.setStatus(targetId == null ? "UNRESOLVED" : "RESOLVED"); + mappings.add(mapping); + } + } + return mappings; + } + + @SuppressWarnings("unchecked") + private List parseManifest(Map manifest) { + if (manifest == null || !(manifest.get("skills") instanceof List rawSkills)) { + throw new BusinessException("EasyFlow manifest 缺少 skills 列表"); + } + if (rawSkills.size() > MAX_SKILLS) { + throw new BusinessException("EasyFlow manifest Skill 数量超过限制"); + } + List result = new ArrayList<>(); + int totalBindings = 0; + for (Object rawSkill : rawSkills) { + if (!(rawSkill instanceof Map map)) { + throw new BusinessException("EasyFlow manifest Skill 项格式不正确"); + } + String packageRoot = requiredString(map.get("packageRoot"), "packageRoot", 128); + String packageHash = requiredString(map.get("packageHash"), "packageHash", 128); + List rawBindings = map.get("capabilities") instanceof List list ? list : List.of(); + if (rawBindings.size() > MAX_BINDINGS_PER_SKILL) { + throw new BusinessException("单个 Skill 的能力绑定超过 " + MAX_BINDINGS_PER_SKILL + " 项限制"); + } + totalBindings += rawBindings.size(); + if (totalBindings > MAX_TOTAL_BINDINGS) { + throw new BusinessException("EasyFlow manifest 能力绑定总数超过 " + MAX_TOTAL_BINDINGS + " 项限制"); + } + List bindings = new ArrayList<>(); + for (Object rawBinding : rawBindings) { + if (!(rawBinding instanceof Map bindingMap)) { + throw new BusinessException("EasyFlow manifest 能力绑定格式不正确"); + } + bindings.add(ManifestBinding.from(bindingMap)); + } + result.add(new ManifestSkill(packageRoot, packageHash, bindings)); + } + return result; + } + + private Map findByNames(java.util.Collection names) { + List safeNames = names == null ? List.of() : names.stream() + .filter(name -> name != null && !name.isBlank()).distinct().limit(MAX_SKILLS * 2L).toList(); + if (safeNames.isEmpty()) { + return new LinkedHashMap<>(); + } + Map result = new LinkedHashMap<>(); + for (Skill skill : skillService.list(QueryWrapper.create() + .eq(Skill::getTenantId, requireAccount().getTenantId()) + .in(Skill::getName, safeNames))) { + result.put(skill.getName(), skill); + } + return result; + } + + private BigInteger resolveTargetCached(Map cache, + SkillCapabilityType type, + String logicalRef) { + String key = type.name() + '\u0000' + logicalRef; + if (!cache.containsKey(key)) { + cache.put(key, targetAccessService.resolveLogicalRef(type, logicalRef)); + } + return cache.get(key); + } + + private SkillImportFormat detectFormat(String path, String originalName) { + boolean extensionSuggestsBundle = originalName != null && originalName.toLowerCase(java.util.Locale.ROOT).endsWith(".efskill"); + try (InputStream input = fileStorageService.readStream(path)) { + boolean hasManifest = bundleReader.containsManifest(input); + if (extensionSuggestsBundle && !hasManifest) { + throw new BusinessException(".efskill 文件缺少 EasyFlow manifest"); + } + if (!extensionSuggestsBundle && hasManifest) { + throw new BusinessException("标准 .zip 不能包含 EasyFlow manifest,请使用 .efskill 扩展名"); + } + return extensionSuggestsBundle ? SkillImportFormat.EASYFLOW : SkillImportFormat.STANDARD; + } catch (IOException exception) { + LOG.error("检测 Skill 导入包格式失败,path={}", path, exception); + throw new BusinessException(500, 500, "检测 Skill 导入包格式失败", exception); + } + } + + private void validateUpload(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BusinessException("Skill 导入文件不能为空"); + } + long limit = SkillPackageLimits.defaults().getMaxCompressedPackageBytes(); + if (file.getSize() > limit) { + throw new BusinessException(413, 4131, "Skill 导入文件超过 " + limit + " 字节限制"); + } + String name = file.getOriginalFilename(); + if (name == null || !(name.toLowerCase(java.util.Locale.ROOT).endsWith(".zip") + || name.toLowerCase(java.util.Locale.ROOT).endsWith(".efskill"))) { + throw new BusinessException("Skill 导入仅支持 .zip 或 .efskill 文件"); + } + } + + private void cleanupUnregisteredPath(String path) { + if (path == null || path.isBlank()) { + return; + } + try { + fileStorageService.delete(path); + } catch (RuntimeException cleanupException) { + LOG.error("清理未登记 Skill 导入临时包失败,path={}", path, cleanupException); + } + } + + private void scheduleStageCleanup(SkillImportStage stage) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + stageStore.complete(stage); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + try { + stageStore.complete(stage); + } catch (RuntimeException exception) { + LOG.error("完成 Skill 导入后清理临时包失败,token={}", stage.getImportToken(), exception); + } + } + }); + } + + private LoginAccount requireAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException(401, 401, "未登录或登录态无效"); + } + return account; + } + + private static String requiredString(Object value, String field, int maxLength) { + if (!(value instanceof String text) || text.isBlank() || text.length() > maxLength) { + throw new BusinessException("EasyFlow manifest 字段不正确:" + field); + } + return text; + } + + private static boolean booleanValue(Object value, boolean defaultValue) { + return value instanceof Boolean bool ? bool : defaultValue; + } + + private static int intValue(Object value, int defaultValue) { + return value instanceof Number number ? number.intValue() : defaultValue; + } + + private SkillImportPreview failedPreview(SkillImportFormat format, SkillPackageException exception) { + SkillImportPreview preview = new SkillImportPreview(); + preview.setFormat((format == null ? SkillImportFormat.STANDARD : format).name()); + preview.setSkills(List.of()); + if (exception.getReport() == null) { + preview.setIssues(List.of(SkillValidationIssue.of("ERROR", "PACKAGE_INVALID", + exception.getMessage(), null))); + return preview; + } + preview.setIssues(exception.getReport().getIssues().stream().map(source -> { + SkillValidationIssue issue = SkillValidationIssue.of(source.getSeverity().name(), source.getCode(), + source.getMessage(), source.getPath()); + issue.setLine(source.getLine()); + issue.setColumn(source.getColumn()); + issue.setSuggestion(source.getSuggestion()); + return issue; + }).toList()); + return preview; + } + + /** + * 将增强包的安全解析失败转换为统一的结构化预览问题。 + * + * @param format 导入格式 + * @param code 问题码 + * @param message 安全错误消息 + * @param path 问题路径 + * @return 不含导入令牌的失败预览 + */ + private SkillImportPreview failedPreview(SkillImportFormat format, + String code, + String message, + String path) { + SkillImportPreview preview = new SkillImportPreview(); + preview.setFormat((format == null ? SkillImportFormat.STANDARD : format).name()); + preview.setSkills(List.of()); + SkillValidationIssue issue = SkillValidationIssue.of("ERROR", code, message, path); + issue.setSuggestion(format == SkillImportFormat.EASYFLOW + ? "请修复增强包结构或从可信 EasyFlow 环境重新导出" + : "请修复标准 Skill 包结构后重新导入"); + preview.setIssues(List.of(issue)); + return preview; + } + + private void assertImportable(SkillPackageReadResult decoded) { + boolean hasError = decoded.getValidationReport().getIssues().stream() + .anyMatch(issue -> "ERROR".equals(issue.getSeverity().name())); + if (hasError) { + throw new BusinessException("Skill 包存在校验错误,不能导入"); + } + } + + private void validateConfirmRequest(SkillImportConfirmRequest request) { + if (request.getRenames().size() > MAX_SKILLS) { + throw new BusinessException("Skill 重命名映射数量超过限制"); + } + if (request.getCapabilityMappings().size() > MAX_TOTAL_BINDINGS) { + throw new BusinessException("Skill 能力映射数量超过限制"); + } + for (Map.Entry rename : request.getRenames().entrySet()) { + if (rename.getKey() == null || rename.getKey().length() > 128 + || rename.getValue() == null || rename.getValue().length() > 128) { + throw new BusinessException("Skill 重命名映射字段超过限制"); + } + } + request.getCapabilityMappings().forEach(this::validateCapabilityMapping); + } + + private void validateCapabilityMapping(SkillImportCapabilityOverride mapping) { + if (mapping == null || mapping.bindingKey() == null || mapping.bindingKey().isBlank() + || mapping.bindingKey().length() > 256) { + throw new BusinessException("能力映射 bindingKey 不能为空且不能超过 256 个字符"); + } + if (mapping.disabled() && mapping.targetId() != null) { + throw new BusinessException("禁用能力映射时不能同时指定 targetId:" + mapping.bindingKey()); + } + if (mapping.targetId() != null && mapping.targetId().signum() <= 0) { + throw new BusinessException("能力映射 targetId 必须为正数:" + mapping.bindingKey()); + } + } + + private void validateManifestAgainstPackage(List manifestSkills, + List packageSkills) { + Map packageByRoot = new LinkedHashMap<>(); + for (com.easyagents.skill.model.Skill skill : packageSkills) { + if (packageByRoot.put(skill.getPackageRoot(), skill) != null) { + throw new BusinessException("Skill 包存在重复 packageRoot:" + skill.getPackageRoot()); + } + } + Set manifestRoots = new LinkedHashSet<>(); + for (ManifestSkill manifestSkill : manifestSkills) { + if (!manifestRoots.add(manifestSkill.packageRoot)) { + throw new BusinessException("EasyFlow manifest 存在重复 packageRoot:" + manifestSkill.packageRoot); + } + com.easyagents.skill.model.Skill skill = packageByRoot.get(manifestSkill.packageRoot); + if (skill == null) { + throw new BusinessException("EasyFlow manifest 引用了包内不存在的 Skill:" + manifestSkill.packageRoot); + } + if (!manifestSkill.packageHash.equals(calculateSkillPackageHash(skill))) { + throw new BusinessException("EasyFlow manifest 的 packageHash 与包内容不一致:" + manifestSkill.packageRoot); + } + } + if (!manifestRoots.equals(packageByRoot.keySet())) { + throw new BusinessException("EasyFlow manifest 与包内 Skill 列表不一致"); + } + } + + private String calculateSkillPackageHash(com.easyagents.skill.model.Skill skill) { + StringBuilder canonical = new StringBuilder("SKILL.md\n") + .append(SkillHashes.sha256Hex((skill.getSkillContent() == null ? "" : skill.getSkillContent()) + .getBytes(java.nio.charset.StandardCharsets.UTF_8))).append('\n'); + skill.getResources().stream().sorted(java.util.Comparator.comparing(com.easyagents.skill.model.SkillResource::getPath)) + .forEach(resource -> canonical.append(resource.getPath()).append('\n') + .append(resource.getContentHash()).append('\n')); + return SkillHashes.sha256Hex(canonical.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + private record PreviewDecode(SkillPackageReadResult readResult, Map manifest) { + } + + private static final class DecodedImport implements AutoCloseable { + private final SkillPackageReadResult readResult; + private final Map manifest; + private final EasyFlowBundleReader.PreparedBundle preparedBundle; + + private DecodedImport(SkillPackageReadResult readResult, + Map manifest, + EasyFlowBundleReader.PreparedBundle preparedBundle) { + this.readResult = readResult; + this.manifest = manifest; + this.preparedBundle = preparedBundle; + } + + @Override + public void close() { + if (preparedBundle != null) { + preparedBundle.close(); + } + } + } + + private record ManifestSkill(String packageRoot, String packageHash, List bindings) { + } + + private static final class ManifestBinding { + private final String bindingKey; + private final SkillCapabilityType type; + private final String targetLogicalRef; + private final String targetName; + private final String runtimeName; + private final boolean enabled; + private final String selectionMode; + private final List selectedTools; + private final String executionMode; + private final boolean hitlEnabled; + private final Map hitlConfig; + private final Map options; + private final int sortNo; + + private ManifestBinding(String bindingKey, + SkillCapabilityType type, + String targetLogicalRef, + String targetName, + String runtimeName, + boolean enabled, + String selectionMode, + List selectedTools, + String executionMode, + boolean hitlEnabled, + Map hitlConfig, + Map options, + int sortNo) { + this.bindingKey = bindingKey; + this.type = type; + this.targetLogicalRef = targetLogicalRef; + this.targetName = targetName; + this.runtimeName = runtimeName; + this.enabled = enabled; + this.selectionMode = selectionMode; + this.selectedTools = selectedTools; + this.executionMode = executionMode; + this.hitlEnabled = hitlEnabled; + this.hitlConfig = hitlConfig; + this.options = options; + this.sortNo = sortNo; + } + + private static ManifestBinding from(Map map) { + String bindingKey = requiredString(map.get("bindingKey"), "bindingKey", 256); + SkillCapabilityType type = SkillCapabilityType.from(requiredString(map.get("capabilityType"), "capabilityType", 32)); + String logicalRef = requiredString(map.get("targetLogicalRef"), "targetLogicalRef", 512); + String targetName = map.get("targetName") instanceof String text && text.length() <= 256 ? text : null; + String runtimeName = requiredString(map.get("runtimeName"), "runtimeName", 64); + List tools = parseTools(map.get("selectedToolNames")); + Map hitl = map.get("hitlConfig") instanceof Map value + ? toStringMap(value) : Map.of(); + Map options = map.get("options") instanceof Map value + ? toStringMap(value) : Map.of(); + return new ManifestBinding(bindingKey, type, logicalRef, targetName, runtimeName, + booleanValue(map.get("enabled"), true), + map.get("selectionMode") instanceof String text ? text : null, + tools, + map.get("executionMode") instanceof String text ? text : null, + booleanValue(map.get("hitlEnabled"), false), + SkillSensitiveConfigSanitizer.sanitizeHitl(hitl), + SkillSensitiveConfigSanitizer.sanitizeOptions(options), + intValue(map.get("sortNo"), 0)); + } + + private SkillCapabilityBinding toBinding() { + SkillCapabilityBinding binding = new SkillCapabilityBinding(); + binding.setCapabilityType(type.name()); + binding.setRuntimeName(runtimeName); + binding.setEnabled(enabled); + binding.setSelectionMode(selectionMode); + binding.setSelectedToolNamesJson(selectedTools); + binding.setExecutionMode(executionMode); + binding.setHitlEnabled(hitlEnabled); + binding.setHitlConfigJson(hitlConfig); + binding.setOptionsJson(options); + binding.setSortNo(sortNo); + return binding; + } + + private static List parseTools(Object value) { + if (!(value instanceof List list)) { + return List.of(); + } + if (list.size() > 200) { + throw new BusinessException("EasyFlow manifest MCP 工具数量超过限制"); + } + Set result = new LinkedHashSet<>(); + for (Object item : list) { + if (!(item instanceof String name) || name.isBlank() || name.length() > 128) { + throw new BusinessException("EasyFlow manifest MCP 工具名不正确"); + } + result.add(name); + } + return new ArrayList<>(result); + } + + private static Map toStringMap(Map value) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : value.entrySet()) { + if (entry.getKey() instanceof String key) { + result.put(key, entry.getValue()); + } + } + return result; + } + } + + /** + * 预览阶段只计算 hash 和大小,不持有完整二进制内容。 + */ + private static final class PreviewContentStore implements SkillContentStore { + + @Override + public String put(byte[] bytes) { + return "sha256:" + com.easyagents.skill.util.SkillHashes.sha256Hex(bytes); + } + + @Override + public SkillContentStage stage(InputStream inputStream, long maxBytes) { + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[8192]; + long size = 0; + int length; + while ((length = inputStream.read(buffer)) >= 0) { + size += length; + if (size > maxBytes) { + throw new BusinessException(413, 4131, "Skill 二进制资源超过安全限制"); + } + digest.update(buffer, 0, length); + } + String hash = java.util.HexFormat.of().formatHex(digest.digest()); + return new SkillContentStage("preview:" + hash, "sha256:" + hash, hash, size, false); + } catch (BusinessException exception) { + throw exception; + } catch (Exception exception) { + throw new BusinessException(500, 500, "读取 Skill 二进制资源失败", exception); + } + } + + @Override + public String commit(SkillContentStage stage) { + return stage.getContentRef(); + } + + @Override public InputStream open(String contentRef) { throw new UnsupportedOperationException(); } + @Override public byte[] readAllBytes(String contentRef) { throw new UnsupportedOperationException(); } + @Override public boolean exists(String contentRef) { return true; } } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportStageStore.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportStageStore.java new file mode 100644 index 00000000..f857a8c3 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportStageStore.java @@ -0,0 +1,263 @@ +package tech.easyflow.skill.imports; + +import com.alicp.jetcache.AutoReleaseLock; +import com.alicp.jetcache.Cache; +import com.mybatisflex.core.query.QueryWrapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.SkillImportStage; +import tech.easyflow.skill.mapper.SkillImportStageMapper; + +import java.math.BigInteger; +import java.time.Duration; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * 基于数据库临时索引、JetCache 单次锁与文件存储的 Skill 导入会话仓库。 + */ +@Service +public class SkillImportStageStore { + + private static final Logger LOG = LoggerFactory.getLogger(SkillImportStageStore.class); + private static final Duration SESSION_TTL = Duration.ofMinutes(30); + private static final Duration PROCESSING_TTL = Duration.ofHours(2); + private static final String CACHE_PREFIX = "skill:import:"; + + private final Cache defaultCache; + private final SkillImportStageMapper stageMapper; + private final FileStorageService fileStorageService; + + /** + * 创建 Skill 导入会话仓库。 + * + * @param defaultCache 平台默认缓存 + * @param stageMapper 临时包 Mapper + * @param fileStorageService 文件存储 + */ + public SkillImportStageStore(@Qualifier("defaultCache") Cache defaultCache, + SkillImportStageMapper stageMapper, + @Qualifier("default") FileStorageService fileStorageService) { + this.defaultCache = defaultCache; + this.stageMapper = stageMapper; + this.fileStorageService = fileStorageService; + } + + /** + * 登记临时包并返回单次令牌。 + * + * @param filePath 临时包存储路径 + * @param originalName 原始文件名 + * @param format 包格式 + * @return 临时包索引 + */ + @Transactional(rollbackFor = Exception.class) + public SkillImportStage create(String filePath, String originalName, SkillImportFormat format) { + LoginAccount account = requireAccount(); + Date now = new Date(); + SkillImportStage stage = new SkillImportStage(); + stage.setImportToken(UUID.randomUUID().toString().replace("-", "")); + stage.setTenantId(account.getTenantId()); + stage.setAccountId(account.getId()); + stage.setFilePath(filePath); + stage.setOriginalName(originalName); + stage.setFormat(format.name()); + stage.setStatus("PENDING"); + stage.setCreated(now); + stage.setExpiresAt(new Date(now.getTime() + SESSION_TTL.toMillis())); + if (stageMapper.insert(stage) != 1) { + throw new BusinessException(500, 500, "创建 Skill 导入会话失败,请稍后重试"); + } + // 缓存只保存小型索引;完整包始终留在受控文件存储中。 + defaultCache.put(cacheKey(stage.getImportToken()), stage, SESSION_TTL.toMinutes(), TimeUnit.MINUTES); + return stage; + } + + /** + * 原子消费导入令牌。令牌一旦消费,即使业务导入失败也不能重复执行。 + * + * @param token 导入令牌 + * @return 被消费的临时包索引 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public SkillImportStage consume(String token) { + validateToken(token); + LoginAccount account = requireAccount(); + try (AutoReleaseLock lock = defaultCache.tryLock(lockKey(token), 60, TimeUnit.SECONDS)) { + if (lock == null) { + throw new BusinessException("Skill 导入正在处理中,请勿重复提交"); + } + SkillImportStage stage = findOwnedStage(token, account); + assertOwner(stage, account); + Date now = new Date(); + Date processingExpiresAt = new Date(now.getTime() + PROCESSING_TTL.toMillis()); + if (stageMapper.consume(token, account.getTenantId(), account.getId(), now, processingExpiresAt) != 1) { + throw new BusinessException("Skill 导入令牌已过期或已被使用,请重新预览"); + } + stage.setStatus("PROCESSING"); + stage.setExpiresAt(processingExpiresAt); + defaultCache.remove(cacheKey(token)); + return stage; + } + } + + /** + * 取消尚未消费的导入会话并释放临时包。 + * + * @param token 导入令牌 + */ + public void cancel(String token) { + validateToken(token); + LoginAccount account = requireAccount(); + try (AutoReleaseLock lock = defaultCache.tryLock(lockKey(token), 30, TimeUnit.SECONDS)) { + if (lock == null) { + throw new BusinessException("Skill 导入正在处理中,暂时无法取消"); + } + SkillImportStage stage = findOwnedStage(token, account); + assertOwner(stage, account); + if (!"PENDING".equals(stage.getStatus())) { + throw new BusinessException("Skill 导入正在处理中,不能取消"); + } + Date now = new Date(); + if (stageMapper.beginCancel(token, account.getTenantId(), account.getId(), now) != 1) { + throw new BusinessException("Skill 导入状态已变化,请刷新后重试"); + } + stage.setStatus("PROCESSING"); + stage.setExpiresAt(now); + defaultCache.remove(cacheKey(token)); + deleteFile(stage); + if (stageMapper.finishCancel(token, account.getTenantId(), account.getId()) != 1) { + throw new BusinessException("Skill 导入状态已变化,请刷新后重试"); + } + } + } + + /** + * 完成导入后释放临时包和索引。 + * + * @param stage 临时包索引 + */ + public void complete(SkillImportStage stage) { + if (stage != null) { + cleanup(stage); + } + } + + /** + * 定时清理过期或已消费但未完成清理的临时包。 + */ + @Scheduled(fixedDelayString = "${easyflow.skill.import-cleanup-delay-ms:300000}") + public void cleanupExpired() { + List expired = stageMapper.selectListByQuery(QueryWrapper.create() + .le(SkillImportStage::getExpiresAt, new Date()) + .orderBy("expires_at asc") + .limit(100)); + for (SkillImportStage stage : expired) { + try (AutoReleaseLock lock = defaultCache.tryLock(lockKey(stage.getImportToken()), 30, TimeUnit.SECONDS)) { + if (lock == null) { + continue; + } + SkillImportStage current = stageMapper.selectOneById(stage.getImportToken()); + if (current != null && current.getExpiresAt() != null && !current.getExpiresAt().after(new Date())) { + cleanup(current); + } + } catch (RuntimeException exception) { + LOG.error("清理过期 Skill 导入临时包失败,token={}", stage.getImportToken(), exception); + } + } + } + + private void cleanup(SkillImportStage stage) { + deleteFile(stage); + stageMapper.deleteById(stage.getImportToken()); + defaultCache.remove(cacheKey(stage.getImportToken())); + } + + private void deleteFile(SkillImportStage stage) { + try { + fileStorageService.delete(stage.getFilePath()); + } catch (RuntimeException exception) { + if (isFileAlreadyAbsent(exception)) { + return; + } + LOG.error("删除 Skill 导入临时包失败,token={}, path={}", + stage.getImportToken(), stage.getFilePath(), exception); + throw new BusinessException(500, 500, "清理 Skill 导入临时包失败,请稍后重试", exception); + } + } + + /** + * 判断存储异常是否表示目标文件已经不存在。 + * + * @param exception 存储删除异常 + * @return 文件已不存在时为 true + */ + private boolean isFileAlreadyAbsent(RuntimeException exception) { + Throwable current = exception; + while (current != null) { + if (current instanceof java.io.FileNotFoundException + || current instanceof java.nio.file.NoSuchFileException) { + return true; + } + current = current.getCause(); + } + return false; + } + + private void assertOwner(SkillImportStage stage, LoginAccount account) { + if (stage == null) { + throw new BusinessException(404, 404, "Skill 导入令牌不存在或已过期"); + } + if (!account.getId().equals(stage.getAccountId()) || !account.getTenantId().equals(stage.getTenantId())) { + throw new BusinessException(403, 403, "无权限使用该 Skill 导入令牌"); + } + if (stage.getExpiresAt() == null || !stage.getExpiresAt().after(new Date())) { + throw new BusinessException("Skill 导入令牌已过期,请重新预览"); + } + } + + private LoginAccount requireAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException(401, 401, "未登录或登录态无效"); + } + return account; + } + + private void validateToken(String token) { + if (token == null || !token.matches("^[a-fA-F0-9]{32}$")) { + throw new BusinessException("Skill 导入令牌格式不正确"); + } + } + + private SkillImportStage findOwnedStage(String token, LoginAccount account) { + SkillImportStage stage = stageMapper.selectOneByQuery(QueryWrapper.create() + .eq(SkillImportStage::getImportToken, token) + .eq(SkillImportStage::getTenantId, account.getTenantId()) + .eq(SkillImportStage::getAccountId, account.getId())); + if (stage == null && stageMapper.selectCountByQuery(QueryWrapper.create() + .eq(SkillImportStage::getImportToken, token)) > 0) { + throw new BusinessException(403, 403, "无权限使用该 Skill 导入令牌"); + } + return stage; + } + + private String cacheKey(String token) { + return CACHE_PREFIX + token; + } + + private String lockKey(String token) { + return CACHE_PREFIX + "lock:" + token; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillManifestValidationException.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillManifestValidationException.java new file mode 100644 index 00000000..c27ba464 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillManifestValidationException.java @@ -0,0 +1,45 @@ +package tech.easyflow.skill.imports; + +import tech.easyflow.common.web.exceptions.BusinessException; + +/** + * 携带结构化问题码和字段路径的 EasyFlow Skill manifest 校验异常。 + */ +public class SkillManifestValidationException extends BusinessException { + + private static final long serialVersionUID = 1L; + + private final String validationCode; + private final String path; + + /** + * 创建 manifest 校验异常。 + * + * @param validationCode 稳定问题码 + * @param path manifest 字段路径 + * @param message 不包含原始敏感值的安全消息 + */ + public SkillManifestValidationException(String validationCode, String path, String message) { + super(400, 4001, message); + this.validationCode = validationCode; + this.path = path; + } + + /** + * 获取稳定问题码。 + * + * @return 问题码 + */ + public String getValidationCode() { + return validationCode; + } + + /** + * 获取 manifest 字段路径。 + * + * @return 字段路径 + */ + public String getPath() { + return path; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetContentMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetContentMapper.java deleted file mode 100644 index d4aecf97..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetContentMapper.java +++ /dev/null @@ -1,10 +0,0 @@ -package tech.easyflow.skill.mapper; - -import com.mybatisflex.core.BaseMapper; -import tech.easyflow.skill.entity.SkillAssetContent; - -/** - * Skill asset 内容索引 Mapper。 - */ -public interface SkillAssetContentMapper extends BaseMapper { -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetMapper.java deleted file mode 100644 index 7ea3e61f..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetMapper.java +++ /dev/null @@ -1,10 +0,0 @@ -package tech.easyflow.skill.mapper; - -import com.mybatisflex.core.BaseMapper; -import tech.easyflow.skill.entity.SkillAsset; - -/** - * Skill asset Mapper。 - */ -public interface SkillAssetMapper extends BaseMapper { -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillCapabilityBindingMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillCapabilityBindingMapper.java new file mode 100644 index 00000000..c468e5a4 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillCapabilityBindingMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.skill.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.skill.entity.SkillCapabilityBinding; + +/** + * Skill 能力绑定 Mapper。 + */ +public interface SkillCapabilityBindingMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillCategoryMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillCategoryMapper.java index 46d45651..fb5deba9 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillCategoryMapper.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillCategoryMapper.java @@ -1,10 +1,27 @@ package tech.easyflow.skill.mapper; import com.mybatisflex.core.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import tech.easyflow.skill.entity.SkillCategory; +import java.math.BigInteger; +import java.util.List; + /** * Skill 分类 Mapper。 */ public interface SkillCategoryMapper extends BaseMapper { + + /** + * 按稳定顺序锁定租户内完整分类树,串行化分类结构变更。 + * + * @param tenantId 租户 ID + * @return 已锁定的分类列表 + */ + @Select("SELECT id,tenant_id AS tenantId,parent_id AS parentId,category_name AS categoryName," + + "level_no AS levelNo,ancestors,sort_no AS sortNo,status,created,created_by AS createdBy," + + "modified,modified_by AS modifiedBy FROM tb_skill_category " + + "WHERE tenant_id=#{tenantId} ORDER BY id FOR UPDATE") + List selectTenantTreeForUpdate(@Param("tenantId") BigInteger tenantId); } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillContentMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillContentMapper.java new file mode 100644 index 00000000..e566a9d0 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillContentMapper.java @@ -0,0 +1,238 @@ +package tech.easyflow.skill.mapper; + +import com.mybatisflex.core.BaseMapper; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import tech.easyflow.skill.entity.SkillContent; + +import java.util.Date; +import java.util.List; + +/** + * Skill 二进制内容 Mapper。 + */ +public interface SkillContentMapper extends BaseMapper { + + /** + * 原子增加与内容引用、大小及哈希均匹配的正式内容引用计数。 + * + *

storage_locator 允许为 null 仅用于兼容迁移前正式内容;空定位符与旧 PENDING 路径 + * 均不会被视为活动内容。

+ * + * @param contentRef 内容引用 + * @param size 内容字节数 + * @return 更新行数 + */ + @Update("UPDATE tb_skill_content SET ref_count=ref_count+1,modified=CURRENT_TIMESTAMP " + + "WHERE content_ref=#{contentRef} AND size=#{size} " + + "AND CONCAT('sha256:',content_hash)=#{contentRef} AND ref_count>0 " + + "AND file_path IS NOT NULL AND file_path<>'' AND file_path NOT LIKE '__PENDING__:%' " + + "AND (storage_locator IS NULL OR storage_locator<>'')") + int retainMatching(@Param("contentRef") String contentRef, @Param("size") long size); + + /** + * 以当前读方式锁定并返回指定内容索引。 + * + *

该查询用于引用计数状态转换,避免 MySQL REPEATABLE READ 下普通一致性读反复返回 + * 调用方事务早先建立的旧快照。

+ * + * @param contentRef 内容引用 + * @return 当前内容索引;不存在时为 null + */ + @Select("SELECT content_ref AS contentRef,content_hash AS contentHash,file_path AS filePath," + + "storage_locator AS storageLocator,media_type AS mediaType,size,ref_count AS refCount,created,modified " + + "FROM tb_skill_content WHERE content_ref=#{contentRef} FOR UPDATE") + SkillContent selectForUpdate(@Param("contentRef") String contentRef); + + /** + * 将已经完成物理校验的旧版零引用内容恢复为一份活动引用。 + * + *

仅允许恢复缺少稳定定位符的迁移前内容;读取路径、哈希、大小与零引用状态均须保持 + * 锁定读取时的值,避免复活正在由新流程清理的可恢复对象。

+ * + * @param contentRef 内容引用 + * @param contentHash 内容哈希 + * @param filePath 已校验的旧版读取路径 + * @param size 内容字节数 + * @return 成功恢复为 1,状态已变化为 0 + */ + @Update("UPDATE tb_skill_content SET ref_count=1,modified=CURRENT_TIMESTAMP " + + "WHERE content_ref=#{contentRef} AND content_hash=#{contentHash} " + + "AND file_path=#{filePath} AND size=#{size} AND ref_count=0 " + + "AND storage_locator IS NULL AND file_path IS NOT NULL AND file_path<>'' " + + "AND file_path NOT LIKE '__PENDING__:%'") + int resurrectVerifiedLegacy(@Param("contentRef") String contentRef, + @Param("contentHash") String contentHash, + @Param("filePath") String filePath, + @Param("size") long size); + + /** + * 插入首个引用已经激活的正式内容索引。 + * + *

新流程必须同时提供非空读取路径和稳定存储定位符,并保证内容引用与哈希一致。

+ * + * @param contentRef 内容引用 + * @param contentHash 内容哈希 + * @param filePath 文件读取路径 + * @param storageLocator 稳定存储定位符 + * @param mediaType 媒体类型 + * @param size 内容字节数 + * @return 成功插入为 1,参数不满足活动内容约束为 0 + * @throws org.springframework.dao.DuplicateKeyException 内容引用已经存在 + */ + @Insert("INSERT INTO tb_skill_content(" + + "content_ref,content_hash,file_path,storage_locator,media_type,size,ref_count,created,modified) " + + "SELECT #{contentRef},#{contentHash},#{filePath},#{storageLocator},#{mediaType},#{size}," + + "1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP " + + "WHERE #{filePath} IS NOT NULL AND #{filePath}<>'' " + + "AND #{filePath} NOT LIKE '__PENDING__:%' " + + "AND #{storageLocator} IS NOT NULL AND #{storageLocator}<>'' " + + "AND #{size}>=0 AND CONCAT('sha256:',#{contentHash})=#{contentRef}") + int insertActive(@Param("contentRef") String contentRef, + @Param("contentHash") String contentHash, + @Param("filePath") String filePath, + @Param("storageLocator") String storageLocator, + @Param("mediaType") String mediaType, + @Param("size") long size); + + /** + * 原子增加内容引用计数。 + * + * @param contentRef 内容引用 + * @return 更新行数 + */ + @Update("UPDATE tb_skill_content SET ref_count = ref_count + 1, modified = CURRENT_TIMESTAMP " + + "WHERE content_ref = #{contentRef} AND ref_count > 0 " + + "AND CONCAT('sha256:',content_hash)=#{contentRef} " + + "AND file_path IS NOT NULL AND file_path<>'' AND file_path NOT LIKE '__PENDING__:%' " + + "AND (storage_locator IS NULL OR storage_locator<>'')") + int retain(String contentRef); + + /** + * 原子减少仍有多个持有者的内容引用计数。 + * + * @param contentRef 内容引用 + * @return 更新行数 + */ + @Update("UPDATE tb_skill_content SET ref_count = ref_count - 1, modified = CURRENT_TIMESTAMP " + + "WHERE content_ref = #{contentRef} AND ref_count > 1 " + + "AND file_path IS NOT NULL AND file_path<>'' AND file_path NOT LIKE '__PENDING__:%' " + + "AND (storage_locator IS NULL OR storage_locator<>'')") + int releaseShared(String contentRef); + + /** + * 通过 INSERT IGNORE 原子抢占新内容 hash,避免跨实例 get-then-insert 竞态。 + * + * @param contentRef 内容引用 + * @param contentHash 内容 hash + * @param pendingPath 临时占位路径 + * @param mediaType 媒体类型 + * @param size 字节数 + * @return 抢占成功为 1,已有内容为 0 + */ + @Insert("INSERT IGNORE INTO tb_skill_content(content_ref,content_hash,file_path,media_type,size,ref_count,created,modified) " + + "VALUES(#{contentRef},#{contentHash},#{pendingPath},#{mediaType},#{size},0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)") + int reserve(@Param("contentRef") String contentRef, + @Param("contentHash") String contentHash, + @Param("pendingPath") String pendingPath, + @Param("mediaType") String mediaType, + @Param("size") long size); + + /** + * 完成内容物理路径写入。 + * + * @param contentRef 内容引用 + * @param filePath 物理路径 + * @return 更新行数 + */ + @Update("UPDATE tb_skill_content SET file_path=#{filePath}, ref_count=1, modified=CURRENT_TIMESTAMP " + + "WHERE content_ref=#{contentRef} AND ref_count=0 AND file_path LIKE '__PENDING__:%'") + int finishReservation(@Param("contentRef") String contentRef, @Param("filePath") String filePath); + + /** + * 按读取路径和稳定定位符精确标记最后一份正式内容引用为待清理。 + * + * @param contentRef 内容引用 + * @param filePath 当前文件读取路径 + * @param storageLocator 当前稳定存储定位符 + * @return 更新行数 + */ + @Update("UPDATE tb_skill_content SET ref_count=0,modified=CURRENT_TIMESTAMP " + + "WHERE content_ref=#{contentRef} AND file_path=#{filePath} " + + "AND storage_locator<=>#{storageLocator} AND ref_count=1 " + + "AND file_path IS NOT NULL AND file_path<>'' AND file_path NOT LIKE '__PENDING__:%' " + + "AND (storage_locator IS NULL OR storage_locator<>'')") + int markReleased(@Param("contentRef") String contentRef, + @Param("filePath") String filePath, + @Param("storageLocator") String storageLocator); + + /** + * 统计当前可读取的正式内容。 + * + * @param contentRef 内容引用 + * @return 可见内容数量 + */ + @Select("SELECT COUNT(1) FROM tb_skill_content WHERE content_ref=#{contentRef} " + + "AND ref_count>0 AND file_path IS NOT NULL AND file_path<>'' " + + "AND file_path NOT LIKE '__PENDING__:%' " + + "AND (storage_locator IS NULL OR storage_locator<>'')") + int countVisible(String contentRef); + + /** + * 查询超过保留期限的未完成占位记录。 + * + * @param cutoff 截止时间 + * @param limit 最大返回数量 + * @return 待清理占位记录 + */ + @Select("SELECT content_ref AS contentRef,content_hash AS contentHash,file_path AS filePath," + + "storage_locator AS storageLocator,media_type AS mediaType,size,ref_count AS refCount,created,modified " + + "FROM tb_skill_content WHERE ref_count=0 AND file_path LIKE '__PENDING__:%' " + + "AND modified < #{cutoff} ORDER BY modified ASC LIMIT #{limit}") + List findStalePending(@Param("cutoff") Date cutoff, @Param("limit") int limit); + + /** + * 条件删除仍处于原占位状态的过期记录。 + * + * @param contentRef 内容引用 + * @param pendingPath 原占位路径 + * @param cutoff 截止时间 + * @return 删除行数 + */ + @Delete("DELETE FROM tb_skill_content WHERE content_ref=#{contentRef} AND file_path=#{pendingPath} " + + "AND ref_count=0 AND file_path LIKE '__PENDING__:%' AND modified < #{cutoff}") + int deleteStalePending(@Param("contentRef") String contentRef, + @Param("pendingPath") String pendingPath, + @Param("cutoff") Date cutoff); + + /** + * 查询需要重试物理删除的零引用内容。 + * + * @param cutoff 截止时间 + * @param limit 最大返回数量 + * @return 待清理内容 + */ + @Select("SELECT content_ref AS contentRef,content_hash AS contentHash,file_path AS filePath," + + "storage_locator AS storageLocator,media_type AS mediaType,size,ref_count AS refCount,created,modified " + + "FROM tb_skill_content WHERE ref_count=0 AND file_path NOT LIKE '__PENDING__:%' " + + "AND storage_locator IS NOT NULL AND storage_locator<>'' " + + "AND modified < #{cutoff} ORDER BY modified ASC LIMIT #{limit}") + List findReleasedBefore(@Param("cutoff") Date cutoff, @Param("limit") int limit); + + /** + * 按读取路径和稳定定位符精确删除已完成物理清理的零引用索引。 + * + * @param contentRef 内容引用 + * @param filePath 原文件读取路径 + * @param storageLocator 原稳定存储定位符 + * @return 删除行数 + */ + @Delete("DELETE FROM tb_skill_content WHERE content_ref=#{contentRef} " + + "AND file_path=#{filePath} AND storage_locator<=>#{storageLocator} AND ref_count=0") + int deleteReleased(@Param("contentRef") String contentRef, + @Param("filePath") String filePath, + @Param("storageLocator") String storageLocator); +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillContentWriteIntentMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillContentWriteIntentMapper.java new file mode 100644 index 00000000..74be370b --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillContentWriteIntentMapper.java @@ -0,0 +1,147 @@ +package tech.easyflow.skill.mapper; + +import com.mybatisflex.core.BaseMapper; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import tech.easyflow.skill.entity.SkillContentWriteIntent; + +import java.util.Date; +import java.util.List; + +/** + * Skill 二进制内容写入意图 Mapper。 + */ +public interface SkillContentWriteIntentMapper extends BaseMapper { + + /** + * 原子预留指定内容引用的写入意图。 + * + * @param contentRef 内容引用 + * @param reservationToken 写入预留令牌 + * @param contentHash 内容哈希 + * @param storageLocator 稳定存储定位符 + * @param mediaType 媒体类型 + * @param size 内容字节数 + * @return 成功插入为 1,参数不满足约束为 0 + * @throws org.springframework.dao.DuplicateKeyException 内容引用已被其他写入意图预留 + */ + @Insert("INSERT INTO tb_skill_content_write_intent(" + + "content_ref,reservation_token,content_hash,storage_locator,media_type,size,state,created,modified) " + + "SELECT #{contentRef},#{reservationToken},#{contentHash},#{storageLocator},#{mediaType},#{size}," + + "'PENDING',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP " + + "WHERE #{storageLocator} IS NOT NULL AND #{storageLocator}<>'' " + + "AND CONCAT('sha256:',#{contentHash})=#{contentRef}") + int reserve(@Param("contentRef") String contentRef, + @Param("reservationToken") String reservationToken, + @Param("contentHash") String contentHash, + @Param("storageLocator") String storageLocator, + @Param("mediaType") String mediaType, + @Param("size") long size); + + /** + * 在调用方事务中将预留意图原子声明为正在写入。 + * + *

UPDATE 会持有目标行的排他锁直至调用方事务结束;事务回滚后状态恢复为 PENDING。

+ * + * @param contentRef 内容引用 + * @param reservationToken 写入预留令牌 + * @return 成功声明为 1,令牌或状态不匹配为 0 + */ + @Update("UPDATE tb_skill_content_write_intent SET state='WRITING',modified=CURRENT_TIMESTAMP " + + "WHERE content_ref=#{contentRef} AND reservation_token=#{reservationToken} AND state='PENDING'") + int claimForWrite(@Param("contentRef") String contentRef, + @Param("reservationToken") String reservationToken); + + /** + * 查询超过截止时间且尚未完成的写入意图。 + * + * @param cutoff 截止时间 + * @param limit 最大返回数量 + * @return 按修改时间升序排列的过期意图 + */ + @Select("SELECT content_ref AS contentRef,reservation_token AS reservationToken," + + "content_hash AS contentHash,storage_locator AS storageLocator,media_type AS mediaType," + + "size,state,created,modified " + + "FROM tb_skill_content_write_intent " + + "WHERE state IN ('PENDING','WRITING','CLEANING') AND modified<#{cutoff} " + + "ORDER BY modified ASC LIMIT #{limit}") + List findStale(@Param("cutoff") Date cutoff, @Param("limit") int limit); + + /** + * 将过期意图原子声明为清理中。 + * + *

expectedState 构成状态 CAS。传入 CLEANING 时,同一令牌可幂等重试;存在正式活动内容时 + * 不允许取得清理权。

+ * + * @param contentRef 内容引用 + * @param reservationToken 写入预留令牌 + * @param expectedState 查询时观察到的状态 + * @param cutoff 截止时间 + * @return 成功声明为 1,状态已变化或存在活动内容为 0 + */ + @Update("UPDATE tb_skill_content_write_intent SET state='CLEANING',modified=CURRENT_TIMESTAMP " + + "WHERE content_ref=#{contentRef} AND reservation_token=#{reservationToken} " + + "AND state=#{expectedState} AND state IN ('PENDING','WRITING','CLEANING') " + + "AND modified<#{cutoff} AND NOT EXISTS (" + + "SELECT 1 FROM tb_skill_content active_content " + + "WHERE active_content.content_ref=tb_skill_content_write_intent.content_ref " + + "AND active_content.ref_count>0)") + int claimForCleanup(@Param("contentRef") String contentRef, + @Param("reservationToken") String reservationToken, + @Param("expectedState") String expectedState, + @Param("cutoff") Date cutoff); + + /** + * 删除当前令牌已经取得清理权的写入意图。 + * + * @param contentRef 内容引用 + * @param reservationToken 写入预留令牌 + * @return 删除行数 + */ + @Delete("DELETE FROM tb_skill_content_write_intent WHERE content_ref=#{contentRef} " + + "AND reservation_token=#{reservationToken} AND state='CLEANING'") + int deleteClaimed(@Param("contentRef") String contentRef, + @Param("reservationToken") String reservationToken); + + /** + * 正式内容已激活时删除残留写入意图。 + * + * @param contentRef 内容引用 + * @param reservationToken 写入预留令牌 + * @return 删除行数 + */ + @Delete("DELETE FROM tb_skill_content_write_intent WHERE content_ref=#{contentRef} " + + "AND reservation_token=#{reservationToken} AND EXISTS (" + + "SELECT 1 FROM tb_skill_content active_content " + + "WHERE active_content.content_ref=tb_skill_content_write_intent.content_ref " + + "AND active_content.ref_count>0)") + int deleteIfActiveExists(@Param("contentRef") String contentRef, + @Param("reservationToken") String reservationToken); + + /** + * 删除调用方尚未声明写入、且确认不会产生物理对象的预留意图。 + * + * @param contentRef 内容引用 + * @param reservationToken 写入预留令牌 + * @return 删除行数 + */ + @Delete("DELETE FROM tb_skill_content_write_intent WHERE content_ref=#{contentRef} " + + "AND reservation_token=#{reservationToken} AND state='PENDING'") + int deletePending(@Param("contentRef") String contentRef, + @Param("reservationToken") String reservationToken); + + /** + * 按内容引用读取完整写入意图。 + * + * @param contentRef 内容引用 + * @return 写入意图,不存在时为 null + */ + @Select("SELECT content_ref AS contentRef,reservation_token AS reservationToken," + + "content_hash AS contentHash,storage_locator AS storageLocator,media_type AS mediaType," + + "size,state,created,modified " + + "FROM tb_skill_content_write_intent WHERE content_ref=#{contentRef}") + SkillContentWriteIntent getIntent(@Param("contentRef") String contentRef); +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillImportStageMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillImportStageMapper.java new file mode 100644 index 00000000..c205b8e7 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillImportStageMapper.java @@ -0,0 +1,65 @@ +package tech.easyflow.skill.mapper; + +import com.mybatisflex.core.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Update; +import tech.easyflow.skill.entity.SkillImportStage; + +import java.math.BigInteger; +import java.util.Date; + +/** + * Skill 导入临时包 Mapper。 + */ +public interface SkillImportStageMapper extends BaseMapper { + + /** + * 原子消费仍有效的导入令牌。 + * + * @param token 导入令牌 + * @param tenantId 租户 ID + * @param accountId 用户 ID + * @param now 当前时间 + * @return 更新行数 + */ + @Update("UPDATE tb_skill_import_stage SET status='PROCESSING', expires_at=#{processingExpiresAt} " + + "WHERE import_token=#{token} AND tenant_id=#{tenantId} AND account_id=#{accountId} " + + "AND status='PENDING' AND expires_at>#{now}") + int consume(@Param("token") String token, + @Param("tenantId") BigInteger tenantId, + @Param("accountId") BigInteger accountId, + @Param("now") Date now, + @Param("processingExpiresAt") Date processingExpiresAt); + + /** + * 将待确认令牌原子转为已过期的处理中状态,阻止删除文件期间被并发消费。 + * + * @param token 导入令牌 + * @param tenantId 租户 ID + * @param accountId 用户 ID + * @param now 当前时间,同时作为立即清理截止时间 + * @return 更新行数 + */ + @Update("UPDATE tb_skill_import_stage SET status='PROCESSING', expires_at=#{now} " + + "WHERE import_token=#{token} AND tenant_id=#{tenantId} AND account_id=#{accountId} " + + "AND status='PENDING'") + int beginCancel(@Param("token") String token, + @Param("tenantId") BigInteger tenantId, + @Param("accountId") BigInteger accountId, + @Param("now") Date now); + + /** + * 删除已原子进入取消流程且属于当前用户的令牌。 + * + * @param token 导入令牌 + * @param tenantId 租户 ID + * @param accountId 用户 ID + * @return 删除行数 + */ + @Delete("DELETE FROM tb_skill_import_stage WHERE import_token=#{token} AND tenant_id=#{tenantId} " + + "AND account_id=#{accountId} AND status='PROCESSING'") + int finishCancel(@Param("token") String token, + @Param("tenantId") BigInteger tenantId, + @Param("accountId") BigInteger accountId); +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java index a9f3db64..76181389 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java @@ -1,10 +1,106 @@ package tech.easyflow.skill.mapper; import com.mybatisflex.core.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; import tech.easyflow.skill.entity.Skill; +import java.math.BigInteger; +import java.util.Date; +import java.util.Map; + /** * Skill Mapper。 */ public interface SkillMapper extends BaseMapper { + + /** + * 在租户边界内更新审批中的发布状态,并显式写入或清空审批实例 ID。 + * + * @param id Skill ID + * @param tenantId 租户 ID + * @param publishStatus 发布状态 + * @param approvalInstanceId 当前审批实例 ID,可为空 + * @return 更新行数 + */ + @Update("UPDATE tb_skill SET publish_status=#{publishStatus}, " + + "current_approval_instance_id=#{approvalInstanceId} " + + "WHERE id=#{id} AND tenant_id=#{tenantId}") + int updateApprovalState(@Param("id") BigInteger id, + @Param("tenantId") BigInteger tenantId, + @Param("publishStatus") String publishStatus, + @Param("approvalInstanceId") BigInteger approvalInstanceId); + + /** + * 在租户边界内持久化已发布快照,并原子清空审批实例 ID。 + * + * @param id Skill ID + * @param tenantId 租户 ID + * @param snapshot 已发布快照 + * @param publishedAt 发布时间 + * @param publishedBy 发布人 + * @param snapshotHash 快照哈希 + * @return 更新行数 + */ + @Update("UPDATE tb_skill SET publish_status='PUBLISHED', " + + "published_snapshot_json=#{snapshot,typeHandler=com.mybatisflex.core.handler.FastjsonTypeHandler}, " + + "published_at=#{publishedAt}, published_by=#{publishedBy}, " + + "snapshot_hash=#{snapshotHash}, current_approval_instance_id=NULL " + + "WHERE id=#{id} AND tenant_id=#{tenantId}") + int publish(@Param("id") BigInteger id, + @Param("tenantId") BigInteger tenantId, + @Param("snapshot") Map snapshot, + @Param("publishedAt") Date publishedAt, + @Param("publishedBy") BigInteger publishedBy, + @Param("snapshotHash") String snapshotHash); + + /** + * 在租户边界内将 Skill 标记为下线,并清空审批实例 ID。 + * + * @param id Skill ID + * @param tenantId 租户 ID + * @return 更新行数 + */ + @Update("UPDATE tb_skill SET publish_status='OFFLINE', current_approval_instance_id=NULL " + + "WHERE id=#{id} AND tenant_id=#{tenantId}") + int markOffline(@Param("id") BigInteger id, @Param("tenantId") BigInteger tenantId); + + /** + * 无审计污染地回填迁移后缺失的包摘要,仅处理 package_hash 为空的旧记录。 + * + * @param id Skill ID + * @param tenantId 租户 ID + * @param packageHash 包哈希 + * @param resourceCount 资源总数 + * @param referenceCount 引用数 + * @param scriptCount 脚本数 + * @param assetCount 二进制资源数 + * @return 更新行数 + */ + @Update("UPDATE tb_skill SET package_hash=#{packageHash}, resource_count=#{resourceCount}, " + + "reference_count=#{referenceCount}, script_count=#{scriptCount}, asset_count=#{assetCount}, " + + "modified=modified, modified_by=modified_by " + + "WHERE id=#{id} AND tenant_id=#{tenantId} AND package_hash IS NULL") + int backfillPackageSummary(@Param("id") BigInteger id, + @Param("tenantId") BigInteger tenantId, + @Param("packageHash") String packageHash, + @Param("resourceCount") Integer resourceCount, + @Param("referenceCount") Integer referenceCount, + @Param("scriptCount") Integer scriptCount, + @Param("assetCount") Integer assetCount); + + /** + * 无审计污染地回填迁移后缺失的能力哈希。 + * + * @param id Skill ID + * @param tenantId 租户 ID + * @param capabilityHash 能力哈希 + * @return 更新行数 + */ + @Update("UPDATE tb_skill SET capability_hash=#{capabilityHash}, modified=modified, modified_by=modified_by " + + "WHERE id=#{id} AND tenant_id=#{tenantId} AND capability_hash IS NULL") + int backfillCapabilityHash(@Param("id") BigInteger id, + @Param("tenantId") BigInteger tenantId, + @Param("capabilityHash") String capabilityHash); + } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillReferenceMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillReferenceMapper.java deleted file mode 100644 index c01d3ff8..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillReferenceMapper.java +++ /dev/null @@ -1,10 +0,0 @@ -package tech.easyflow.skill.mapper; - -import com.mybatisflex.core.BaseMapper; -import tech.easyflow.skill.entity.SkillReference; - -/** - * Skill reference Mapper。 - */ -public interface SkillReferenceMapper extends BaseMapper { -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillResourceMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillResourceMapper.java new file mode 100644 index 00000000..99bb9c94 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillResourceMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.skill.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.skill.entity.SkillResource; + +/** + * Skill 通用资源 Mapper。 + */ +public interface SkillResourceMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillScriptMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillScriptMapper.java deleted file mode 100644 index d34e7611..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillScriptMapper.java +++ /dev/null @@ -1,10 +0,0 @@ -package tech.easyflow.skill.mapper; - -import com.mybatisflex.core.BaseMapper; -import tech.easyflow.skill.entity.SkillScript; - -/** - * Skill script Mapper。 - */ -public interface SkillScriptMapper extends BaseMapper { -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java index b0c7ceeb..a4d86f90 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java @@ -2,12 +2,19 @@ package tech.easyflow.skill.publish; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Component; +import com.mybatisflex.core.query.QueryWrapper; import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.publish.AbstractAiResourceLifecycleHandler; +import tech.easyflow.approval.entity.ApprovalInstance; +import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest; +import tech.easyflow.approval.enums.ApprovalActionType; import tech.easyflow.approval.enums.ApprovalResourceType; import tech.easyflow.approval.service.ApprovalInstanceService; import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.mapper.SkillMapper; import tech.easyflow.skill.service.SkillService; import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.enums.ResourceAction; @@ -24,7 +31,9 @@ import java.util.Map; public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHandler { private final SkillService skillService; + private final SkillMapper skillMapper; private final ResourceAccessService resourceAccessService; + private final ApprovalInstanceService approvalInstanceService; /** * 创建 Skill 审批资源处理器。 @@ -32,15 +41,19 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand * @param approvalInstanceService 审批实例服务 * @param objectMapper JSON 映射器 * @param skillService Skill 服务 + * @param skillMapper Skill Mapper * @param resourceAccessService 资源访问服务 */ public SkillApprovalSubjectHandler(ApprovalInstanceService approvalInstanceService, ObjectMapper objectMapper, SkillService skillService, + SkillMapper skillMapper, ResourceAccessService resourceAccessService) { super(approvalInstanceService, objectMapper); this.skillService = skillService; + this.skillMapper = skillMapper; this.resourceAccessService = resourceAccessService; + this.approvalInstanceService = approvalInstanceService; } /** @@ -56,18 +69,31 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand */ @Override public void assertPublishedAccess(Object identifier, String denyMessage) { - Skill skill = skillService.getById(String.valueOf(identifier)); + Skill skill = findCurrentTenantSkill(new BigInteger(String.valueOf(identifier)), false); if (skill == null || !PublishStatus.from(skill.getPublishStatus()).isExternallyVisible() || skill.getPublishedSnapshotJson() == null || skill.getPublishedSnapshotJson().isEmpty()) { throw new BusinessException(denyMessage); } } + /** + * {@inheritDoc} + */ + @Override + public ApprovalSubmitRequest buildSubmitRequest(BigInteger resourceId, String actionType, BigInteger operatorId) { + ApprovalSubmitRequest request = super.buildSubmitRequest(resourceId, actionType, operatorId); + if (ApprovalActionType.PUBLISH.getCode().equals(request.getActionType())) { + skillService.retainSnapshotContents(readResourceSnapshot(request.getSnapshotJson())); + } + return request; + } + @Override protected Skill requireResource(BigInteger resourceId) { - Skill skill = skillService.getById(resourceId); + // 生命周期提交与审批决策均在事务中执行,行锁串行化同一 Skill 的状态迁移。 + Skill skill = findCurrentTenantSkill(resourceId, true); if (skill == null) { - throw new BusinessException("Skill 不存在"); + throw new BusinessException(404, 404, "Skill 不存在"); } return skill; } @@ -109,44 +135,108 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand return skillService.buildPublishSnapshot(resource); } + /** + * 删除审批只记录最小治理信息,避免失效能力阻断删除或把提示词、资源内容及能力配置写入审批快照。 + * + * @param resource Skill + * @return 删除审批治理快照 + */ + @Override + protected Map buildDeleteResourceSnapshot(Skill resource) { + return skillService.buildGovernanceSnapshot(resource); + } + + /** + * 优先使用稳定快照 hash 判断内容是否变化,兼容旧快照中的时间字段。 + * + * @param currentSnapshot 当前草稿快照 + * @param publishedSnapshot 已发布快照 + * @return 内容一致时为 true + */ + @Override + protected boolean isSameSnapshot(Map currentSnapshot, Map publishedSnapshot) { + Object currentHash = currentSnapshot == null ? null : currentSnapshot.get("snapshotHash"); + Object publishedHash = publishedSnapshot == null ? null : publishedSnapshot.get("snapshotHash"); + if (currentHash != null && publishedHash != null) { + return currentHash.equals(publishedHash); + } + return super.isSameSnapshot(currentSnapshot, publishedSnapshot); + } + @Override protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) { - Skill skill = new Skill(); - skill.setId(resourceId); - skill.setPublishStatus(publishStatus.getCode()); - skill.setCurrentApprovalInstanceId(currentApprovalInstanceId); - skillService.updateById(skill); + Skill existing = requireResource(resourceId); + if (skillMapper.updateApprovalState(resourceId, existing.getTenantId(), publishStatus.getCode(), + currentApprovalInstanceId) != 1) { + throw new BusinessException(500, 500, "更新 Skill 审批状态失败,请稍后重试"); + } } @Override protected void publishResource(BigInteger resourceId, Map resourceSnapshot, BigInteger operatorId) { - Skill skill = new Skill(); - skill.setId(resourceId); - skill.setPublishStatus(PublishStatus.PUBLISHED.getCode()); - skill.setPublishedSnapshotJson(resourceSnapshot); - skill.setPublishedAt(new Date()); - skill.setPublishedBy(operatorId); - skill.setCurrentApprovalInstanceId(null); - skillService.updateById(skill); + Skill existing = requireResource(resourceId); + if (skillMapper.publish(resourceId, existing.getTenantId(), resourceSnapshot, new Date(), operatorId, + stringValue(resourceSnapshot.get("snapshotHash"))) != 1) { + throw new BusinessException(500, 500, "发布 Skill 失败,请稍后重试"); + } + skillService.releaseSnapshotContents(existing.getPublishedSnapshotJson()); } @Override protected void markResourceOffline(BigInteger resourceId) { - Skill skill = new Skill(); - skill.setId(resourceId); - skill.setPublishStatus(PublishStatus.OFFLINE.getCode()); - skill.setCurrentApprovalInstanceId(null); - skillService.updateById(skill); + Skill existing = requireResource(resourceId); + if (skillMapper.markOffline(resourceId, existing.getTenantId()) != 1) { + throw new BusinessException(500, 500, "下线 Skill 失败,请稍后重试"); + } } @Override protected void removeResource(BigInteger resourceId) { - skillService.removeAggregate(resourceId); + skillService.removeLifecycleAggregate(resourceId); } @Override protected String resourceLabel() { return "Skill"; } -} + /** + * 审批驳回或撤回时释放发布候选快照持有的二进制内容。 + * + * @param resourceId Skill ID + * @param previousStatus 审批前发布状态 + */ + @Override + public void restoreState(BigInteger resourceId, PublishStatus previousStatus) { + Skill skill = requireResource(resourceId); + BigInteger instanceId = skill.getCurrentApprovalInstanceId(); + if (instanceId != null) { + ApprovalInstance instance = approvalInstanceService.getById(instanceId); + if (instance == null) { + throw new BusinessException(500, 500, "Skill 审批状态异常,无法安全恢复内容引用"); + } + if (ApprovalActionType.PUBLISH.getCode().equals(instance.getActionType())) { + skillService.releaseSnapshotContents(readResourceSnapshot(instance.getSnapshotJson())); + } + } + super.restoreState(resourceId, previousStatus); + } + + private String stringValue(Object value) { + return value == null ? null : String.valueOf(value); + } + + private Skill findCurrentTenantSkill(BigInteger id, boolean forUpdate) { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException(401, 401, "未登录或登录态无效"); + } + QueryWrapper query = QueryWrapper.create() + .eq(Skill::getId, id) + .eq(Skill::getTenantId, account.getTenantId()); + if (forUpdate) { + query.forUpdate(); + } + return skillMapper.selectOneByQuery(query); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillPublishAppService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillPublishAppService.java index fb8816e5..f800c25d 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillPublishAppService.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillPublishAppService.java @@ -61,12 +61,15 @@ public class SkillPublishAppService { if (id == null) { throw new BusinessException("Skill 审批时资源ID不能为空"); } + tech.easyflow.common.entity.LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException(401, 401, "未登录或登录态无效"); + } return aiResourceLifecycleService.submitAction( ApprovalResourceType.SKILL.getCode(), id, actionType.getCode(), - SaTokenUtil.getLoginAccount().getId() + account.getId() ); } } - diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/repository/DBSkillRepository.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/repository/DBSkillRepository.java index 46d13743..214d705b 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/repository/DBSkillRepository.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/repository/DBSkillRepository.java @@ -4,13 +4,21 @@ import com.easyagents.skill.model.SkillDescriptor; import com.easyagents.skill.repository.SkillRepository; import com.mybatisflex.core.query.QueryWrapper; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillResource; import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.security.SkillVisibilityQueryHelper; +import tech.easyflow.skill.store.DBSkillContentStore; import tech.easyflow.skill.support.SkillModelConverter; import java.math.BigInteger; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -20,24 +28,45 @@ import java.util.Optional; public class DBSkillRepository implements SkillRepository { private final SkillService skillService; + private final DBSkillContentStore contentStore; + private final SkillVisibilityQueryHelper visibilityQueryHelper; /** * 创建数据库 Skill 仓储。 * * @param skillService Skill 服务 + * @param contentStore 二进制内容仓库 + * @param visibilityQueryHelper 可见性查询助手 */ - public DBSkillRepository(SkillService skillService) { + public DBSkillRepository(SkillService skillService, + DBSkillContentStore contentStore, + SkillVisibilityQueryHelper visibilityQueryHelper) { this.skillService = skillService; + this.contentStore = contentStore; + this.visibilityQueryHelper = visibilityQueryHelper; } /** - * {@inheritDoc} + * 保存 Skill,并转移新增二进制内容引用的所有权。 + * + *

调用方在新增 Skill 或为已有 Skill 增加二进制资源前,必须通过内容仓库的 + * {@code put}/{@code commit} 为每个新增资源取得一份引用。保存成功后,这些新增引用转由 + * Skill 聚合持有;保存失败时不发生所有权转移,本方法产生的引用计数变更随事务回滚, + * 调用方仍负责释放在外部事务中预先取得的新引用。更新时,旧、新资源多重集的交集复用旧 + * 聚合已有所有权,本适配器会在替换前 retain 相同次数,以抵消资源替换对旧聚合的 release; + * 仅出现在新聚合中的引用直接接管调用方已取得的引用。

+ * + * @param skill 待保存的 Skill 聚合 */ @Override + @Transactional(rollbackFor = Exception.class) public void save(com.easyagents.skill.model.Skill skill) { + requireAccount(); Skill entity = SkillModelConverter.fromAgentSkill(skill); BigInteger parsedId = tryParseId(skill.getId()); - if (parsedId != null && skillService.getById(parsedId) != null) { + if (parsedId != null && findReadable(parsedId) != null) { + Skill existing = skillService.getDetail(parsedId); + retainReusedContentRefs(existing.getResources(), entity.getResources()); entity.setId(parsedId); skillService.updateDraft(entity); return; @@ -45,12 +74,62 @@ public class DBSkillRepository implements SkillRepository { skillService.saveDraft(entity); } + /** + * 为旧、新资源多重集的交集增加临时持有,抵消替换流程对旧聚合引用的统一释放。 + * + * @param existingResources 旧聚合资源 + * @param incomingResources 新聚合资源 + */ + private void retainReusedContentRefs(List existingResources, + List incomingResources) { + Map remainingOldRefs = contentRefCounts(existingResources); + if (incomingResources == null || incomingResources.isEmpty() || remainingOldRefs.isEmpty()) { + return; + } + for (SkillResource resource : incomingResources) { + String contentRef = resource == null ? null : resource.getContentRef(); + Integer remaining = remainingOldRefs.get(contentRef); + if (remaining == null || remaining <= 0) { + continue; + } + contentStore.retain(contentRef); + if (remaining == 1) { + remainingOldRefs.remove(contentRef); + } else { + remainingOldRefs.put(contentRef, remaining - 1); + } + } + } + + /** + * 统计二进制内容引用多重集。 + * + * @param resources Skill 资源 + * @return contentRef 到出现次数的映射 + */ + private Map contentRefCounts(List resources) { + Map counts = new HashMap<>(); + if (resources == null) { + return counts; + } + for (SkillResource resource : resources) { + String contentRef = resource == null ? null : resource.getContentRef(); + if (contentRef != null && !contentRef.isBlank()) { + counts.merge(contentRef, 1, Integer::sum); + } + } + return counts; + } + /** * {@inheritDoc} */ @Override public Optional get(String skillId) { BigInteger id = parseId(skillId); + if (findReadable(id) == null) { + return Optional.empty(); + } Skill skill = skillService.getDetail(id); return Optional.of(SkillModelConverter.toAgentSkill(skill)); } @@ -61,7 +140,9 @@ public class DBSkillRepository implements SkillRepository { @Override public Optional getDescriptor(String skillId) { BigInteger id = parseId(skillId); - Skill skill = skillService.getById(id); + QueryWrapper query = descriptorQuery().eq(Skill::getId, id); + visibilityQueryHelper.applyReadableAccess(query); + Skill skill = skillService.getOne(query); if (skill == null) { return Optional.empty(); } @@ -74,7 +155,10 @@ public class DBSkillRepository implements SkillRepository { */ @Override public List listDescriptors() { - return skillService.list().stream() + requireAccount(); + QueryWrapper query = descriptorQuery(); + visibilityQueryHelper.applyReadableAccess(query); + return skillService.list(query).stream() .map(skill -> new SkillDescriptor(String.valueOf(skill.getId()), skill.getName(), skill.getDescription(), new com.easyagents.skill.model.SkillMetadata(skill.getMetadataJson()))) .toList(); @@ -94,14 +178,41 @@ public class DBSkillRepository implements SkillRepository { @Override public boolean exists(String skillId) { BigInteger id = parseId(skillId); - return skillService.count(QueryWrapper.create().eq(Skill::getId, id)) > 0; + QueryWrapper query = QueryWrapper.create().eq(Skill::getId, id); + visibilityQueryHelper.applyReadableAccess(query); + return skillService.count(query) > 0; + } + + private Skill findReadable(BigInteger id) { + QueryWrapper query = QueryWrapper.create().eq(Skill::getId, id); + visibilityQueryHelper.applyReadableAccess(query); + return skillService.getOne(query); + } + + private QueryWrapper descriptorQuery() { + return QueryWrapper.create().select( + "id", "tenant_id", "dept_id", "category_id", "name", "description", "metadata_json", + "visibility_scope", "created_by"); + } + + private LoginAccount requireAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException(401, 401, "未登录或登录态无效"); + } + return account; } private BigInteger parseId(String skillId) { + requireAccount(); if (skillId == null || skillId.isBlank()) { throw new BusinessException("Skill ID 不能为空"); } - return new BigInteger(skillId); + try { + return new BigInteger(skillId); + } catch (NumberFormatException exception) { + throw new BusinessException("Skill ID 格式不正确"); + } } private BigInteger tryParseId(String skillId) { diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillCredentialValueGuard.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillCredentialValueGuard.java new file mode 100644 index 00000000..d7b53f29 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillCredentialValueGuard.java @@ -0,0 +1,447 @@ +package tech.easyflow.skill.security; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.text.Normalizer; +import java.util.Base64; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * EasyFlow 平台附加配置中的高置信凭据值检测器。 + * + *

该检测器只用于能力配置和增强包元数据,不应用于标准 Skill 文档或资源正文。

+ */ +public final class SkillCredentialValueGuard { + + private static final int MAX_PERCENT_DECODE_PASSES = 5; + private static final Pattern URI_USER_INFO = Pattern.compile( + "(?i)\\b[a-z][a-z0-9+.-]*://([^\\s/?#@]+)@"); + private static final Pattern ASSIGNMENT = Pattern.compile( + "(?i)(?:^|[\\s?&#{\\[,;])['\"]?([A-Z0-9_.\\[\\]-]{1,160})['\"]?\\s*[:=]\\s*"); + private static final Pattern AUTHORIZATION_SCHEME = Pattern.compile("(?i)^(?:bearer|basic)\\s+"); + private static final Pattern STANDALONE_AUTHORIZATION_SCHEME = Pattern.compile( + "(?i)(?|\\[(?:REDACTED|MASKED|HIDDEN|TOKEN|API[-_]?KEY|SECRET|PASSWORD)]|" + + "\\*{3,})$"); + private static final Pattern SAFE_SENTINEL = Pattern.compile( + "(?i)^(?:none|null|unset|disabled|not[-_ ]?set|n/?a)$"); + private static final Set AUTH_PROSE_WORDS = Set.of( + "authentication", "authorization", "credentials", "credential", "information", + "header", "scheme", "token", "example", "placeholder"); + + /** + * 禁止实例化纯静态安全工具。 + */ + private SkillCredentialValueGuard() { + } + + /** + * 判断字符串是否包含可识别的实际凭据材料。 + * + * @param value 待检查的平台附加配置值 + * @return 检测到实际凭据时为 true + */ + public static boolean containsCredential(String value) { + if (value == null || value.isBlank()) { + return false; + } + String candidate = normalize(value); + for (int pass = 0; pass <= MAX_PERCENT_DECODE_PASSES; pass++) { + if (containsCredentialNormalized(candidate)) { + return true; + } + String decoded = percentDecode(candidate); + if (decoded.equals(candidate)) { + return false; + } + if (pass == MAX_PERCENT_DECODE_PASSES) { + // 超过有界规范化深度仍持续变化时按高风险输入处理,避免任意层编码绕过。 + return true; + } + candidate = normalize(decoded); + } + return false; + } + + /** + * 对单层规范化文本执行结构化凭据检测。 + * + * @param value 已规范化文本 + * @return 检测到凭据时为 true + */ + private static boolean containsCredentialNormalized(String value) { + if (PRIVATE_KEY_MARKER.matcher(value).find() + || COMMON_TOKEN_PREFIX.matcher(value).find() + || JWT.matcher(value).find()) { + return true; + } + Matcher userInfoMatcher = URI_USER_INFO.matcher(value); + while (userInfoMatcher.find()) { + String userInfo = stripWrappingQuotes(userInfoMatcher.group(1)); + int separator = userInfo.lastIndexOf(':'); + if (separator >= 0 && !isSafeCredentialScalar(userInfo.substring(separator + 1))) { + return true; + } + } + Matcher assignmentMatcher = ASSIGNMENT.matcher(value); + while (assignmentMatcher.find()) { + if (!isSensitiveKey(assignmentMatcher.group(1))) { + continue; + } + String assignedValue = extractAssignedValue(value, assignmentMatcher.end()); + if (assignedValue.isEmpty()) { + continue; + } + if (!isSafeCredentialScalar(assignedValue)) { + return true; + } + } + Matcher schemeMatcher = STANDALONE_AUTHORIZATION_SCHEME.matcher(value); + while (schemeMatcher.find()) { + String payload = extractAuthorizationPayload(value, schemeMatcher.end()); + if (looksLikeAuthorizationPayload(schemeMatcher.group(1), payload)) { + return true; + } + } + return false; + } + + /** + * 从赋值分隔符后提取一个受限标量,支持常见引号和占位符形式。 + * + * @param source 完整文本 + * @param start 值起始位置 + * @return 去除外层引号的标量 + */ + private static String extractAssignedValue(String source, int start) { + int cursor = start; + while (cursor < source.length() && Character.isWhitespace(source.charAt(cursor))) { + cursor++; + } + if (cursor >= source.length()) { + return ""; + } + char first = source.charAt(cursor); + if (first == '\'' || first == '"') { + int quoteEnd = findClosingQuote(source, cursor, first); + if (quoteEnd < 0) { + return stripWrappingQuotes(source.substring(cursor)); + } + int end = extendScalarTail(source, quoteEnd + 1); + return stripWrappingQuotes(source.substring(cursor, end)); + } + Matcher schemeMatcher = AUTHORIZATION_SCHEME.matcher(source.substring(cursor)); + if (schemeMatcher.find()) { + String payload = extractAssignedValue(source, cursor + schemeMatcher.end()); + return source.substring(cursor, cursor + schemeMatcher.end()) + payload; + } + int placeholderEnd = findPairedPlaceholderEnd(source, cursor); + if (placeholderEnd >= 0) { + return source.substring(cursor, extendScalarTail(source, placeholderEnd)).trim(); + } + int end = cursor; + while (end < source.length() && !isScalarDelimiter(source.charAt(end))) { + end++; + } + return stripWrappingQuotes(source.substring(cursor, end)); + } + + /** + * 提取认证方案后的值;普通说明句保留为整体,供结构化判定区分 Token 与文案。 + * + * @param source 完整文本 + * @param start 认证值起始位置 + * @return 认证载荷 + */ + private static String extractAuthorizationPayload(String source, int start) { + int cursor = start; + while (cursor < source.length() && Character.isWhitespace(source.charAt(cursor))) { + cursor++; + } + if (cursor >= source.length()) { + return ""; + } + int placeholderEnd = findPairedPlaceholderEnd(source, cursor); + if (placeholderEnd >= 0) { + return source.substring(cursor, extendScalarTail(source, placeholderEnd)).trim(); + } + int end = cursor; + while (end < source.length() + && !Character.isWhitespace(source.charAt(end)) + && !isScalarDelimiter(source.charAt(end))) { + end++; + } + return stripWrappingQuotes(source.substring(cursor, end)); + } + + /** + * 查找当前位置开始的成对占位符结束位置。 + * + * @param source 完整文本 + * @param start 起始位置 + * @return 占位符结束位置(不含);当前位置不是完整占位符时返回 -1 + */ + private static int findPairedPlaceholderEnd(String source, int start) { + String closing; + if (source.startsWith("${", start)) { + closing = "}"; + } else if (source.startsWith("{{", start)) { + closing = "}}"; + } else if (source.startsWith("<", start)) { + closing = ">"; + } else if (source.startsWith("[", start)) { + closing = "]"; + } else { + return -1; + } + int end = source.indexOf(closing, start + 1); + return end < 0 ? -1 : end + closing.length(); + } + + /** + * 查找未转义的结束引号。 + * + * @param source 完整文本 + * @param start 起始引号位置 + * @param quote 引号字符 + * @return 结束引号位置;未闭合时返回 -1 + */ + private static int findClosingQuote(String source, int start, char quote) { + boolean escaped = false; + for (int index = start + 1; index < source.length(); index++) { + char current = source.charAt(index); + if (current == quote && !escaped) { + return index; + } + escaped = current == '\\' && !escaped; + if (current != '\\') { + escaped = false; + } + } + return -1; + } + + /** + * 将紧邻占位符或引号的尾随字符纳入同一标量,避免占位符前缀绕过。 + * + * @param source 完整文本 + * @param start 尾随内容起始位置 + * @return 标量结束位置 + */ + private static int extendScalarTail(String source, int start) { + int end = start; + while (end < source.length() && !isScalarDelimiter(source.charAt(end))) { + end++; + } + return end; + } + + /** + * 判断字符是否结束当前凭据标量。 + * + * @param value 待判断字符 + * @return 属于结构分隔符时为 true + */ + private static boolean isScalarDelimiter(char value) { + return value == ',' || value == ';' || value == '}' || value == ']' + || value == '&' || value == '#'; + } + + /** + * 判断提取值是否为明确的非凭据占位符。 + * + * @param value 提取值 + * @return 属于允许占位符时为 true + */ + private static boolean isPlaceholder(String value) { + return PLACEHOLDER.matcher(stripWrappingQuotes(value).trim()).matches(); + } + + /** + * 判断赋值或认证方案后的标量是否明确不含真实凭据。 + * + * @param value 原始标量 + * @return 完整占位符或明确空值哨兵时为 true + */ + private static boolean isSafeCredentialScalar(String value) { + String scalar = stripWrappingQuotes(value).trim(); + if ("bearer".equalsIgnoreCase(scalar) || "basic".equalsIgnoreCase(scalar)) { + return true; + } + Matcher schemeMatcher = AUTHORIZATION_SCHEME.matcher(scalar); + if (schemeMatcher.find()) { + String scheme = scalar.substring(0, schemeMatcher.end()).trim(); + String payload = scalar.substring(schemeMatcher.end()).trim(); + return !looksLikeAuthorizationPayload(scheme, payload); + } + return isPlaceholder(scalar) || SAFE_SENTINEL.matcher(scalar).matches(); + } + + /** + * 判断认证方案后的载荷是否具有实际凭据结构。 + * + * @param scheme 认证方案 + * @param value 认证载荷 + * @return 具有实际凭据结构时为 true + */ + private static boolean looksLikeAuthorizationPayload(String scheme, String value) { + String payload = stripWrappingQuotes(value).trim(); + if (payload.isEmpty() || isPlaceholder(payload) || SAFE_SENTINEL.matcher(payload).matches()) { + return false; + } + int placeholderEnd = findPairedPlaceholderEnd(payload, 0); + if (placeholderEnd > 0 && !payload.substring(placeholderEnd).trim().isEmpty()) { + return true; + } + if (payload.chars().anyMatch(Character::isWhitespace)) { + return false; + } + if (AUTH_PROSE_WORDS.contains(payload.toLowerCase(Locale.ROOT))) { + return false; + } + if ("basic".equalsIgnoreCase(scheme)) { + return isBasicCredential(payload); + } + return payload.length() >= 12 && payload.matches("[A-Za-z0-9._~+/=-]+"); + } + + /** + * 判断 Basic 载荷是否能解码为 user:secret 结构。 + * + * @param payload Base64 载荷 + * @return 符合 Basic 凭据结构时为 true + */ + private static boolean isBasicCredential(String payload) { + if (payload.length() < 8 || !payload.matches("[A-Za-z0-9+/]+={0,2}")) { + return false; + } + try { + byte[] decoded = Base64.getDecoder().decode(payload); + String text = new String(decoded, StandardCharsets.UTF_8); + int separator = text.indexOf(':'); + return separator > 0 && separator < text.length() - 1 + && text.chars().noneMatch(Character::isISOControl); + } catch (IllegalArgumentException exception) { + return false; + } + } + + /** + * 判断赋值左侧字段是否属于凭据语义。 + * + * @param key 原始字段名 + * @return 敏感字段时为 true + */ + private static boolean isSensitiveKey(String key) { + String normalized = normalize(key).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + return normalized.equals("key") + || normalized.endsWith("authorization") + || normalized.endsWith("apikey") + || normalized.endsWith("accesskey") + || normalized.endsWith("secretaccesskey") + || normalized.endsWith("accesstoken") + || normalized.endsWith("refreshtoken") + || normalized.endsWith("idtoken") + || normalized.endsWith("authtoken") + || normalized.endsWith("token") + || normalized.endsWith("clientsecret") + || normalized.endsWith("password") + || normalized.endsWith("passwd") + || normalized.endsWith("secret") + || normalized.endsWith("cookie") + || normalized.endsWith("session") + || normalized.endsWith("sessionid") + || normalized.endsWith("credential") + || normalized.endsWith("signature"); + } + + /** + * 去除成对单引号或双引号。 + * + * @param value 原始标量 + * @return 去除外层引号的标量 + */ + private static String stripWrappingQuotes(String value) { + if (value == null) { + return ""; + } + String trimmed = value.trim(); + if (trimmed.length() >= 2) { + char first = trimmed.charAt(0); + char last = trimmed.charAt(trimmed.length() - 1); + if ((first == '\'' && last == '\'') || (first == '"' && last == '"')) { + return trimmed.substring(1, trimmed.length() - 1).trim(); + } + } + return trimmed; + } + + /** + * 执行 Unicode 兼容规范化。 + * + * @param value 原始文本 + * @return NFKC 文本 + */ + private static String normalize(String value) { + String normalized = Normalizer.normalize(value, Normalizer.Form.NFKC); + StringBuilder visible = new StringBuilder(normalized.length()); + normalized.codePoints() + .filter(codePoint -> Character.getType(codePoint) != Character.FORMAT) + .filter(codePoint -> !Character.isISOControl(codePoint)) + .forEach(visible::appendCodePoint); + return visible.toString(); + } + + /** + * 尝试解码一层百分号转义,非法转义保持原文。 + * + * @param value 原始文本 + * @return 解码结果或原文 + */ + private static String percentDecode(String value) { + try { + StringBuilder escapedInvalidPercent = new StringBuilder(value.length()); + for (int index = 0; index < value.length(); index++) { + char current = value.charAt(index); + if (current == '%' && (index + 2 >= value.length() + || !isHexDigit(value.charAt(index + 1)) + || !isHexDigit(value.charAt(index + 2)))) { + escapedInvalidPercent.append("%25"); + } else { + escapedInvalidPercent.append(current); + } + } + return URLDecoder.decode(escapedInvalidPercent.toString(), StandardCharsets.UTF_8); + } catch (IllegalArgumentException exception) { + return value; + } + } + + /** + * 判断字符是否为十六进制数字。 + * + * @param value 待判断字符 + * @return 十六进制数字时为 true + */ + private static boolean isHexDigit(char value) { + return value >= '0' && value <= '9' + || value >= 'a' && value <= 'f' + || value >= 'A' && value <= 'F'; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillPortableTargetSanitizer.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillPortableTargetSanitizer.java new file mode 100644 index 00000000..bd6de5b2 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillPortableTargetSanitizer.java @@ -0,0 +1,169 @@ +package tech.easyflow.skill.security; + +import tech.easyflow.skill.enums.SkillCapabilityType; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Skill 跨环境目标引用和展示元数据的安全校验器。 + */ +public final class SkillPortableTargetSanitizer { + + private static final Pattern LOGICAL_SEGMENT = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_.-]{0,199}"); + private static final Pattern URI_USER_INFO = Pattern.compile( + "(?i)\\b[a-z][a-z0-9+.-]*://[^\\s/?#]*@"); + private static final Pattern CREDENTIAL_QUERY = Pattern.compile( + "(?i)[?&;](?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|api[_-]?key|key|" + + "secret|client[_-]?secret|password|passwd|authorization|auth|" + + "(?:x-amz-)?signature|credential)\\s*="); + private static final Pattern ABSOLUTE_PATH = Pattern.compile( + "(?:^|[^A-Za-z0-9_.:/-])(?:/(?!/)[^\\s]+|\\\\\\\\[^\\s]+|" + + "[A-Za-z]:[\\\\/][^\\s]+|~[\\\\/][^\\s]+)"); + private static final Pattern FILE_URI = Pattern.compile("(?i)\\bfile:(?://)?[/\\\\]"); + + /** + * 禁止实例化纯静态安全工具。 + */ + private SkillPortableTargetSanitizer() { + } + + /** + * 判断逻辑引用是否符合当前能力类型的严格可移植语法。 + * + * @param type 能力类型 + * @param logicalRef 待校验逻辑引用 + * @return 符合安全语法时为 true + */ + public static boolean isSafeLogicalRef(SkillCapabilityType type, String logicalRef) { + if (type == null || logicalRef == null || logicalRef.isBlank() + || SkillCredentialValueGuard.containsCredential(logicalRef)) { + return false; + } + if (unresolvedRef(type).equals(logicalRef)) { + return true; + } + return switch (type) { + case WORKFLOW -> hasSingleSafeSegment(logicalRef, "workflow:"); + case MCP -> hasSingleSafeSegment(logicalRef, "mcp:"); + case PLUGIN_ITEM -> hasTwoSafeSegments(logicalRef, "plugin-item:"); + }; + } + + /** + * 返回安全逻辑引用;历史脏值统一降级为不可解析引用。 + * + * @param type 能力类型 + * @param logicalRef 原始逻辑引用 + * @return 安全逻辑引用 + */ + public static String safeLogicalRefOrUnresolved(SkillCapabilityType type, String logicalRef) { + return isSafeLogicalRef(type, logicalRef) ? logicalRef : unresolvedRef(type); + } + + /** + * 构造能力类型对应的不可解析逻辑引用。 + * + * @param type 能力类型 + * @return 不可解析逻辑引用 + */ + public static String unresolvedRef(SkillCapabilityType type) { + return "unresolved:" + type.name().toLowerCase(Locale.ROOT).replace('_', '-'); + } + + /** + * 判断展示元数据是否不含凭据式 URI、认证查询参数和绝对路径。 + * + * @param value 待校验元数据 + * @return 可安全写入增强包时为 true + */ + public static boolean isSafePortableMetadata(String value) { + if (value == null) { + return true; + } + String normalized = value; + for (int pass = 0; pass < 3; pass++) { + if (!isSafePortableMetadataValue(normalized)) { + return false; + } + String decoded = percentDecode(normalized); + if (decoded.equals(normalized)) { + return true; + } + normalized = decoded; + } + return isSafePortableMetadataValue(normalized); + } + + /** + * 返回安全展示元数据;空白或不安全内容返回 null。 + * + * @param value 原始元数据 + * @return 安全元数据或 null + */ + public static String safePortableMetadataOrNull(String value) { + return value == null || value.isBlank() || !isSafePortableMetadata(value) ? null : value; + } + + /** + * 校验单段类型逻辑引用。 + * + * @param logicalRef 逻辑引用 + * @param prefix 类型前缀 + * @return 单段符合安全语法时为 true + */ + private static boolean hasSingleSafeSegment(String logicalRef, String prefix) { + return logicalRef.startsWith(prefix) + && LOGICAL_SEGMENT.matcher(logicalRef.substring(prefix.length())).matches(); + } + + /** + * 校验插件与工具组成的双段逻辑引用。 + * + * @param logicalRef 逻辑引用 + * @param prefix 类型前缀 + * @return 两段均符合安全语法时为 true + */ + private static boolean hasTwoSafeSegments(String logicalRef, String prefix) { + if (!logicalRef.startsWith(prefix)) { + return false; + } + String value = logicalRef.substring(prefix.length()); + int separator = value.indexOf('/'); + return separator > 0 && separator == value.lastIndexOf('/') + && LOGICAL_SEGMENT.matcher(value.substring(0, separator)).matches() + && LOGICAL_SEGMENT.matcher(value.substring(separator + 1)).matches(); + } + + /** + * 对单次规范化后的元数据执行危险内容检测。 + * + * @param value 元数据 + * @return 未发现危险内容时为 true + */ + private static boolean isSafePortableMetadataValue(String value) { + return value.chars().noneMatch(Character::isISOControl) + && !SkillCredentialValueGuard.containsCredential(value) + && !URI_USER_INFO.matcher(value).find() + && !CREDENTIAL_QUERY.matcher(value).find() + && !ABSOLUTE_PATH.matcher(value).find() + && !FILE_URI.matcher(value).find(); + } + + /** + * 尝试解码一层百分号转义,非法转义保持原文。 + * + * @param value 原始值 + * @return 解码结果或原文 + */ + private static String percentDecode(String value) { + try { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } catch (IllegalArgumentException exception) { + // 非法百分号转义不能安全规范化,按原文继续检查并由调用方的字段语法约束处理。 + return value; + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillSensitiveConfigSanitizer.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillSensitiveConfigSanitizer.java new file mode 100644 index 00000000..3fd054cc --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillSensitiveConfigSanitizer.java @@ -0,0 +1,68 @@ +package tech.easyflow.skill.security; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * Skill 平台配置的敏感字段白名单清洗器。 + */ +public final class SkillSensitiveConfigSanitizer { + + private static final Set HITL_ALLOWED_KEYS = Set.of( + "prompt", "title", "description", "confirmLabel", "cancelLabel" + ); + private static final Set OPTIONS_ALLOWED_KEYS = Set.of( + "timeoutMs", "retryCount", "async", "readOnly" + ); + + private SkillSensitiveConfigSanitizer() { + } + + /** + * 仅保留已定义的非敏感 HITL 展示配置。 + * + * @param source 原始 HITL 配置 + * @return 白名单配置 + */ + public static Map sanitizeHitl(Map source) { + return sanitizeAllowed(source, HITL_ALLOWED_KEYS); + } + + /** + * 仅保留已定义的非敏感执行选项。 + * + * @param source 原始执行选项 + * @return 白名单配置 + */ + public static Map sanitizeOptions(Map source) { + return sanitizeAllowed(source, OPTIONS_ALLOWED_KEYS); + } + + private static Map sanitizeAllowed(Map source, Set allowedKeys) { + if (source == null || source.isEmpty()) { + return new LinkedHashMap<>(); + } + Map sanitized = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + if (entry.getKey() == null || !allowedKeys.contains(entry.getKey())) { + continue; + } + Object value = sanitizeScalar(entry.getValue()); + if (value != null) { + sanitized.put(entry.getKey(), value); + } + } + return sanitized; + } + + private static Object sanitizeScalar(Object value) { + if (value == null) { + return null; + } + if (value instanceof String || value instanceof Number || value instanceof Boolean) { + return value; + } + return null; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillVisibilityQueryHelper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillVisibilityQueryHelper.java new file mode 100644 index 00000000..3b85a62c --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillVisibilityQueryHelper.java @@ -0,0 +1,83 @@ +package tech.easyflow.skill.security; + +import com.mybatisflex.core.query.QueryCondition; +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Component; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.VisibilityScope; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.SysDeptService; + +import java.math.BigInteger; +import java.util.Collections; +import java.util.Set; + +import static tech.easyflow.skill.entity.table.SkillTableDef.SKILL; + +/** + * 将 Skill 的分类、归属人与可见范围权限转换为数据库可执行的读取条件。 + */ +@Component +public class SkillVisibilityQueryHelper { + + private final CategoryPermissionService categoryPermissionService; + private final SysDeptService sysDeptService; + + /** + * 创建 Skill 可见性查询助手。 + * + * @param categoryPermissionService 分类权限服务 + * @param sysDeptService 部门服务 + */ + public SkillVisibilityQueryHelper(CategoryPermissionService categoryPermissionService, + SysDeptService sysDeptService) { + this.categoryPermissionService = categoryPermissionService; + this.sysDeptService = sysDeptService; + } + + /** + * 将当前登录用户的 Skill 读取权限追加到查询条件。 + * + * @param queryWrapper 查询条件 + */ + public void applyReadableAccess(QueryWrapper queryWrapper) { + LoginAccount account = SaTokenUtil.getLoginAccount(); + BigInteger accountId = account == null ? null : account.getId(); + BigInteger tenantId = account == null ? null : account.getTenantId(); + if (accountId == null || tenantId == null) { + queryWrapper.and(SKILL.ID.eq(BigInteger.valueOf(-1))); + return; + } + // 超级管理员也只能读取当前租户;项目未启用 MyBatis-Flex 全局租户过滤器。 + queryWrapper.and(SKILL.TENANT_ID.eq(tenantId)); + RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode()); + if (access.isSuperAdmin()) { + return; + } + QueryCondition owner = SKILL.CREATED_BY.eq(accountId); + if (access.isRestricted() && access.getCategoryIds().isEmpty()) { + queryWrapper.and(owner); + return; + } + Set readableDeptIds = account.getDeptId() == null + ? Collections.emptySet() : sysDeptService.getSelfAndAncestorDeptIds(account.getDeptId()); + QueryCondition visible = SKILL.VISIBILITY_SCOPE.eq(VisibilityScope.PUBLIC.name()); + if (!readableDeptIds.isEmpty()) { + visible = visible.or(SKILL.VISIBILITY_SCOPE.eq(VisibilityScope.DEPT.name()) + .and(SKILL.DEPT_ID.in(readableDeptIds))); + } + if (access.isRestricted()) { + visible = SKILL.CATEGORY_ID.in(access.getCategoryIds()).and(visible); + } + QueryCondition readable = owner.or(visible); + if (access.isAllAccess()) { + // L13 明确约定 ALL 分类范围可以读取未分类 Skill,包括其他创建者的私有草稿。 + readable = readable.or(SKILL.CATEGORY_ID.isNull()); + } + queryWrapper.and(readable); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetContentService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetContentService.java deleted file mode 100644 index 7d4fdce2..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetContentService.java +++ /dev/null @@ -1,10 +0,0 @@ -package tech.easyflow.skill.service; - -import com.mybatisflex.core.service.IService; -import tech.easyflow.skill.entity.SkillAssetContent; - -/** - * Skill asset 内容索引服务。 - */ -public interface SkillAssetContentService extends IService { -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetService.java deleted file mode 100644 index b6fc8012..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetService.java +++ /dev/null @@ -1,10 +0,0 @@ -package tech.easyflow.skill.service; - -import com.mybatisflex.core.service.IService; -import tech.easyflow.skill.entity.SkillAsset; - -/** - * Skill asset 服务。 - */ -public interface SkillAssetService extends IService { -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillCategoryService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillCategoryService.java index 5f69762a..bcc46467 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillCategoryService.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillCategoryService.java @@ -16,4 +16,15 @@ public interface SkillCategoryService extends IService { * @param categoryId 分类 ID,可为空 */ void validateUsableCategory(BigInteger categoryId); + + /** + * 锁定当前租户完整分类树,并校验目标分类可供 Skill 使用。 + * + *

Skill 新建、改分类和移出分类必须在同一事务内先调用此方法,再写入 + * {@code tb_skill.category_id},从而与分类删除形成统一的行锁顺序。

+ * + * @param categoryId 分类 ID;为空时仍锁定分类树,以保护从原分类移出的并发操作 + * @throws tech.easyflow.common.web.exceptions.BusinessException 分类不存在、不可用或登录态无效 + */ + void lockAndValidateUsableCategory(BigInteger categoryId); } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillReferenceService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillReferenceService.java deleted file mode 100644 index a11c8f22..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillReferenceService.java +++ /dev/null @@ -1,10 +0,0 @@ -package tech.easyflow.skill.service; - -import com.mybatisflex.core.service.IService; -import tech.easyflow.skill.entity.SkillReference; - -/** - * Skill reference 服务。 - */ -public interface SkillReferenceService extends IService { -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillResourceService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillResourceService.java new file mode 100644 index 00000000..bce9a0b9 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillResourceService.java @@ -0,0 +1,22 @@ +package tech.easyflow.skill.service; + +import com.mybatisflex.core.service.IService; +import tech.easyflow.skill.entity.SkillResource; + +import java.math.BigInteger; +import java.util.List; + +/** + * Skill 通用资源服务。 + */ +public interface SkillResourceService extends IService { + + /** + * 查询资源描述信息,不加载文本正文或二进制内容引用。 + * + * @param skillId Skill ID + * @param tenantId 租户 ID + * @return 按显示顺序排列的资源描述列表 + */ + List listDescriptors(BigInteger skillId, BigInteger tenantId); +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillScriptService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillScriptService.java deleted file mode 100644 index c0b974fb..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillScriptService.java +++ /dev/null @@ -1,10 +0,0 @@ -package tech.easyflow.skill.service; - -import com.mybatisflex.core.service.IService; -import tech.easyflow.skill.entity.SkillScript; - -/** - * Skill script 服务。 - */ -public interface SkillScriptService extends IService { -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java index d0292caa..21f9c47d 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java @@ -2,6 +2,7 @@ package tech.easyflow.skill.service; import com.mybatisflex.core.service.IService; import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.validation.SkillValidationResult; import java.math.BigInteger; import java.util.Map; @@ -19,6 +20,22 @@ public interface SkillService extends IService { */ Skill getDetail(BigInteger id); + /** + * 获取管理端详情,资源仅返回描述字段,不预加载全部文件正文。 + * + * @param id Skill ID + * @return Skill 管理详情 + */ + Skill getManagementDetail(BigInteger id); + + /** + * 获取仅包含标准 Skill 包内容的授权详情,不解析平台能力目标。 + * + * @param id Skill ID + * @return Skill 包内容详情 + */ + Skill getPackageDetail(BigInteger id); + /** * 保存 Skill 草稿。 * @@ -35,6 +52,50 @@ public interface SkillService extends IService { */ Skill updateDraft(Skill skill); + /** + * 覆盖导入内容,并在锁定目标行后再次确认目标仍为草稿。 + * + * @param skill 导入后的 Skill 草稿 + * @return 更新后的 Skill + */ + Skill overwriteImportedDraft(Skill skill); + + /** + * 按客户端读取到的 SKILL.md 内容 hash 原子更新草稿,防止并发覆盖。 + * + * @param skill Skill 草稿 + * @param expectedSkillContentHash 客户端读取到的 SKILL.md SHA-256 + * @return 更新后的 Skill + */ + Skill updateDraftIfContentMatches(Skill skill, String expectedSkillContentHash); + + /** + * 复制一个可读 Skill 为当前用户拥有的新草稿。 + * + * @param sourceId 源 Skill ID + * @param name 新 Skill 标准名称 + * @param displayName 新 Skill 展示名称 + * @param categoryId 目标分类 ID,可为空 + * @return 新建的 Skill 草稿 + */ + Skill copyDraft(BigInteger sourceId, String name, String displayName, BigInteger categoryId); + + /** + * 对当前 Skill 包和能力绑定执行全量校验。 + * + * @param id Skill ID + * @param publishValidation 是否执行发布级能力解析 + * @return 结构化校验结果 + */ + SkillValidationResult validateSkill(BigInteger id, boolean publishValidation); + + /** + * 在文件级修改后重新计算资源计数和包 hash。 + * + * @param id Skill ID + */ + void refreshPackageState(BigInteger id); + /** * 构建发布快照。 * @@ -43,6 +104,28 @@ public interface SkillService extends IService { */ Map buildPublishSnapshot(Skill skill); + /** + * 构建删除审批使用的最小治理快照。 + * + * @param skill Skill + * @return 不含提示词、资源内容和能力配置的治理快照 + */ + Map buildGovernanceSnapshot(Skill skill); + + /** + * 为发布候选或已发布快照中的每个二进制资源增加一份持有引用。 + * + * @param snapshot Skill 发布快照 + */ + void retainSnapshotContents(Map snapshot); + + /** + * 释放发布候选或已发布快照中的每个二进制资源持有引用。 + * + * @param snapshot Skill 发布快照 + */ + void releaseSnapshotContents(Map snapshot); + /** * 从发布快照还原 Skill。 * @@ -52,9 +135,23 @@ public interface SkillService extends IService { Skill fromSnapshot(Map snapshot); /** - * 删除 Skill 聚合。 + * 删除草稿或已下线的 Skill 聚合。 + * + *

已发布记录必须先下线,任何审批中记录都不能通过此普通仓储入口删除。

* * @param id Skill ID + * @throws tech.easyflow.common.web.exceptions.BusinessException Skill 不存在、无权限或当前状态不可删除 */ void removeAggregate(BigInteger id); + + /** + * 由统一发布生命周期删除 Skill 聚合。 + * + *

该入口允许删除审批已经进入 {@code DELETE_PENDING} 的记录,也兼容未配置审批流时 + * 直接删除草稿或已下线记录。普通仓储删除必须使用 {@link #removeAggregate(BigInteger)}。

+ * + * @param id Skill ID + * @throws tech.easyflow.common.web.exceptions.BusinessException Skill 不存在、无权限或当前状态不可删除 + */ + void removeLifecycleAggregate(BigInteger id); } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillApprovalStateServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillApprovalStateServiceImpl.java index b6eb0145..cfad4559 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillApprovalStateServiceImpl.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillApprovalStateServiceImpl.java @@ -86,11 +86,17 @@ public class SkillApprovalStateServiceImpl implements SkillApprovalStateService .map(Skill::getCurrentApprovalInstanceId) .filter(Objects::nonNull) .collect(Collectors.toCollection(LinkedHashSet::new)); - if (instanceIds.isEmpty()) { + Set tenantIds = skills.stream() + .map(Skill::getTenantId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (instanceIds.isEmpty() || tenantIds.isEmpty()) { return Collections.emptyMap(); } List instances = approvalInstanceMapper.selectListByQuery( - QueryWrapper.create().in(ApprovalInstance::getId, instanceIds) + QueryWrapper.create() + .in(ApprovalInstance::getId, instanceIds) + .in(ApprovalInstance::getTenantId, tenantIds) ); return instances.stream().collect(Collectors.toMap(ApprovalInstance::getId, Function.identity())); } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetContentServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetContentServiceImpl.java deleted file mode 100644 index d30c22ca..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetContentServiceImpl.java +++ /dev/null @@ -1,14 +0,0 @@ -package tech.easyflow.skill.service.impl; - -import com.mybatisflex.spring.service.impl.ServiceImpl; -import org.springframework.stereotype.Service; -import tech.easyflow.skill.entity.SkillAssetContent; -import tech.easyflow.skill.mapper.SkillAssetContentMapper; -import tech.easyflow.skill.service.SkillAssetContentService; - -/** - * Skill asset 内容索引服务实现。 - */ -@Service -public class SkillAssetContentServiceImpl extends ServiceImpl implements SkillAssetContentService { -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetServiceImpl.java deleted file mode 100644 index cb60c126..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetServiceImpl.java +++ /dev/null @@ -1,14 +0,0 @@ -package tech.easyflow.skill.service.impl; - -import com.mybatisflex.spring.service.impl.ServiceImpl; -import org.springframework.stereotype.Service; -import tech.easyflow.skill.entity.SkillAsset; -import tech.easyflow.skill.mapper.SkillAssetMapper; -import tech.easyflow.skill.service.SkillAssetService; - -/** - * Skill asset 服务实现。 - */ -@Service -public class SkillAssetServiceImpl extends ServiceImpl implements SkillAssetService { -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillCategoryServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillCategoryServiceImpl.java index 6975ea3d..905d7f1a 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillCategoryServiceImpl.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillCategoryServiceImpl.java @@ -2,16 +2,24 @@ package tech.easyflow.skill.service.impl; import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.spring.service.impl.ServiceImpl; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.skill.entity.SkillCategory; import tech.easyflow.skill.mapper.SkillCategoryMapper; +import tech.easyflow.skill.mapper.SkillMapper; import tech.easyflow.skill.service.SkillCategoryService; +import javax.annotation.Resource; +import java.io.Serializable; import java.math.BigInteger; +import java.util.Collection; import java.util.Date; +import java.util.List; /** * Skill 分类服务实现。 @@ -21,6 +29,9 @@ public class SkillCategoryServiceImpl extends ServiceImpl lockedCategories = lockTenantTree(account.getTenantId()); + if (categoryId == null) { + return; } + SkillCategory category = lockedCategories == null + ? requireTenantCategory(categoryId) + : requireLockedCategory(lockedCategories, categoryId, account.getTenantId()); + validateUsableCategory(category); + } + + /** + * 校验已按租户边界读取的分类状态。 + * + * @param category Skill 分类 + * @throws BusinessException 分类层级超限或已停用 + */ + private void validateUsableCategory(SkillCategory category) { if (category.getLevelNo() != null && category.getLevelNo() > MAX_LEVEL) { throw new BusinessException("Skill 分类最多支持三级"); } @@ -45,18 +79,68 @@ public class SkillCategoryServiceImpl extends ServiceImpl lockedCategories = lockTenantTree(account.getTenantId()); + applyCategoryFields(entity, lockedCategories); + assertUniqueCategoryName(entity); + try { + return super.save(entity); + } catch (DuplicateKeyException exception) { + throw new BusinessException(409, 4094, "同级分类下已存在同名 Skill 分类"); + } } /** * {@inheritDoc} */ @Override + @Transactional(rollbackFor = Exception.class) public boolean updateById(SkillCategory entity) { - applyCategoryFields(entity); - return super.updateById(entity); + LoginAccount account = requireAccount(); + List lockedCategories = lockTenantTree(account.getTenantId()); + SkillCategory before = entity == null || entity.getId() == null ? null + : (lockedCategories == null + ? copyCategoryState(entity) + : requireLockedCategory(lockedCategories, entity.getId(), account.getTenantId())); + if (before == null) { + throw new BusinessException("Skill 分类不存在"); + } + if (getMapper() != null) { + entity.setTenantId(before.getTenantId()); + entity.setCreated(before.getCreated()); + entity.setCreatedBy(before.getCreatedBy()); + } + applyCategoryFields(entity, lockedCategories); + assertUniqueCategoryName(entity); + List descendants = entity.getId() == null ? List.of() + : lockedCategories == null + ? listDescendants(entity.getId()) + : listDescendants(lockedCategories, entity.getId()); + int previousLevel = before == null || before.getLevelNo() == null ? entity.getLevelNo() : before.getLevelNo(); + int levelDelta = entity.getLevelNo() - previousLevel; + int deepestLevel = descendants.stream() + .map(SkillCategory::getLevelNo) + .filter(java.util.Objects::nonNull) + .mapToInt(Integer::intValue) + .max().orElse(previousLevel) + levelDelta; + if (deepestLevel > MAX_LEVEL) { + throw new BusinessException("移动后子分类将超过三级限制"); + } + boolean updated; + try { + updated = getMapper() == null || getMapper().updateByQuery(entity, QueryWrapper.create() + .eq(SkillCategory::getId, entity.getId()) + .eq(SkillCategory::getTenantId, account.getTenantId())) == 1; + } catch (DuplicateKeyException exception) { + throw new BusinessException(409, 4094, "同级分类下已存在同名 Skill 分类"); + } + if (!updated) { + return false; + } + updateDescendantPaths(entity, before, descendants, levelDelta); + return true; } /** @@ -69,36 +153,113 @@ public class SkillCategoryServiceImpl extends ServiceImpl 0; + return count(QueryWrapper.create() + .eq(SkillCategory::getTenantId, requireAccount().getTenantId()) + .eq(SkillCategory::getParentId, categoryId)) > 0; } - private void applyCategoryFields(SkillCategory category) { + /** + * 删除分类前在服务层校验子分类和 Skill 占用,避免绕过控制器。 + * + * @param id 分类 ID + * @return 删除结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removeById(Serializable id) { + BigInteger categoryId; + try { + categoryId = id instanceof BigInteger value ? value : new BigInteger(String.valueOf(id)); + } catch (RuntimeException exception) { + throw new BusinessException("Skill 分类 ID 格式不正确"); + } + LoginAccount account = requireAccount(); + List lockedCategories = lockTenantTree(account.getTenantId()); + if (lockedCategories == null) { + requireTenantCategory(categoryId); + } else { + requireLockedCategory(lockedCategories, categoryId, account.getTenantId()); + } + boolean occupiedByChildren = lockedCategories == null + ? hasChildren(categoryId) + : lockedCategories.stream().anyMatch(category -> categoryId.equals(category.getParentId())); + if (occupiedByChildren) { + throw new BusinessException("请先删除子分类"); + } + if (skillMapper != null && skillMapper.selectCountByQuery( + QueryWrapper.create().eq("tenant_id", account.getTenantId()) + .eq("category_id", categoryId)) > 0) { + throw new BusinessException("请先迁移或删除该分类下的 Skill"); + } + return getMapper() == null || getMapper().deleteByQuery(QueryWrapper.create() + .eq(SkillCategory::getId, categoryId) + .eq(SkillCategory::getTenantId, account.getTenantId())) == 1; + } + + /** + * 批量删除时逐项执行分类占用约束。 + * + * @param ids 分类 ID 集合 + * @return 全部删除成功时为 true + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removeByIds(Collection ids) { + if (ids == null || ids.isEmpty()) { + return false; + } + for (Serializable id : ids) { + removeById(id); + } + return true; + } + + /** + * 应用分类字段,并优先从当前事务已锁定的分类树解析父级。 + * + * @param category 待保存分类 + * @param lockedCategories 已锁定分类树;无数据库 Mapper 的单元场景可为 null + */ + private void applyCategoryFields(SkillCategory category, List lockedCategories) { if (category == null) { throw new BusinessException("Skill 分类不能为空"); } if (category.getCategoryName() == null || category.getCategoryName().isBlank()) { throw new BusinessException("Skill 分类名称不能为空"); } + category.setCategoryName(category.getCategoryName().trim()); + if (category.getCategoryName().length() > 128) { + throw new BusinessException("Skill 分类名称不能超过 128 个字符"); + } SkillCategory parent = null; if (category.getParentId() != null) { - parent = getById(category.getParentId()); - if (parent == null) { - throw new BusinessException("父级 Skill 分类不存在"); - } + LoginAccount account = requireAccount(); + parent = lockedCategories == null + ? requireTenantCategory(category.getParentId()) + : requireLockedCategory(lockedCategories, category.getParentId(), account.getTenantId()); if (category.getId() != null && category.getId().equals(category.getParentId())) { throw new BusinessException("父级分类不能是自身"); } + if (category.getId() != null && containsAncestor(parent.getAncestors(), category.getId())) { + throw new BusinessException("父级分类不能是当前分类的后代"); + } } int level = parent == null ? 1 : (parent.getLevelNo() == null ? 1 : parent.getLevelNo()) + 1; if (level > MAX_LEVEL) { throw new BusinessException("Skill 分类最多支持三级"); } - LoginAccount account = SaTokenUtil.getLoginAccount(); + LoginAccount account = requireAccount(); Date now = new Date(); category.setLevelNo(level); category.setAncestors(parent == null ? "" : appendAncestor(parent)); category.setStatus(category.getStatus() == null ? 1 : category.getStatus()); + if (category.getStatus() != 0 && category.getStatus() != 1) { + throw new BusinessException("Skill 分类状态只支持 0 或 1"); + } category.setSortNo(category.getSortNo() == null ? 0 : category.getSortNo()); + if (category.getSortNo() < -999_999 || category.getSortNo() > 999_999) { + throw new BusinessException("Skill 分类排序值超出允许范围"); + } if (category.getId() == null) { category.setTenantId(account.getTenantId()); category.setCreated(now); @@ -117,4 +278,147 @@ public class SkillCategoryServiceImpl extends ServiceImpl listDescendants(BigInteger categoryId) { + return list(QueryWrapper.create() + .eq(SkillCategory::getTenantId, requireAccount().getTenantId()) + .and("FIND_IN_SET(?, ancestors)", categoryId)); + } + + /** + * 从已锁定分类树中提取当前分类的全部后代。 + * + * @param lockedCategories 已锁定分类树 + * @param categoryId 当前分类 ID + * @return 后代分类列表 + */ + private List listDescendants(List lockedCategories, BigInteger categoryId) { + return lockedCategories.stream() + .filter(category -> containsAncestor(category.getAncestors(), categoryId)) + .toList(); + } + + private void updateDescendantPaths(SkillCategory category, + SkillCategory before, + List descendants, + int levelDelta) { + if (descendants.isEmpty()) { + return; + } + String oldPrefix = before == null || before.getAncestors() == null || before.getAncestors().isBlank() + ? String.valueOf(category.getId()) : before.getAncestors() + "," + category.getId(); + String newPrefix = category.getAncestors() == null || category.getAncestors().isBlank() + ? String.valueOf(category.getId()) : category.getAncestors() + "," + category.getId(); + for (SkillCategory descendant : descendants) { + String ancestors = descendant.getAncestors(); + if (ancestors == null || (!ancestors.equals(oldPrefix) && !ancestors.startsWith(oldPrefix + ","))) { + throw new BusinessException(500, 500, "Skill 分类层级数据异常,请联系管理员处理"); + } + descendant.setAncestors(newPrefix + ancestors.substring(oldPrefix.length())); + descendant.setLevelNo(descendant.getLevelNo() + levelDelta); + descendant.setModified(category.getModified()); + descendant.setModifiedBy(category.getModifiedBy()); + if (getMapper().updateByQuery(descendant, QueryWrapper.create() + .eq(SkillCategory::getId, descendant.getId()) + .eq(SkillCategory::getTenantId, category.getTenantId())) != 1) { + throw new BusinessException(500, 500, "更新 Skill 子分类层级失败,请稍后重试"); + } + } + } + + private boolean containsAncestor(String ancestors, BigInteger categoryId) { + if (ancestors == null || ancestors.isBlank()) { + return false; + } + String expected = String.valueOf(categoryId); + for (String ancestor : ancestors.split(",")) { + if (expected.equals(ancestor.trim())) { + return true; + } + } + return false; + } + + private SkillCategory copyCategoryState(SkillCategory source) { + SkillCategory copy = new SkillCategory(); + copy.setId(source.getId()); + copy.setParentId(source.getParentId()); + copy.setLevelNo(source.getLevelNo()); + copy.setAncestors(source.getAncestors()); + copy.setTenantId(source.getTenantId()); + return copy; + } + + private SkillCategory requireTenantCategory(BigInteger categoryId) { + if (categoryId == null) { + throw new BusinessException("Skill 分类 ID 不能为空"); + } + LoginAccount account = requireAccount(); + SkillCategory category = getMapper() == null ? getById(categoryId) : getOne(QueryWrapper.create() + .eq(SkillCategory::getId, categoryId) + .eq(SkillCategory::getTenantId, account.getTenantId())); + if (category == null || !account.getTenantId().equals(category.getTenantId())) { + throw new BusinessException(404, 404, "Skill 分类不存在"); + } + return category; + } + + /** + * 锁定租户完整分类树。所有结构写操作都使用同一锁顺序,防止并发移动形成循环或孤儿节点。 + * + * @param tenantId 租户 ID + * @return 已锁定分类树;无 Mapper 的隔离单元场景返回 null + */ + private List lockTenantTree(BigInteger tenantId) { + if (getMapper() == null) { + return null; + } + List categories = getMapper().selectTenantTreeForUpdate(tenantId); + return categories == null ? List.of() : categories; + } + + /** + * 从已锁定分类树中读取同租户分类。 + * + * @param lockedCategories 已锁定分类树 + * @param categoryId 分类 ID + * @param tenantId 租户 ID + * @return 分类实体 + */ + private SkillCategory requireLockedCategory(List lockedCategories, + BigInteger categoryId, + BigInteger tenantId) { + if (categoryId == null) { + throw new BusinessException("Skill 分类 ID 不能为空"); + } + return lockedCategories.stream() + .filter(category -> categoryId.equals(category.getId()) && tenantId.equals(category.getTenantId())) + .findFirst() + .orElseThrow(() -> new BusinessException(404, 404, "Skill 分类不存在")); + } + + private void assertUniqueCategoryName(SkillCategory category) { + QueryWrapper query = QueryWrapper.create() + .eq(SkillCategory::getTenantId, category.getTenantId()) + .eq(SkillCategory::getCategoryName, category.getCategoryName()); + if (category.getParentId() == null) { + query.isNull(SkillCategory::getParentId); + } else { + query.eq(SkillCategory::getParentId, category.getParentId()); + } + if (category.getId() != null) { + query.ne(SkillCategory::getId, category.getId()); + } + if (getMapper() != null && count(query) > 0) { + throw new BusinessException(409, 4094, "同级分类下已存在同名 Skill 分类"); + } + } + + private LoginAccount requireAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException(401, 401, "未登录或登录态无效"); + } + return account; + } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillReferenceServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillReferenceServiceImpl.java deleted file mode 100644 index 32c60b69..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillReferenceServiceImpl.java +++ /dev/null @@ -1,14 +0,0 @@ -package tech.easyflow.skill.service.impl; - -import com.mybatisflex.spring.service.impl.ServiceImpl; -import org.springframework.stereotype.Service; -import tech.easyflow.skill.entity.SkillReference; -import tech.easyflow.skill.mapper.SkillReferenceMapper; -import tech.easyflow.skill.service.SkillReferenceService; - -/** - * Skill reference 服务实现。 - */ -@Service -public class SkillReferenceServiceImpl extends ServiceImpl implements SkillReferenceService { -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillResourceServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillResourceServiceImpl.java new file mode 100644 index 00000000..df760fb6 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillResourceServiceImpl.java @@ -0,0 +1,43 @@ +package tech.easyflow.skill.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.spring.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; +import tech.easyflow.skill.entity.SkillResource; +import tech.easyflow.skill.mapper.SkillResourceMapper; +import tech.easyflow.skill.service.SkillResourceService; + +import java.math.BigInteger; +import java.util.List; + +/** + * Skill 通用资源服务实现。 + */ +@Service +public class SkillResourceServiceImpl extends ServiceImpl + implements SkillResourceService { + + /** + * {@inheritDoc} + */ + @Override + public List listDescriptors(BigInteger skillId, BigInteger tenantId) { + return list(descriptorQuery(skillId, tenantId)); + } + + /** + * 构建轻量资源描述查询,避免文件树和管理详情加载全部正文。 + * + * @param skillId Skill ID + * @param tenantId 租户 ID + * @return 资源描述查询 + */ + QueryWrapper descriptorQuery(BigInteger skillId, BigInteger tenantId) { + return QueryWrapper.create() + .select("id", "tenant_id", "skill_id", "path", "normalized_path", "kind", "language", + "media_type", "is_text", "content_hash", "size", "metadata_json", "sort_no") + .eq(SkillResource::getTenantId, tenantId) + .eq(SkillResource::getSkillId, skillId) + .orderBy("sort_no asc, normalized_path asc"); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillScriptServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillScriptServiceImpl.java deleted file mode 100644 index 83dda3e1..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillScriptServiceImpl.java +++ /dev/null @@ -1,14 +0,0 @@ -package tech.easyflow.skill.service.impl; - -import com.mybatisflex.spring.service.impl.ServiceImpl; -import org.springframework.stereotype.Service; -import tech.easyflow.skill.entity.SkillScript; -import tech.easyflow.skill.mapper.SkillScriptMapper; -import tech.easyflow.skill.service.SkillScriptService; - -/** - * Skill script 服务实现。 - */ -@Service -public class SkillScriptServiceImpl extends ServiceImpl implements SkillScriptService { -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillServiceImpl.java index c1309846..74ea2de5 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillServiceImpl.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillServiceImpl.java @@ -2,23 +2,36 @@ package tech.easyflow.skill.service.impl; import com.easyagents.skill.exception.SkillException; import com.easyagents.skill.factory.SkillFactory; +import com.easyagents.skill.model.SkillDocument; +import com.easyagents.skill.util.SkillFrontmatter; +import com.easyagents.skill.util.SkillHashes; +import com.easyagents.skill.util.SkillPaths; import com.easyagents.skill.validation.defaults.DefaultSkillValidator; +import com.easyagents.skill.validation.SkillValidationMode; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.spring.service.impl.ServiceImpl; import org.springframework.stereotype.Service; +import org.springframework.dao.DuplicateKeyException; import org.springframework.transaction.annotation.Transactional; import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.capability.SkillCapabilityBindingService; import tech.easyflow.skill.entity.Skill; -import tech.easyflow.skill.entity.SkillAsset; -import tech.easyflow.skill.entity.SkillReference; -import tech.easyflow.skill.entity.SkillScript; +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.entity.SkillResource; import tech.easyflow.skill.mapper.SkillMapper; -import tech.easyflow.skill.service.*; +import tech.easyflow.skill.service.SkillCategoryService; +import tech.easyflow.skill.service.SkillResourceService; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.store.DBSkillContentStore; import tech.easyflow.skill.support.SkillModelConverter; +import tech.easyflow.skill.support.SkillResourceModelAdapter; +import tech.easyflow.skill.validation.SkillValidationIssue; +import tech.easyflow.skill.validation.SkillValidationResult; import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.enums.ResourceAction; @@ -26,11 +39,19 @@ import tech.easyflow.system.enums.VisibilityScope; import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.ResourceAccessService; -import javax.annotation.Resource; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.Comparator; import java.util.Date; +import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; +import java.util.TreeMap; /** * Skill 业务服务实现。 @@ -39,21 +60,40 @@ import java.util.Map; public class SkillServiceImpl extends ServiceImpl implements SkillService { private final DefaultSkillValidator skillValidator = new DefaultSkillValidator(); + private final SkillCategoryService skillCategoryService; + private final SkillResourceService skillResourceService; + private final SkillCapabilityBindingService capabilityBindingService; + private final DBSkillContentStore contentStore; + private final ResourceAccessService resourceAccessService; + private final CategoryPermissionService categoryPermissionService; + private final ObjectMapper objectMapper; - @Resource - private SkillCategoryService skillCategoryService; - @Resource - private SkillReferenceService skillReferenceService; - @Resource - private SkillScriptService skillScriptService; - @Resource - private SkillAssetService skillAssetService; - @Resource - private ResourceAccessService resourceAccessService; - @Resource - private CategoryPermissionService categoryPermissionService; - @Resource - private ObjectMapper objectMapper; + /** + * 创建 Skill 业务服务。 + * + * @param skillCategoryService Skill 分类服务 + * @param skillResourceService 通用资源服务 + * @param capabilityBindingService 能力绑定服务 + * @param contentStore 二进制内容仓库 + * @param resourceAccessService 资源访问服务 + * @param categoryPermissionService 分类权限服务 + * @param objectMapper JSON 映射器 + */ + public SkillServiceImpl(SkillCategoryService skillCategoryService, + SkillResourceService skillResourceService, + SkillCapabilityBindingService capabilityBindingService, + DBSkillContentStore contentStore, + ResourceAccessService resourceAccessService, + CategoryPermissionService categoryPermissionService, + ObjectMapper objectMapper) { + this.skillCategoryService = skillCategoryService; + this.skillResourceService = skillResourceService; + this.capabilityBindingService = capabilityBindingService; + this.contentStore = contentStore; + this.resourceAccessService = resourceAccessService; + this.categoryPermissionService = categoryPermissionService; + this.objectMapper = objectMapper; + } /** * {@inheritDoc} @@ -63,6 +103,44 @@ public class SkillServiceImpl extends ServiceImpl implements Skill skill = requireSkill(id); resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill"); fillResources(skill); + fillCapabilityBindings(skill); + return skill; + } + + /** + * {@inheritDoc} + */ + @Override + public Skill getManagementDetail(BigInteger id) { + Skill skill = requireSkill(id); + resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill"); + fillResourceDescriptors(skill); + fillCapabilityBindings(skill); + return skill; + } + + private void fillCapabilityBindings(Skill skill) { + List bindings = + capabilityBindingService.listBindings(skill.getId()); + skill.setCapabilityBindings(bindings); + boolean containsRedactedTarget = bindings.stream() + .anyMatch(binding -> "NO_PERMISSION".equals(binding.getTargetStatus())); + if (!containsRedactedTarget && (skill.getCapabilityHash() == null || skill.getCapabilityHash().isBlank())) { + String capabilityHash = capabilityBindingService.calculateStoredHash(skill.getId()); + skill.setCapabilityHash(capabilityHash); + getMapper().backfillCapabilityHash(skill.getId(), skill.getTenantId(), capabilityHash); + } + } + + /** + * {@inheritDoc} + */ + @Override + public Skill getPackageDetail(BigInteger id) { + Skill skill = requireSkill(id); + resourceAccessService.assertAccess( + CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill"); + fillResources(skill); return skill; } @@ -72,10 +150,27 @@ public class SkillServiceImpl extends ServiceImpl implements @Override @Transactional(rollbackFor = Exception.class) public Skill saveDraft(Skill skill) { + if (skill == null) { + throw new BusinessException("Skill 不能为空"); + } + skillCategoryService.lockAndValidateUsableCategory(skill.getCategoryId()); validateDraft(skill); + assertUniqueName(skill.getName(), null); + List resources = SkillResourceModelAdapter.toResources(skill); applyDraftDefaults(skill); - save(skill); - replaceResources(skill); + normalizeResources(skill, resources); + skill.setPackageHash(calculatePackageHash(skill.getSkillContent(), resources)); + skill.setResources(resources); + syncCounts(skill, resources); + try { + if (!save(skill)) { + throw new BusinessException(500, 500, "保存 Skill 失败,请稍后重试"); + } + } catch (DuplicateKeyException exception) { + throw new BusinessException(409, 4092, "当前租户已存在同名 Skill"); + } + resources.forEach(resource -> resource.setSkillId(skill.getId())); + replaceResources(skill, resources); return getDetail(skill.getId()); } @@ -85,26 +180,201 @@ public class SkillServiceImpl extends ServiceImpl implements @Override @Transactional(rollbackFor = Exception.class) public Skill updateDraft(Skill skill) { + return updateDraftInternal(skill, null, false); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Skill overwriteImportedDraft(Skill skill) { + return updateDraftInternal(skill, null, true); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Skill updateDraftIfContentMatches(Skill skill, String expectedSkillContentHash) { + if (expectedSkillContentHash == null || !expectedSkillContentHash.matches("^[a-f0-9]{64}$")) { + throw new BusinessException(409, 4091, "缺少或无效的文件版本,请重新加载后再保存"); + } + return updateDraftInternal(skill, expectedSkillContentHash, false); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Skill copyDraft(BigInteger sourceId, String name, String displayName, BigInteger categoryId) { + if (sourceId == null) { + throw new BusinessException("源 Skill ID 不能为空"); + } + String normalizedName = name == null ? "" : name.trim(); + if (!isCanonicalName(normalizedName)) { + throw new BusinessException("新 Skill 名称仅支持小写字母、数字和连字符"); + } + + Skill source = getDetail(sourceId); + SkillDocument document; + try { + document = SkillFrontmatter.parseDocument(source.getSkillContent()); + document.putFrontmatter("name", normalizedName); + } catch (SkillException exception) { + throw new BusinessException("复制 Skill 时解析 SKILL.md 失败:" + exception.getMessage()); + } + + List resources = source.getResources() == null ? List.of() + : source.getResources().stream().map(this::copyResource).toList(); + // 新草稿对每个二进制资源持有独立引用;外层事务失败时引用计数会随数据库事务回滚。 + resources.stream().map(SkillResource::getContentRef) + .filter(contentRef -> contentRef != null && !contentRef.isBlank()) + .forEach(contentStore::retain); + + Skill draft = new Skill(); + draft.setCategoryId(categoryId); + draft.setDisplayName(displayName == null || displayName.isBlank() + ? normalizedName : displayName.trim()); + draft.setSkillContent(document.render()); + draft.setEnabled(true); + draft.setVisibilityScope(VisibilityScope.PRIVATE.name()); + draft.setSourceType("MANUAL"); + draft.setResources(resources); + Skill saved = saveDraft(draft); + + List bindings = source.getCapabilityBindings() == null ? List.of() + : source.getCapabilityBindings().stream().map(this::copyBinding).toList(); + if (!bindings.isEmpty()) { + capabilityBindingService.replaceBindings(saved.getId(), bindings, saved.getCapabilityHash()); + return getDetail(saved.getId()); + } + return saved; + } + + private Skill updateDraftInternal(Skill skill, + String expectedSkillContentHash, + boolean requireDraftStatus) { if (skill == null || skill.getId() == null) { throw new BusinessException("Skill ID 不能为空"); } - Skill existing = requireSkill(skill.getId()); + // 与分类删除保持“分类树 -> Skill 行”的统一锁顺序,避免相反顺序形成死锁。 + skillCategoryService.lockAndValidateUsableCategory(skill.getCategoryId()); + Skill existing = requireSkill(skill.getId(), true); + String originalSkillContent = existing.getSkillContent(); resourceAccessService.assertAccess(CategoryResourceType.SKILL, existing, ResourceAction.MANAGE, "无权限管理该 Skill"); + if (requireDraftStatus && PublishStatus.from(existing.getPublishStatus()) != PublishStatus.DRAFT) { + throw new BusinessException(409, 4092, "仅允许覆盖草稿状态的 Skill:" + existing.getName()); + } + if (skill.getSkillContent() == null) { + skill.setSkillContent(existing.getSkillContent()); + } + if (expectedSkillContentHash != null) { + String actualHash = SkillHashes.sha256Hex((existing.getSkillContent() == null ? "" : existing.getSkillContent()) + .getBytes(StandardCharsets.UTF_8)); + if (!expectedSkillContentHash.equals(actualHash)) { + throw new BusinessException(409, 4091, "文件已被其他操作更新,请重新加载后合并内容"); + } + } + // 来源由服务端继承,使旧版下划线导入草稿可继续编辑,同时仍在发布校验中阻断。 + skill.setSourceType(existing.getSourceType()); validateDraft(skill); + assertUniqueName(skill.getName(), skill.getId()); + List resources = hasResourcePayload(skill) + ? SkillResourceModelAdapter.toResources(skill) + : listResources(skill.getId()); + normalizeResources(existing, resources); applyDraftUpdate(existing, skill); - updateById(existing); - replaceResources(existing); + existing.setPackageHash(calculatePackageHash(existing.getSkillContent(), resources)); + existing.setResources(resources); + syncCounts(existing, resources); + try { + QueryWrapper updateQuery = tenantSkillQuery(existing.getId()); + if (expectedSkillContentHash != null) { + // BINARY 比较保证文本大小写变化也会使旧版本条件失效。 + updateQuery.and("BINARY skill_content = ?", originalSkillContent); + } + if (getMapper().updateByQuery(existing, updateQuery) != 1) { + if (expectedSkillContentHash != null) { + throw new BusinessException(409, 4091, "文件已被其他操作更新,请重新加载后合并内容"); + } + throw new BusinessException(500, 500, "更新 Skill 失败,请稍后重试"); + } + } catch (DuplicateKeyException exception) { + throw new BusinessException(409, 4092, "当前租户已存在同名 Skill"); + } + if (hasResourcePayload(skill)) { + replaceResources(existing, resources); + } return getDetail(existing.getId()); } + /** + * {@inheritDoc} + */ + @Override + public SkillValidationResult validateSkill(BigInteger id, boolean publishValidation) { + Skill detail = getDetail(id); + if (publishValidation) { + resourceAccessService.assertAccess(CategoryResourceType.SKILL, detail, ResourceAction.MANAGE, + "无权限管理该 Skill"); + } + List issues = new ArrayList<>(); + com.easyagents.skill.validation.SkillValidationReport packageReport = + skillValidator.validateReport( + SkillModelConverter.toAgentSkill(detail), null, + publishValidation ? SkillValidationMode.STANDARD + : SkillValidationMode.DRAFT_IMPORT); + for (com.easyagents.skill.validation.SkillValidationIssue source : packageReport.getIssues()) { + SkillValidationIssue issue = SkillValidationIssue.of(source.getSeverity().name(), source.getCode(), + source.getMessage(), source.getPath()); + issue.setLine(source.getLine()); + issue.setColumn(source.getColumn()); + issue.setSuggestion(source.getSuggestion()); + issues.add(issue); + } + SkillValidationResult capabilityResult = capabilityBindingService.validateBindings(id, null, publishValidation); + issues.addAll(capabilityResult.getIssues()); + SkillValidationResult result = new SkillValidationResult(); + result.setIssues(issues); + result.setValid(issues.stream().noneMatch(issue -> "ERROR".equals(issue.getSeverity()))); + return result; + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void refreshPackageState(BigInteger id) { + Skill skill = requireSkill(id); + resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, + "无权限管理该 Skill"); + List resources = listResources(id); + syncCounts(skill, resources); + skill.setPackageHash(calculatePackageHash(skill.getSkillContent(), resources)); + skill.setModified(new Date()); + skill.setModifiedBy(requireCurrentLoginAccount().getId()); + if (getMapper().updateByQuery(skill, tenantSkillQuery(skill.getId())) != 1) { + throw new BusinessException(500, 500, "刷新 Skill 包状态失败,请稍后重试"); + } + } + /** * {@inheritDoc} */ @Override public Map buildPublishSnapshot(Skill skill) { Skill detail = getDetail(skill.getId()); - com.easyagents.skill.model.Skill agentSkill = SkillModelConverter.toAgentSkill(detail); + SkillValidationResult validation = validateSkill(detail.getId(), true); + validation.getIssues().stream().filter(issue -> "ERROR".equals(issue.getSeverity())).findFirst() + .ifPresent(issue -> { + throw new BusinessException("Skill 发布校验失败:" + issue.getMessage()); + }); Map snapshot = new LinkedHashMap<>(); + snapshot.put("schemaVersion", 1); snapshot.put("id", detail.getId()); snapshot.put("tenantId", detail.getTenantId()); snapshot.put("deptId", detail.getDeptId()); @@ -119,13 +389,67 @@ public class SkillServiceImpl extends ServiceImpl implements snapshot.put("visibilityScope", detail.getVisibilityScope()); snapshot.put("sourceType", detail.getSourceType()); snapshot.put("packageHash", detail.getPackageHash()); - snapshot.put("references", agentSkill.getReferences()); - snapshot.put("scripts", agentSkill.getScripts()); - snapshot.put("assets", agentSkill.getAssets()); - snapshot.put("snapshotAt", new Date()); + snapshot.put("resources", buildResourceSnapshot(detail.getResources())); + List> capabilitySnapshot = capabilityBindingService.buildPublishSnapshot(detail.getId()); + // 发布态 hash 覆盖解析后的目标版本和 MCP ALL 最终工具清单,目标变化会形成新快照。 + String capabilityHash = hashJson(capabilitySnapshot); + snapshot.put("capabilityHash", capabilityHash); + snapshot.put("capabilities", capabilitySnapshot); + String snapshotHash = hashJson(snapshot); + snapshot.put("snapshotHash", snapshotHash); return snapshot; } + /** + * {@inheritDoc} + */ + @Override + public Map buildGovernanceSnapshot(Skill skill) { + if (skill == null || skill.getId() == null) { + throw new BusinessException("Skill 治理快照缺少资源标识"); + } + Map snapshot = new LinkedHashMap<>(); + snapshot.put("schemaVersion", 1); + snapshot.put("id", skill.getId()); + snapshot.put("tenantId", skill.getTenantId()); + snapshot.put("deptId", skill.getDeptId()); + snapshot.put("categoryId", skill.getCategoryId()); + snapshot.put("name", skill.getName()); + snapshot.put("displayName", skill.getDisplayName()); + snapshot.put("publishStatus", skill.getPublishStatus()); + snapshot.put("enabled", skill.getEnabled()); + snapshot.put("visibilityScope", skill.getVisibilityScope()); + snapshot.put("sourceType", skill.getSourceType()); + snapshot.put("packageHash", skill.getPackageHash()); + snapshot.put("capabilityHash", skill.getCapabilityHash()); + snapshot.put("resourceCount", skill.getResourceCount()); + snapshot.put("capabilityCount", skill.getCapabilityCount()); + snapshot.put("createdBy", skill.getCreatedBy()); + return snapshot; + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void retainSnapshotContents(Map snapshot) { + for (String contentRef : snapshotContentRefs(snapshot)) { + contentStore.retain(contentRef); + } + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void releaseSnapshotContents(Map snapshot) { + for (String contentRef : snapshotContentRefs(snapshot)) { + contentStore.release(contentRef); + } + } + /** * {@inheritDoc} */ @@ -142,6 +466,9 @@ public class SkillServiceImpl extends ServiceImpl implements skill.setCategoryId(toBigInteger(snapshot.get("categoryId"))); skill.setPublishStatus(PublishStatus.PUBLISHED.getCode()); skill.setPublishedSnapshotJson(snapshot); + if (skill.getResources() != null) { + SkillResourceModelAdapter.fillCompatibilityViews(skill, skill.getResources()); + } return skill; } @@ -151,17 +478,82 @@ public class SkillServiceImpl extends ServiceImpl implements @Override @Transactional(rollbackFor = Exception.class) public void removeAggregate(BigInteger id) { + removeAggregate(id, false); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void removeLifecycleAggregate(BigInteger id) { + removeAggregate(id, true); + } + + /** + * 在锁定 Skill 主行后删除完整聚合并释放内容引用。 + * + * @param id Skill ID + * @param lifecycleDelete 是否来自统一发布生命周期 + * @throws BusinessException Skill 不存在、无权限、状态不可删除或聚合删除失败 + */ + private void removeAggregate(BigInteger id, boolean lifecycleDelete) { if (id == null) { return; } - removeResources(id); - removeById(id); + // 文件、资源和能力更新同样先锁 Skill 行;删除必须持有该锁直到引用计数变更完成。 + Skill skill = requireSkill(id, true); + resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限删除该 Skill"); + assertRemovableStatus(skill, lifecycleDelete); + List resources = listResources(id); + if (!skillResourceService.remove(QueryWrapper.create() + .eq(SkillResource::getTenantId, skill.getTenantId()) + .eq(SkillResource::getSkillId, id))) { + if (!resources.isEmpty()) { + throw new BusinessException(500, 500, "删除 Skill 资源失败,请稍后重试"); + } + } + capabilityBindingService.removeBySkillId(id); + if (getMapper().deleteByQuery(tenantSkillQuery(id)) != 1) { + throw new BusinessException(500, 500, "删除 Skill 失败,请稍后重试"); + } + releaseContents(resources); + releaseSnapshotContents(skill.getPublishedSnapshotJson()); + } + + /** + * 校验普通仓储删除与生命周期删除各自允许的发布状态。 + * + * @param skill 已锁定的 Skill + * @param lifecycleDelete 是否来自统一发布生命周期 + * @throws BusinessException Skill 已发布或处于当前删除入口不允许的审批状态 + */ + private void assertRemovableStatus(Skill skill, boolean lifecycleDelete) { + PublishStatus status = PublishStatus.from(skill.getPublishStatus()); + if (status == PublishStatus.PUBLISHED) { + throw new BusinessException(409, 4092, "当前 Skill 已发布,请先下线后再删除"); + } + if (status == PublishStatus.PUBLISH_PENDING || status == PublishStatus.OFFLINE_PENDING + || (!lifecycleDelete && status == PublishStatus.DELETE_PENDING)) { + throw new BusinessException(409, 4092, "当前 Skill 存在进行中的审批,请先处理完成"); + } } private Skill requireSkill(BigInteger id) { - Skill skill = getById(id); + return requireSkill(id, false); + } + + private Skill requireSkill(BigInteger id, boolean forUpdate) { + if (id == null) { + throw new BusinessException("Skill ID 不能为空"); + } + QueryWrapper query = tenantSkillQuery(id); + if (forUpdate) { + query.forUpdate(); + } + Skill skill = getOne(query); if (skill == null) { - throw new BusinessException("Skill 不存在"); + throw new BusinessException(404, 404, "Skill 不存在"); } return skill; } @@ -177,15 +569,32 @@ public class SkillServiceImpl extends ServiceImpl implements if (skill.getDescription() == null || skill.getDescription().isBlank()) { throw new BusinessException("Skill 描述不能为空"); } + if (skill.getDisplayName() != null && skill.getDisplayName().length() > 128) { + throw new BusinessException("Skill 展示名称不能超过 128 个字符"); + } if (skill.getSkillContent() == null || skill.getSkillContent().isBlank()) { throw new BusinessException("SKILL.md 内容不能为空"); } - skillCategoryService.validateUsableCategory(skill.getCategoryId()); + if (!isCanonicalName(skill.getName()) && !isLegacyImportName(skill)) { + throw new BusinessException("Skill 名称仅支持小写字母、数字和连字符"); + } validateTargetCategoryVisible(skill.getCategoryId()); skill.setVisibilityScope(VisibilityScope.fromOrDefault(skill.getVisibilityScope(), VisibilityScope.PRIVATE).name()); validateSkillPackage(skill); } + private void assertUniqueName(String name, BigInteger excludeId) { + QueryWrapper query = QueryWrapper.create() + .eq(Skill::getTenantId, requireCurrentLoginAccount().getTenantId()) + .eq(Skill::getName, name); + if (excludeId != null) { + query.ne(Skill::getId, excludeId); + } + if (count(query) > 0) { + throw new BusinessException(409, 4092, "当前租户已存在同名 Skill:" + name); + } + } + private void validateTargetCategoryVisible(BigInteger categoryId) { if (categoryId == null) { return; @@ -208,7 +617,8 @@ public class SkillServiceImpl extends ServiceImpl implements skill.setEnabled(skill.getEnabled() == null || skill.getEnabled()); skill.setSourceType(skill.getSourceType() == null ? "MANUAL" : skill.getSourceType()); skill.setPublishStatus(PublishStatus.DRAFT.getCode()); - syncCounts(skill); + skill.setCapabilityCount(0); + skill.setCapabilityHash(capabilityBindingService.calculateHash(List.of())); } private void applyDraftUpdate(Skill existing, Skill incoming) { @@ -221,69 +631,206 @@ public class SkillServiceImpl extends ServiceImpl implements existing.setSkillContent(incoming.getSkillContent()); existing.setEnabled(incoming.getEnabled() == null || incoming.getEnabled()); existing.setVisibilityScope(incoming.getVisibilityScope()); - existing.setSourceType(incoming.getSourceType()); - existing.setPackageHash(incoming.getPackageHash()); - existing.setReferences(incoming.getReferences()); - existing.setScripts(incoming.getScripts()); - existing.setAssets(incoming.getAssets()); - syncCounts(existing); + if (incoming.getSourceType() != null && !incoming.getSourceType().isBlank()) { + existing.setSourceType(incoming.getSourceType()); + } existing.setModified(new Date()); existing.setModifiedBy(account.getId()); } - private void syncCounts(Skill skill) { - skill.setReferenceCount(skill.getReferences() == null ? 0 : skill.getReferences().size()); - skill.setScriptCount(skill.getScripts() == null ? 0 : skill.getScripts().size()); - skill.setAssetCount(skill.getAssets() == null ? 0 : skill.getAssets().size()); + private void syncCounts(Skill skill, List resources) { + int references = 0; + int scripts = 0; + int assets = 0; + for (SkillResource resource : resources) { + if ("REFERENCE".equals(resource.getKind())) { + references++; + } else if ("SCRIPT".equals(resource.getKind())) { + scripts++; + } else if (!Boolean.TRUE.equals(resource.getIsText())) { + assets++; + } + } + skill.setResourceCount(resources.size()); + skill.setReferenceCount(references); + skill.setScriptCount(scripts); + skill.setAssetCount(assets); } private void fillResources(Skill skill) { - skill.setReferences(skillReferenceService.list(QueryWrapper.create().eq(SkillReference::getSkillId, skill.getId()).orderBy("path asc"))); - skill.setScripts(skillScriptService.list(QueryWrapper.create().eq(SkillScript::getSkillId, skill.getId()).orderBy("path asc"))); - skill.setAssets(skillAssetService.list(QueryWrapper.create().eq(SkillAsset::getSkillId, skill.getId()).orderBy("path asc"))); + List resources = listResources(skill.getId()); + skill.setResources(resources); + refreshPackageSummary(skill, resources); + SkillResourceModelAdapter.fillCompatibilityViews(skill, resources); } - private void replaceResources(Skill skill) { - removeResources(skill.getId()); - BigInteger tenantId = skill.getTenantId(); - BigInteger skillId = skill.getId(); - if (skill.getReferences() != null) { - for (SkillReference reference : skill.getReferences()) { - reference.setTenantId(tenantId); - reference.setSkillId(skillId); - skillReferenceService.save(reference); + private void fillResourceDescriptors(Skill skill) { + List resources = skillResourceService.listDescriptors( + skill.getId(), requireCurrentLoginAccount().getTenantId()); + skill.setResources(resources); + refreshPackageSummary(skill, resources); + } + + private void refreshPackageSummary(Skill skill, List resources) { + String previousHash = skill.getPackageHash(); + syncCounts(skill, resources); + skill.setPackageHash(calculatePackageHash(skill.getSkillContent(), resources)); + if (previousHash == null || previousHash.isBlank()) { + getMapper().backfillPackageSummary(skill.getId(), skill.getTenantId(), skill.getPackageHash(), + skill.getResourceCount(), skill.getReferenceCount(), skill.getScriptCount(), skill.getAssetCount()); + } + } + + private List listResources(BigInteger skillId) { + return skillResourceService.list(QueryWrapper.create() + .eq(SkillResource::getTenantId, requireCurrentLoginAccount().getTenantId()) + .eq(SkillResource::getSkillId, skillId) + .orderBy("sort_no asc, normalized_path asc")); + } + + private void replaceResources(Skill skill, List resources) { + List oldResources = listResources(skill.getId()); + if (!oldResources.isEmpty()) { + if (!skillResourceService.remove(QueryWrapper.create() + .eq(SkillResource::getTenantId, skill.getTenantId()) + .eq(SkillResource::getSkillId, skill.getId()))) { + throw new BusinessException(500, 500, "替换 Skill 资源失败,请稍后重试"); } } - if (skill.getScripts() != null) { - for (SkillScript script : skill.getScripts()) { - script.setTenantId(tenantId); - script.setSkillId(skillId); - skillScriptService.save(script); + if (!resources.isEmpty()) { + if (!skillResourceService.saveBatch(resources)) { + throw new BusinessException(500, 500, "保存 Skill 资源失败,请稍后重试"); } } - if (skill.getAssets() != null) { - for (SkillAsset asset : skill.getAssets()) { - asset.setTenantId(tenantId); - asset.setSkillId(skillId); - skillAssetService.save(asset); + for (SkillResource oldResource : oldResources) { + if (oldResource.getContentRef() != null) { + contentStore.release(oldResource.getContentRef()); } } } - private void removeResources(BigInteger skillId) { - skillReferenceService.remove(QueryWrapper.create().eq(SkillReference::getSkillId, skillId)); - skillScriptService.remove(QueryWrapper.create().eq(SkillScript::getSkillId, skillId)); - skillAssetService.remove(QueryWrapper.create().eq(SkillAsset::getSkillId, skillId)); + /** + * 复制通用资源配置,数据库归属和审计字段由新草稿保存流程重建。 + * + * @param source 源资源 + * @return 无持久化标识的资源副本 + */ + private SkillResource copyResource(SkillResource source) { + SkillResource target = new SkillResource(); + target.setPath(source.getPath()); + target.setNormalizedPath(source.getNormalizedPath()); + target.setKind(source.getKind()); + target.setLanguage(source.getLanguage()); + target.setMediaType(source.getMediaType()); + target.setIsText(source.getIsText()); + target.setTextContent(source.getTextContent()); + target.setContentRef(source.getContentRef()); + target.setContentHash(source.getContentHash()); + target.setSize(source.getSize()); + target.setMetadataJson(new LinkedHashMap<>(source.getMetadataJson())); + target.setSortNo(source.getSortNo()); + return target; + } + + /** + * 复制能力绑定配置,目标授权和派生状态由替换流程重新解析。 + * + * @param source 源能力绑定 + * @return 无持久化标识的绑定副本 + */ + private SkillCapabilityBinding copyBinding(SkillCapabilityBinding source) { + SkillCapabilityBinding target = new SkillCapabilityBinding(); + target.setCapabilityType(source.getCapabilityType()); + target.setTargetId(source.getTargetId()); + target.setTargetLogicalRef(source.getTargetLogicalRef()); + target.setRuntimeName(source.getRuntimeName()); + target.setEnabled(source.getEnabled()); + target.setSelectionMode(source.getSelectionMode()); + target.setSelectedToolNamesJson(source.getSelectedToolNamesJson()); + target.setExecutionMode(source.getExecutionMode()); + target.setHitlEnabled(source.getHitlEnabled()); + target.setHitlConfigJson(source.getHitlConfigJson() == null + ? Map.of() : new LinkedHashMap<>(source.getHitlConfigJson())); + target.setOptionsJson(source.getOptionsJson() == null + ? Map.of() : new LinkedHashMap<>(source.getOptionsJson())); + target.setSortNo(source.getSortNo()); + return target; + } + + private void normalizeResources(Skill skill, List resources) { + Set paths = new HashSet<>(); + LoginAccount account = requireCurrentLoginAccount(); + Date now = new Date(); + for (int index = 0; index < resources.size(); index++) { + SkillResource resource = resources.get(index); + if (resource == null) { + throw new BusinessException("Skill 资源不能为空"); + } + String normalizedPath; + try { + normalizedPath = SkillPaths.normalize(resource.getPath() == null + ? resource.getNormalizedPath() : resource.getPath()); + } catch (SkillException exception) { + throw new BusinessException("Skill 资源路径不合法:" + exception.getMessage()); + } + if (SkillPaths.SKILL_FILE.equals(normalizedPath)) { + throw new BusinessException("SKILL.md 必须保存在 Skill 主表中"); + } + if (normalizedPath.split("/").length > 16) { + throw new BusinessException("Skill 资源路径层级不能超过 16 层:" + normalizedPath); + } + if (!paths.add(collisionKey(normalizedPath))) { + throw new BusinessException("Skill 资源路径重复:" + normalizedPath); + } + resource.setId(null); + resource.setTenantId(skill.getTenantId()); + resource.setSkillId(skill.getId()); + resource.setPath(normalizedPath); + resource.setNormalizedPath(normalizedPath); + resource.setSortNo(resource.getSortNo() == null ? index : resource.getSortNo()); + resource.setCreated(now); + resource.setCreatedBy(account.getId()); + resource.setModified(now); + resource.setModifiedBy(account.getId()); + if (Boolean.TRUE.equals(resource.getIsText())) { + byte[] bytes = (resource.getTextContent() == null ? "" : resource.getTextContent()) + .getBytes(StandardCharsets.UTF_8); + resource.setContentRef(null); + resource.setContentHash(SkillHashes.sha256Hex(bytes)); + resource.setSize((long) bytes.length); + } else { + if (resource.getContentRef() == null || !contentStore.exists(resource.getContentRef())) { + throw new BusinessException("Skill 二进制资源内容不存在:" + normalizedPath); + } + String expectedHash = resource.getContentRef().startsWith("sha256:") + ? resource.getContentRef().substring("sha256:".length()) : null; + if (expectedHash == null || !expectedHash.equals(resource.getContentHash())) { + throw new BusinessException("Skill 二进制资源 hash 不一致:" + normalizedPath); + } + resource.setTextContent(null); + } + } + } + + private boolean hasResourcePayload(Skill skill) { + return skill.getResources() != null || skill.getReferences() != null + || skill.getScripts() != null || skill.getAssets() != null; + } + + private void releaseContents(List resources) { + for (SkillResource resource : resources) { + if (resource.getContentRef() != null) { + contentStore.release(resource.getContentRef()); + } + } } private void normalizeFromSkillContent(Skill skill) { try { - com.easyagents.skill.model.Skill parsed = SkillFactory.create( + com.easyagents.skill.model.Skill parsed = SkillFactory.createWithResources( skill.getId() == null ? "draft" : String.valueOf(skill.getId()), skill.getSkillContent(), - SkillModelConverter.toAgentReferences(skill.getReferences()), - SkillModelConverter.toAgentScripts(skill.getScripts()), - SkillModelConverter.toAgentAssets(skill.getAssets()) + SkillModelConverter.toAgentResources(SkillResourceModelAdapter.toResources(skill)) ); skill.setName(parsed.getName()); if (skill.getDisplayName() == null || skill.getDisplayName().isBlank()) { @@ -291,27 +838,136 @@ public class SkillServiceImpl extends ServiceImpl implements } skill.setDescription(parsed.getDescription()); skill.setMetadataJson(parsed.getMetadata().getValues()); - } catch (SkillException e) { - throw new BusinessException("SKILL.md frontmatter 不合法:" + e.getMessage()); + } catch (SkillException exception) { + throw new BusinessException("SKILL.md frontmatter 不合法:" + exception.getMessage()); } } private void validateSkillPackage(Skill skill) { try { skillValidator.validate(SkillModelConverter.toAgentSkill(skill)); - } catch (SkillException e) { - throw new BusinessException("Skill 包校验失败:" + e.getMessage()); + } catch (SkillException exception) { + throw new BusinessException("Skill 包校验失败:" + exception.getMessage()); } } + private String calculatePackageHash(String skillContent, List resources) { + StringBuilder canonical = new StringBuilder(); + canonical.append(SkillPaths.SKILL_FILE).append('\n') + .append(SkillHashes.sha256Hex((skillContent == null ? "" : skillContent) + .getBytes(StandardCharsets.UTF_8))).append('\n'); + resources.stream().sorted(Comparator.comparing(SkillResource::getNormalizedPath)) + .forEach(resource -> canonical.append(resource.getNormalizedPath()).append('\n') + .append(resource.getContentHash()).append('\n')); + return SkillHashes.sha256Hex(canonical.toString().getBytes(StandardCharsets.UTF_8)); + } + + private List> buildResourceSnapshot(List resources) { + List> result = new ArrayList<>(); + for (SkillResource resource : resources) { + Map item = new LinkedHashMap<>(); + item.put("path", resource.getNormalizedPath()); + item.put("kind", resource.getKind()); + item.put("language", resource.getLanguage()); + item.put("mediaType", resource.getMediaType()); + item.put("text", resource.getIsText()); + item.put("textContent", resource.getTextContent()); + item.put("contentRef", resource.getContentRef()); + item.put("contentHash", resource.getContentHash()); + item.put("size", resource.getSize()); + item.put("metadata", resource.getMetadataJson()); + result.add(item); + } + return result; + } + + private String hashJson(Object value) { + try { + return SkillHashes.sha256Hex(objectMapper.writeValueAsBytes(canonicalizeJson(value))); + } catch (JsonProcessingException exception) { + throw new BusinessException(500, 500, "计算 Skill 发布快照 hash 失败", exception); + } + } + + private Object canonicalizeJson(Object value) { + if (value instanceof Map map) { + Map sorted = new TreeMap<>(); + map.forEach((key, item) -> sorted.put(String.valueOf(key), canonicalizeJson(item))); + return sorted; + } + if (value instanceof List list) { + return list.stream().map(this::canonicalizeJson).toList(); + } + return value; + } + + /** + * 读取新旧发布快照中的二进制内容引用。 + * + * @param snapshot 发布或审批快照 + * @return 按资源出现次数保留的内容引用 + */ + private List snapshotContentRefs(Map snapshot) { + if (snapshot == null) { + return List.of(); + } + Object resources = snapshot.get("resources"); + if (resources instanceof List resourceList) { + return collectSnapshotContentRefs(resourceList); + } + // V24 published snapshots stored binary resources in assets[]. Keep this fallback until + // every legacy snapshot has naturally been replaced or removed through the lifecycle. + Object assets = snapshot.get("assets"); + return assets instanceof List assetList ? collectSnapshotContentRefs(assetList) : List.of(); + } + + /** + * 从资源数组中收集非空内容引用。 + * + * @param resources 快照资源数组 + * @return 内容引用列表 + */ + private List collectSnapshotContentRefs(List resources) { + List refs = new ArrayList<>(); + for (Object item : resources) { + if (item instanceof Map resource) { + Object contentRef = resource.get("contentRef"); + if (contentRef instanceof String value && !value.isBlank()) { + refs.add(value); + } + } + } + return refs; + } + + private boolean isCanonicalName(String name) { + return name != null && name.matches("[a-z0-9]+(?:-[a-z0-9]+)*"); + } + + private boolean isLegacyImportName(Skill skill) { + String sourceType = skill.getSourceType(); + return ("STANDARD_ZIP".equals(sourceType) || "EASYFLOW_BUNDLE".equals(sourceType)) + && skill.getName() != null && skill.getName().matches("[a-z0-9]+(?:[_-][a-z0-9]+)*"); + } + + private String collisionKey(String path) { + return Normalizer.normalize(path, Normalizer.Form.NFKC).toLowerCase(Locale.ROOT); + } + private LoginAccount requireCurrentLoginAccount() { LoginAccount account = SaTokenUtil.getLoginAccount(); - if (account == null || account.getId() == null) { - throw new BusinessException("未登录或登录态无效"); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException(401, 401, "未登录或登录态无效"); } return account; } + private QueryWrapper tenantSkillQuery(BigInteger skillId) { + return QueryWrapper.create() + .eq(Skill::getId, skillId) + .eq(Skill::getTenantId, requireCurrentLoginAccount().getTenantId()); + } + private BigInteger toBigInteger(Object value) { if (value == null) { return null; diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/store/DBSkillContentStore.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/store/DBSkillContentStore.java index 49aa3e9a..9ed9af19 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/store/DBSkillContentStore.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/store/DBSkillContentStore.java @@ -1,127 +1,968 @@ package tech.easyflow.skill.store; import com.easyagents.skill.store.SkillContentStore; +import com.easyagents.skill.store.SkillContentStage; import com.easyagents.skill.util.SkillHashes; -import com.mybatisflex.core.query.QueryWrapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.common.cache.DistributedScheduledLock; import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.filestorage.FileStorageWriteHandle; +import tech.easyflow.common.filestorage.FileStorageWriteResult; import tech.easyflow.common.web.exceptions.BusinessException; -import tech.easyflow.skill.entity.SkillAssetContent; -import tech.easyflow.skill.service.SkillAssetContentService; +import tech.easyflow.skill.entity.SkillContent; +import tech.easyflow.skill.entity.SkillContentWriteIntent; +import tech.easyflow.skill.mapper.SkillContentMapper; +import tech.easyflow.skill.mapper.SkillContentWriteIntentMapper; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.Date; +import java.util.List; +import java.util.function.Supplier; +import java.util.regex.Pattern; /** - * 基于数据库索引和现有文件存储的 Skill 内容存储。 + * 基于数据库引用索引与平台文件存储的 Skill 二进制内容仓库。 + * + *

put、retain 与 release 的引用计数变更始终加入调用方事务。新内容在物理写入前通过独立事务 + * 保存可恢复写入意图,正式索引与意图删除在调用方事务中原子提交;进程在任意写入阶段退出时, + * 数据库回滚都会恢复可供定时任务精确清理的意图。最后引用对应的物理文件仅在事务提交后删除。

*/ @Component public class DBSkillContentStore implements SkillContentStore { + private static final Logger LOG = LoggerFactory.getLogger(DBSkillContentStore.class); private static final String DEFAULT_MEDIA_TYPE = "application/octet-stream"; + private static final String PENDING_PREFIX = "__PENDING__:"; + private static final String INTENT_PENDING = "PENDING"; + private static final String INTENT_WRITING = "WRITING"; + private static final String INTENT_CLEANING = "CLEANING"; + private static final String CONTENT_PATH_PREFIX = "skill-content/"; + private static final Pattern CONTENT_REF_PATTERN = Pattern.compile("sha256:[0-9a-f]{64}"); + private static final int RELEASE_RETRY_LIMIT = 8; - private final SkillAssetContentService skillAssetContentService; + private final SkillContentMapper skillContentMapper; + private final SkillContentWriteIntentMapper writeIntentMapper; private final FileStorageService fileStorageService; + private final TransactionTemplate requiredTransactionTemplate; + private final TransactionTemplate cleanupTransactionTemplate; + + /** 未完成内容占位的最长保留时间。 */ + @Value("${easyflow.skill.content-pending-ttl-ms:1800000}") + private long pendingTtlMs = 1_800_000L; + + /** 零引用内容进入定时重试前的保护时间,避免与提交后清理并发。 */ + @Value("${easyflow.skill.content-release-retry-delay-ms:60000}") + private long releaseRetryDelayMs = 60_000L; + + /** 单次清理任务处理的最大记录数。 */ + @Value("${easyflow.skill.content-cleanup-batch-size:100}") + private int cleanupBatchSize = 100; /** - * 创建数据库 Skill 内容存储。 + * 创建 Skill 内容仓库。 * - * @param skillAssetContentService asset 内容索引服务 - * @param fileStorageService 文件存储服务 + * @param skillContentMapper 内容索引 Mapper + * @param writeIntentMapper 内容写入意图 Mapper + * @param fileStorageService 平台文件存储 + * @param transactionManager 平台事务管理器 */ - public DBSkillContentStore(SkillAssetContentService skillAssetContentService, - @Qualifier("default") FileStorageService fileStorageService) { - this.skillAssetContentService = skillAssetContentService; + public DBSkillContentStore(SkillContentMapper skillContentMapper, + SkillContentWriteIntentMapper writeIntentMapper, + @Qualifier("default") FileStorageService fileStorageService, + PlatformTransactionManager transactionManager) { + this.skillContentMapper = skillContentMapper; + this.writeIntentMapper = writeIntentMapper; this.fileStorageService = fileStorageService; + this.requiredTransactionTemplate = new TransactionTemplate(transactionManager); + this.cleanupTransactionTemplate = new TransactionTemplate(transactionManager); + this.cleanupTransactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); } /** - * {@inheritDoc} + * 保存字节内容并取得一份引用。 + * + * @param bytes 内容字节 + * @return sha256 内容引用 */ @Override public String put(byte[] bytes) { - String contentRef = SkillHashes.sha256Ref(bytes); - SkillAssetContent existing = skillAssetContentService.getById(contentRef); - if (existing != null) { - existing.setRefCount((existing.getRefCount() == null ? 0 : existing.getRefCount()) + 1); - existing.setModified(new Date()); - skillAssetContentService.updateById(existing); - return contentRef; - } - String hex = contentRef.substring("sha256:".length()); - String prePath = "skill-assets/" + hex.substring(0, Math.min(2, hex.length())); - MultipartFile file = new ByteArrayMultipartFile(bytes, contentRef + ".bin", DEFAULT_MEDIA_TYPE); - String filePath = fileStorageService.save(file, prePath); - SkillAssetContent content = new SkillAssetContent(); - content.setContentRef(contentRef); - content.setContentHash(hex); - content.setFilePath(filePath); - content.setMediaType(DEFAULT_MEDIA_TYPE); - content.setSize((long) (bytes == null ? 0 : bytes.length)); - content.setRefCount(1); - content.setCreated(new Date()); - content.setModified(new Date()); - skillAssetContentService.save(content); - return contentRef; + byte[] safeBytes = bytes == null ? new byte[0] : bytes.clone(); + return executeRequired(() -> putKnownHash( + new ByteArrayMultipartFile(safeBytes, "content.bin", DEFAULT_MEDIA_TYPE), + SkillHashes.sha256Ref(safeBytes), safeBytes.length, DEFAULT_MEDIA_TYPE)); } /** - * {@inheritDoc} + * 流式保存内容。 + * + * @param inputStream 内容流 + * @param maxBytes 最大字节数 + * @return 内容引用 + */ + @Override + public String put(InputStream inputStream, long maxBytes) { + SkillContentStage stage = stage(inputStream, maxBytes); + try { + return commit(stage); + } catch (RuntimeException exception) { + rollback(stage); + throw exception; + } + } + + /** + * 将二进制内容流式写入本机受控临时文件,完成全包校验前不增加正式引用。 + * + * @param inputStream 内容流 + * @param maxBytes 最大字节数 + * @return 暂存描述 + */ + @Override + public SkillContentStage stage(InputStream inputStream, long maxBytes) { + if (inputStream == null || maxBytes < 0) { + throw new BusinessException("Skill 内容暂存参数不正确"); + } + Path path = null; + try { + path = Files.createTempFile("easyflow-skill-stage-", ".bin"); + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + long size = 0; + try (OutputStream output = Files.newOutputStream(path, StandardOpenOption.TRUNCATE_EXISTING)) { + byte[] buffer = new byte[8192]; + int length; + while ((length = inputStream.read(buffer)) >= 0) { + if (length == 0) { + continue; + } + if (size > maxBytes - length) { + throw new BusinessException("Skill 二进制内容超过 " + maxBytes + " 字节限制"); + } + size += length; + digest.update(buffer, 0, length); + output.write(buffer, 0, length); + } + } + String hash = java.util.HexFormat.of().formatHex(digest.digest()); + return new SkillContentStage(path.toAbsolutePath().toString(), "sha256:" + hash, hash, size, false); + } catch (BusinessException exception) { + deleteStageQuietly(path); + throw exception; + } catch (Exception exception) { + deleteStageQuietly(path); + throw new BusinessException(500, 500, "暂存 Skill 二进制内容失败", exception); + } + } + + /** + * 提交一个已校验的临时内容并取得正式引用。 + * + * @param stage 暂存描述 + * @return 正式内容引用 + */ + @Override + public String commit(SkillContentStage stage) { + return commitStage(stage, DEFAULT_MEDIA_TYPE); + } + + /** + * 以指定媒体类型提交一个已校验的临时内容。 + * + * @param stage 暂存描述 + * @param mediaType 内容媒体类型 + * @return 正式内容引用 + */ + private String commitStage(SkillContentStage stage, String mediaType) { + Path path = requireStagePath(stage); + try { + validateStage(stage, path); + String normalizedMediaType = normalizeMediaType(mediaType); + PathMultipartFile file = new PathMultipartFile( + path, stage.getContentRef() + ".bin", normalizedMediaType); + String contentRef = executeRequired(() -> + putKnownHash(file, stage.getContentRef(), stage.getSize(), normalizedMediaType)); + deleteStageQuietly(path); + return contentRef; + } catch (RuntimeException exception) { + deleteStageQuietly(path); + throw exception; + } + } + + /** + * 回滚尚未提交的临时内容。 + * + * @param stage 暂存描述 + */ + @Override + public void rollback(SkillContentStage stage) { + if (stage != null) { + deleteStageQuietly(requireStagePath(stage)); + } + } + + /** + * 保存上传文件并取得一份引用,计算 hash 时使用输入流,不创建额外全量字节副本。 + * + * @param file 上传文件 + * @param mediaType 媒体类型 + * @return sha256 内容引用 + */ + public String put(MultipartFile file, String mediaType) { + if (file == null || file.isEmpty()) { + throw new BusinessException("Skill 资源文件不能为空"); + } + try (InputStream inputStream = file.getInputStream()) { + SkillContentStage stage = stage(inputStream, file.getSize()); + return commitStage(stage, mediaType); + } catch (IOException exception) { + throw new BusinessException(500, 500, "读取 Skill 资源文件失败", exception); + } + } + + /** + * 为已有内容增加一份持有引用。 + * + * @param contentRef 内容引用 + */ + @Override + public void retain(String contentRef) { + executeRequired(() -> { + if (contentRef == null || contentRef.isBlank() || skillContentMapper.retain(contentRef) != 1) { + throw new BusinessException(404, 404, "Skill 二进制内容不存在"); + } + return null; + }); + } + + /** + * 释放一份内容引用,最后一个持有者释放后删除物理文件。 + * + * @param contentRef 内容引用 + */ + @Override + public void release(String contentRef) { + if (contentRef == null || contentRef.isBlank()) { + return; + } + executeRequired(() -> { + releaseInTransaction(contentRef); + return null; + }); + } + + /** + * 打开内容读取流。 + * + * @param contentRef 内容引用 + * @return 内容流,调用方负责关闭 */ @Override public InputStream open(String contentRef) { - SkillAssetContent content = requireContent(contentRef); + SkillContent content = requireContent(contentRef); try { return fileStorageService.readStream(content.getFilePath()); - } catch (IOException e) { - throw new BusinessException("读取 Skill asset 失败"); + } catch (IOException exception) { + throw new BusinessException(500, 500, "读取 Skill 二进制内容失败", exception); } } /** - * {@inheritDoc} + * 读取全部内容。仅为旧版 M18 接口兼容保留,大文件路径应使用 {@link #open(String)}。 + * + * @param contentRef 内容引用 + * @return 内容字节 */ @Override public byte[] readAllBytes(String contentRef) { try (InputStream inputStream = open(contentRef)) { return inputStream.readAllBytes(); - } catch (IOException e) { - throw new BusinessException("读取 Skill asset 失败"); + } catch (IOException exception) { + throw new BusinessException(500, 500, "读取 Skill 二进制内容失败", exception); } } /** - * {@inheritDoc} + * 将内容流式复制到目标输出流。 + * + * @param contentRef 内容引用 + * @param outputStream 目标输出流 + */ + public void transferTo(String contentRef, OutputStream outputStream) { + try (InputStream inputStream = open(contentRef)) { + inputStream.transferTo(outputStream); + } catch (IOException exception) { + throw new BusinessException(500, 500, "输出 Skill 二进制内容失败", exception); + } + } + + /** + * 判断内容是否存在。 + * + * @param contentRef 内容引用 + * @return 存在时为 true */ @Override public boolean exists(String contentRef) { - if (contentRef == null || contentRef.isBlank()) { - return false; - } - return skillAssetContentService.count(QueryWrapper.create().eq(SkillAssetContent::getContentRef, contentRef)) > 0; + return contentRef != null && !contentRef.isBlank() && skillContentMapper.countVisible(contentRef) > 0; } - private SkillAssetContent requireContent(String contentRef) { - SkillAssetContent content = skillAssetContentService.getById(contentRef); - if (content == null) { - throw new BusinessException("Skill asset 内容不存在"); + /** + * 在当前事务中抢占或复用指定 hash 的正式内容引用。 + * + * @param file 待保存文件 + * @param contentRef 内容引用 + * @param size 内容大小 + * @param mediaType 媒体类型 + * @return 正式内容引用 + */ + private String putKnownHash(MultipartFile file, String contentRef, long size, String mediaType) { + validateContentRef(contentRef); + if (file == null || size < 0) { + throw new BusinessException("Skill 内容写入参数不正确"); + } + String hex = contentRef.substring("sha256:".length()); + String storagePath = expectedStoragePath(hex); + String filename = expectedStorageFilename(hex); + FileStorageWriteHandle handle = fileStorageService.prepareRecoverableWrite(storagePath, filename); + validateDeterministicHandle(handle, contentRef); + String locator = handle.encodeLocator(); + String reservationToken = java.util.UUID.randomUUID().toString(); + int reserved; + boolean duplicateIntent = false; + try { + reserved = executeCleanupTransaction(() -> writeIntentMapper.reserve( + contentRef, reservationToken, hex, locator, mediaType, size)); + } catch (DuplicateKeyException duplicateException) { + reserved = 0; + duplicateIntent = true; + } + if (reserved != 1) { + if (!duplicateIntent) { + throw new BusinessException(500, 500, "创建 Skill 内容写入意图失败"); + } + if (skillContentMapper.retainMatching(contentRef, size) == 1) { + return contentRef; + } + throw conflictingContentException(skillContentMapper.selectForUpdate(contentRef), size); + } + + // 独立意图提交后才触碰内容索引,避免外层缺失主键 gap lock 与内层预留等待形成等待环。 + if (skillContentMapper.retainMatching(contentRef, size) == 1) { + if (writeIntentMapper.deleteIfActiveExists(contentRef, reservationToken) != 1) { + throw new BusinessException(500, 500, "复用 Skill 内容后删除写入意图失败"); + } + return contentRef; + } + + SkillContent existing = skillContentMapper.selectForUpdate(contentRef); + if (existing != null) { + if (isReleasedLegacyContent(existing, contentRef, size)) { + try { + verifyLegacyPhysicalContent(existing, contentRef, size); + } catch (RuntimeException exception) { + discardPendingIntent(contentRef, reservationToken, exception); + throw exception; + } + if (skillContentMapper.resurrectVerifiedLegacy( + contentRef, hex, existing.getFilePath(), size) != 1) { + BusinessException exception = new BusinessException( + 503, 5031, "Skill 旧版内容状态已变化,请重试"); + discardPendingIntent(contentRef, reservationToken, exception); + throw exception; + } + if (writeIntentMapper.deleteIfActiveExists(contentRef, reservationToken) != 1) { + throw new BusinessException(500, 500, "恢复 Skill 旧版内容后删除写入意图失败"); + } + return contentRef; + } + BusinessException exception = conflictingContentException(existing, size); + discardPendingIntent(contentRef, reservationToken, exception); + throw exception; + } + + if (writeIntentMapper.claimForWrite(contentRef, reservationToken) != 1) { + throw new BusinessException(503, 5031, "Skill 内容写入意图状态已变化,请重试"); + } + try { + FileStorageWriteResult result = fileStorageService.saveRecoverable(file, handle); + if (!locator.equals(result.getLocator())) { + throw new BusinessException(500, 500, "Skill 文件存储返回了不一致的恢复定位符"); + } + if (skillContentMapper.insertActive( + contentRef, hex, result.getUrl(), locator, mediaType, size) != 1) { + throw new BusinessException(500, 500, "完成 Skill 内容索引写入失败"); + } + if (writeIntentMapper.deleteIfActiveExists(contentRef, reservationToken) != 1) { + throw new BusinessException(500, 500, "完成 Skill 内容写入意图提交失败"); + } + return contentRef; + } catch (DuplicateKeyException exception) { + throw new BusinessException( + 503, 5031, "Skill 内容索引发生并发冲突,请重试", exception); + } + } + + /** + * 为 hash 冲突、释放中或状态不完整的内容索引构造明确异常。 + * + * @param existing 当前读取得的内容索引,可为 null + * @param expectedSize 本次内容大小 + * @return 对应索引状态的业务异常 + */ + private BusinessException conflictingContentException(SkillContent existing, long expectedSize) { + if (existing == null) { + return new BusinessException(503, 5031, "Skill 内容正在写入或清理,请稍后重试"); + } + if (existing.getSize() == null || existing.getSize() != expectedSize) { + return new BusinessException(500, 500, "Skill 内容 hash 冲突或索引大小不一致"); + } + if (existing.getStorageLocator() == null && existing.getRefCount() != null + && existing.getRefCount() == 0) { + return new BusinessException(500, 500, + "Skill 旧版内容无法完成物理校验,请核对原存储并执行 storage_locator 迁移"); + } + return new BusinessException(503, 5031, "Skill 内容正在写入或释放,请稍后重试"); + } + + /** + * 判断内容是否为允许经过物理校验后恢复的迁移前零引用索引。 + * + * @param content 当前读锁定的内容索引 + * @param contentRef 预期内容引用 + * @param expectedSize 预期内容大小 + * @return 仅缺少 locator 的完整旧版零引用内容返回 true + */ + private boolean isReleasedLegacyContent(SkillContent content, String contentRef, long expectedSize) { + return content != null && content.getRefCount() != null && content.getRefCount() == 0 + && content.getStorageLocator() == null + && content.getFilePath() != null && !content.getFilePath().isBlank() + && !content.getFilePath().startsWith(PENDING_PREFIX) + && content.getSize() != null && content.getSize() == expectedSize + && contentRef.equals("sha256:" + content.getContentHash()); + } + + /** + * 从旧版读取路径重新校验对象大小与完整 SHA-256,确认同 hash 内容仍可安全复用。 + * + * @param content 旧版内容索引 + * @param contentRef 预期内容引用 + * @param expectedSize 预期内容大小 + * @throws BusinessException 对象不可读、大小变化或哈希不一致时抛出 + */ + private void verifyLegacyPhysicalContent(SkillContent content, String contentRef, long expectedSize) { + try { + long actualSize = fileStorageService.getFileSize(content.getFilePath()); + if (actualSize != expectedSize) { + throw new BusinessException(500, 500, "Skill 旧版内容物理大小与索引不一致"); + } + try (InputStream inputStream = fileStorageService.readStream(content.getFilePath())) { + if (!contentRef.substring("sha256:".length()).equals(sha256Hex(inputStream))) { + throw new BusinessException(500, 500, "Skill 旧版内容物理哈希与索引不一致"); + } + } + } catch (BusinessException exception) { + throw exception; + } catch (IOException | RuntimeException exception) { + throw new BusinessException(500, 500, + "读取 Skill 旧版内容失败,请核对原存储并执行 storage_locator 迁移", exception); + } + } + + /** + * 在独立事务中丢弃确认不会对应任何新物理对象的 PENDING 意图。 + * + * @param contentRef 内容引用 + * @param reservationToken 当前预留令牌 + * @param primaryException 即将返回给调用方的主异常 + */ + private void discardPendingIntent( + String contentRef, String reservationToken, RuntimeException primaryException) { + try { + int deleted = executeCleanupTransaction(() -> + writeIntentMapper.deletePending(contentRef, reservationToken)); + if (deleted != 1) { + LOG.warn("未能立即删除无物理写入的 Skill PENDING 意图,等待定时清理,contentRef={}", + contentRef); + } + } catch (RuntimeException cleanupException) { + primaryException.addSuppressed(cleanupException); + } + } + + /** + * 在当前事务中原子释放一份引用,并处理 retain/release 并发更新。 + * + * @param contentRef 内容引用 + */ + private void releaseInTransaction(String contentRef) { + for (int attempt = 0; attempt < RELEASE_RETRY_LIMIT; attempt++) { + if (skillContentMapper.releaseShared(contentRef) == 1) { + return; + } + SkillContent content = skillContentMapper.selectForUpdate(contentRef); + if (!isVisible(content)) { + return; + } + if (skillContentMapper.markReleased( + contentRef, content.getFilePath(), content.getStorageLocator()) == 1) { + content.setRefCount(0); + if (content.getStorageLocator() == null || content.getStorageLocator().isBlank()) { + LOG.warn("Skill 旧版内容缺少稳定定位符,保留零引用索引等待人工迁移,contentRef={}", + contentRef); + } else { + scheduleReleasedContentPurge(content); + } + return; + } + } + throw new BusinessException(503, 5031, "Skill 内容引用计数并发更新失败,请重试"); + } + + /** + * 获取当前可读的正式内容索引。 + * + * @param contentRef 内容引用 + * @return 可读内容索引 + */ + private SkillContent requireContent(String contentRef) { + if (contentRef == null || contentRef.isBlank()) { + throw new BusinessException("Skill 内容引用不能为空"); + } + SkillContent content = skillContentMapper.selectOneById(contentRef); + if (!isVisible(content)) { + throw new BusinessException(404, 404, "Skill 二进制内容不存在"); } return content; } - private static class ByteArrayMultipartFile implements MultipartFile { + /** + * 判断索引是否已完成写入且仍有引用。 + * + * @param content 内容索引 + * @return 可见时为 true + */ + private boolean isVisible(SkillContent content) { + return content != null && content.getRefCount() != null && content.getRefCount() > 0 + && content.getFilePath() != null && !content.getFilePath().isBlank() + && !content.getFilePath().startsWith(PENDING_PREFIX) + && (content.getStorageLocator() == null || !content.getStorageLocator().isBlank()); + } + + /** + * 校验 sha256 内容引用格式。 + * + * @param contentRef 内容引用 + */ + private void validateContentRef(String contentRef) { + if (contentRef == null || !CONTENT_REF_PATTERN.matcher(contentRef).matches()) { + throw new BusinessException("Skill 内容引用格式不正确"); + } + } + + /** + * 返回内容哈希对应的稳定相对目录。 + * + * @param contentHash 小写 SHA-256 十六进制值 + * @return 不带结尾斜杠的相对目录 + */ + private String expectedStoragePath(String contentHash) { + return CONTENT_PATH_PREFIX + contentHash.substring(0, 2); + } + + /** + * 返回内容哈希对应的稳定文件名。 + * + * @param contentHash 小写 SHA-256 十六进制值 + * @return 固定二进制文件名 + */ + private String expectedStorageFilename(String contentHash) { + return contentHash + ".bin"; + } + + /** + * 校验恢复句柄只指向当前内容哈希的确定性对象位置。 + * + * @param handle 待校验句柄 + * @param contentRef 内容引用 + */ + private void validateDeterministicHandle(FileStorageWriteHandle handle, String contentRef) { + validateContentRef(contentRef); + if (handle == null) { + throw new BusinessException(500, 500, "文件存储未返回可恢复写入句柄"); + } + String contentHash = contentRef.substring("sha256:".length()); + String expectedPath = expectedStoragePath(contentHash) + "/"; + if (!expectedPath.equals(handle.getPath()) + || !expectedStorageFilename(contentHash).equals(handle.getFilename())) { + throw new BusinessException(500, 500, "文件存储恢复句柄与内容哈希不一致"); + } + } + + /** + * 通过 REQUIRED 传播显式执行事务,覆盖类内方法调用场景。 + * + * @param action 事务动作 + * @param 返回类型 + * @return 动作结果 + */ + private T executeRequired(Supplier action) { + return requiredTransactionTemplate.execute(status -> action.get()); + } + + /** + * 在独立事务中执行写入意图预留或清理动作。 + * + * @param action 独立事务动作 + * @param 返回类型 + * @return 动作结果 + */ + private T executeCleanupTransaction(Supplier action) { + return cleanupTransactionTemplate.execute(status -> action.get()); + } + + /** + * 规范化内容媒体类型。 + * + * @param mediaType 原媒体类型 + * @return 非空媒体类型 + */ + private String normalizeMediaType(String mediaType) { + if (mediaType == null || mediaType.isBlank()) { + return DEFAULT_MEDIA_TYPE; + } + String normalized = mediaType.trim(); + if (normalized.length() > 128 || normalized.codePoints().anyMatch(Character::isISOControl)) { + throw new BusinessException("Skill 内容媒体类型不合法或超过长度限制"); + } + return normalized; + } + + /** + * 流式计算 SHA-256 十六进制摘要。 + * + * @param inputStream 内容流 + * @return 十六进制摘要 + * @throws IOException 读取内容失败 + */ + private String sha256Hex(InputStream inputStream) throws IOException { + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[8192]; + int length; + while ((length = inputStream.read(buffer)) >= 0) { + digest.update(buffer, 0, length); + } + return java.util.HexFormat.of().formatHex(digest.digest()); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 算法不可用", exception); + } + } + + /** + * 重新校验暂存描述、文件大小与内容摘要。 + * + * @param stage 暂存描述 + * @param path 受控暂存路径 + */ + private void validateStage(SkillContentStage stage, Path path) { + validateContentRef(stage.getContentRef()); + if (stage.isAlreadyCommitted() || stage.getSize() < 0 + || !stage.getContentRef().substring("sha256:".length()).equals(stage.getContentHash())) { + throw new BusinessException(500, 500, "Skill 内容暂存描述不一致"); + } + try { + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.size(path) != stage.getSize()) { + throw new BusinessException(500, 500, "Skill 暂存内容不存在或大小已变化"); + } + try (InputStream inputStream = Files.newInputStream(path)) { + String actualHash = sha256Hex(inputStream); + if (!stage.getContentHash().equals(actualHash)) { + throw new BusinessException(500, 500, "Skill 暂存内容校验失败"); + } + } + } catch (IOException exception) { + throw new BusinessException(500, 500, "读取 Skill 暂存内容失败", exception); + } + } + + /** + * 校验并返回位于系统临时目录下的受控暂存路径。 + * + * @param stage 暂存描述 + * @return 规范化暂存路径 + */ + private Path requireStagePath(SkillContentStage stage) { + if (stage == null || stage.getStageId() == null) { + throw new BusinessException("Skill 内容暂存描述不能为空"); + } + Path tempRoot = Path.of(System.getProperty("java.io.tmpdir")).toAbsolutePath().normalize(); + Path path = Path.of(stage.getStageId()).toAbsolutePath().normalize(); + if (!path.startsWith(tempRoot) || !path.getFileName().toString().startsWith("easyflow-skill-stage-")) { + throw new BusinessException(500, 500, "Skill 内容暂存路径不合法"); + } + return path; + } + + /** + * 尽力删除已使用或回滚的暂存文件。 + * + * @param path 暂存路径 + */ + private void deleteStageQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // 临时文件清理由系统临时目录兜底;主异常优先返回。 + } + } + + /** + * 定时清理过期占位与待重试删除的零引用内容。 + */ + @Scheduled( + fixedDelayString = "${easyflow.skill.content-cleanup-delay-ms:300000}", + initialDelayString = "${easyflow.skill.content-cleanup-delay-ms:300000}" + ) + @DistributedScheduledLock(key = "easyflow:schedule:skill-content:cleanup", leaseSeconds = 600L) + public void cleanupStaleContent() { + cleanupStaleContent(new Date(), cleanupBatchSize); + } + + /** + * 按给定基准时间执行一轮内容清理。 + * + * @param now 清理基准时间 + * @param batchSize 单类记录最大处理数量 + */ + void cleanupStaleContent(Date now, int batchSize) { + Date safeNow = now == null ? new Date() : now; + int safeBatchSize = Math.max(1, Math.min(batchSize, 1_000)); + Date pendingCutoff = subtractSafely(safeNow, pendingTtlMs); + Date releasedCutoff = subtractSafely(safeNow, releaseRetryDelayMs); + try { + List intents = writeIntentMapper.findStale(pendingCutoff, safeBatchSize); + if (intents != null) { + for (SkillContentWriteIntent intent : intents) { + cleanupWriteIntent(intent, pendingCutoff); + } + } + } catch (RuntimeException exception) { + LOG.error("扫描过期 Skill 内容写入意图失败", exception); + } + try { + List pendingContents = skillContentMapper.findStalePending(pendingCutoff, safeBatchSize); + if (pendingContents != null) { + for (SkillContent content : pendingContents) { + if (content != null && content.getContentRef() != null && content.getFilePath() != null) { + skillContentMapper.deleteStalePending( + content.getContentRef(), content.getFilePath(), pendingCutoff); + } + } + } + } catch (RuntimeException exception) { + LOG.error("清理过期 Skill 内容占位失败", exception); + } + try { + List releasedContents = + skillContentMapper.findReleasedBefore(releasedCutoff, safeBatchSize); + if (releasedContents != null) { + for (SkillContent content : releasedContents) { + purgeReleasedContent(content); + } + } + } catch (RuntimeException exception) { + LOG.error("扫描零引用 Skill 内容失败", exception); + } + } + + /** + * 清理一个已经超过保留期限的写入意图。 + * + *

先在独立事务中确认是否已有正式活动内容;没有活动内容时通过令牌与状态 CAS 取得清理权, + * 再执行事务外物理 I/O。删除失败时保留 CLEANING 意图,供下一轮幂等重试。

+ * + * @param intent 查询到的写入意图 + * @param cutoff 本轮过期截止时间 + */ + private void cleanupWriteIntent(SkillContentWriteIntent intent, Date cutoff) { + if (!isValidIntentIdentity(intent) || cutoff == null) { + return; + } + String contentRef = intent.getContentRef(); + String reservationToken = intent.getReservationToken(); + try { + int activeCleanup = executeCleanupTransaction(() -> + writeIntentMapper.deleteIfActiveExists(contentRef, reservationToken)); + if (activeCleanup == 1) { + return; + } + int claimed = executeCleanupTransaction(() -> writeIntentMapper.claimForCleanup( + contentRef, reservationToken, intent.getState(), cutoff)); + if (claimed != 1) { + return; + } + FileStorageWriteHandle handle = decodeAndValidateHandle( + intent.getStorageLocator(), contentRef, intent.getContentHash()); + fileStorageService.deleteRecoverable(handle); + if (fileStorageService.existsRecoverable(handle)) { + throw new IllegalStateException("清理后 Skill 写入意图物理对象仍存在"); + } + int deleted = executeCleanupTransaction(() -> + writeIntentMapper.deleteClaimed(contentRef, reservationToken)); + if (deleted != 1) { + LOG.warn("Skill 内容写入意图物理对象已清理,但意图状态已变化,contentRef={}", contentRef); + } + } catch (RuntimeException exception) { + LOG.error("清理 Skill 内容写入意图失败,contentRef={}", contentRef, exception); + } + } + + /** + * 校验写入意图包含安全、彼此一致的主键、令牌、哈希与状态。 + * + * @param intent 写入意图 + * @return 可进入清理状态机时返回 true + */ + private boolean isValidIntentIdentity(SkillContentWriteIntent intent) { + if (intent == null || intent.getContentRef() == null || intent.getReservationToken() == null + || intent.getReservationToken().isBlank() || intent.getContentHash() == null + || intent.getStorageLocator() == null || intent.getStorageLocator().isBlank() + || intent.getSize() == null || intent.getSize() < 0 + || !(INTENT_PENDING.equals(intent.getState()) + || INTENT_WRITING.equals(intent.getState()) + || INTENT_CLEANING.equals(intent.getState()))) { + LOG.error("发现结构不完整的 Skill 内容写入意图,保留记录等待人工核查"); + return false; + } + if (!CONTENT_REF_PATTERN.matcher(intent.getContentRef()).matches() + || !intent.getContentRef().equals("sha256:" + intent.getContentHash())) { + LOG.error("发现哈希不一致的 Skill 内容写入意图,保留记录等待人工核查"); + return false; + } + return true; + } + + /** + * 解码恢复定位符,并验证其确定性对象路径与内容哈希一致。 + * + * @param locator 稳定恢复定位符 + * @param contentRef 内容引用 + * @param contentHash 内容哈希 + * @return 通过校验的恢复句柄 + */ + private FileStorageWriteHandle decodeAndValidateHandle( + String locator, String contentRef, String contentHash) { + validateContentRef(contentRef); + String expectedHash = contentRef.substring("sha256:".length()); + if (contentHash == null || !expectedHash.equals(contentHash)) { + throw new IllegalStateException("Skill 内容索引哈希与内容引用不一致"); + } + FileStorageWriteHandle handle = FileStorageWriteHandle.decodeLocator(locator); + validateDeterministicHandle(handle, contentRef); + return handle; + } + + /** + * 将最后引用的物理清理延迟到当前事务提交之后。 + * + * @param content 已标记为零引用的内容 + */ + private void scheduleReleasedContentPurge(SkillContent content) { + Runnable purge = () -> purgeReleasedContent(content); + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + purge.run(); + } + }); + } else { + LOG.warn("当前事务未启用同步回调,Skill 零引用内容将由定时任务延迟清理,contentRef={}", + content.getContentRef()); + } + } + + /** + * 删除零引用物理内容,并在独立事务中条件删除索引。 + * + * @param content 零引用内容 + */ + private void purgeReleasedContent(SkillContent content) { + if (content == null || content.getContentRef() == null || content.getFilePath() == null + || content.getFilePath().startsWith(PENDING_PREFIX) + || content.getStorageLocator() == null || content.getStorageLocator().isBlank() + || content.getRefCount() == null || content.getRefCount() != 0) { + return; + } + try { + FileStorageWriteHandle handle = decodeAndValidateHandle( + content.getStorageLocator(), content.getContentRef(), content.getContentHash()); + fileStorageService.deleteRecoverable(handle); + if (fileStorageService.existsRecoverable(handle)) { + throw new IllegalStateException("删除后 Skill 内容物理对象仍存在"); + } + } catch (RuntimeException exception) { + LOG.error("清理已释放 Skill 内容失败,contentRef={}", content.getContentRef(), exception); + return; + } + try { + cleanupTransactionTemplate.executeWithoutResult(status -> + skillContentMapper.deleteReleased( + content.getContentRef(), content.getFilePath(), content.getStorageLocator())); + } catch (RuntimeException exception) { + LOG.error("删除已释放 Skill 内容索引失败,contentRef={}", content.getContentRef(), exception); + } + } + + /** + * 避免异常配置导致时间减法溢出。 + * + * @param base 基准时间 + * @param durationMillis 回溯毫秒数 + * @return 截止时间 + */ + private Date subtractSafely(Date base, long durationMillis) { + try { + return new Date(Math.subtractExact(base.getTime(), Math.max(0L, durationMillis))); + } catch (ArithmeticException exception) { + return new Date(Long.MIN_VALUE); + } + } + + /** + * 基于字节数组的 MultipartFile 适配器。 + */ + private static final class ByteArrayMultipartFile implements MultipartFile { private final byte[] bytes; private final String filename; private final String contentType; private ByteArrayMultipartFile(byte[] bytes, String filename, String contentType) { - this.bytes = bytes == null ? new byte[0] : bytes; + this.bytes = bytes; this.filename = filename; this.contentType = contentType; } @@ -133,6 +974,31 @@ public class DBSkillContentStore implements SkillContentStore { @Override public long getSize() { return bytes.length; } @Override public byte[] getBytes() { return bytes; } @Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); } - @Override public void transferTo(File dest) throws IOException { org.springframework.util.FileCopyUtils.copy(bytes, dest); } + @Override public void transferTo(File destination) throws IOException { org.springframework.util.FileCopyUtils.copy(bytes, destination); } + } + + /** + * 基于临时路径的流式 MultipartFile 适配器。 + */ + private static final class PathMultipartFile implements MultipartFile { + + private final Path path; + private final String filename; + private final String contentType; + + private PathMultipartFile(Path path, String filename, String contentType) { + this.path = path; + this.filename = filename; + this.contentType = contentType; + } + + @Override public String getName() { return "file"; } + @Override public String getOriginalFilename() { return filename; } + @Override public String getContentType() { return contentType; } + @Override public boolean isEmpty() { return getSize() == 0; } + @Override public long getSize() { try { return Files.size(path); } catch (IOException exception) { throw new BusinessException(500, 500, "读取 Skill 临时内容大小失败", exception); } } + @Override public byte[] getBytes() throws IOException { return Files.readAllBytes(path); } + @Override public InputStream getInputStream() throws IOException { return Files.newInputStream(path); } + @Override public void transferTo(File destination) throws IOException { Files.copy(path, destination.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); } } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillModelConverter.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillModelConverter.java index d4e20189..a60ff4bb 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillModelConverter.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillModelConverter.java @@ -6,6 +6,7 @@ import com.easyagents.skill.model.SkillScriptLanguage; import tech.easyflow.skill.entity.Skill; import tech.easyflow.skill.entity.SkillAsset; import tech.easyflow.skill.entity.SkillReference; +import tech.easyflow.skill.entity.SkillResource; import tech.easyflow.skill.entity.SkillScript; import java.math.BigInteger; @@ -26,13 +27,14 @@ public final class SkillModelConverter { * @return easy-agents-skill 聚合 */ public static com.easyagents.skill.model.Skill toAgentSkill(Skill skill) { - return SkillFactory.create( + com.easyagents.skill.model.Skill result = SkillFactory.createWithResources( String.valueOf(skill.getId()), skill.getSkillContent(), - toAgentReferences(skill.getReferences()), - toAgentScripts(skill.getScripts()), - toAgentAssets(skill.getAssets()) + toAgentResources(skill.getResources() == null + ? SkillResourceModelAdapter.toResources(skill) : skill.getResources()) ); + result.setPackageRoot(skill.getName()); + return result; } /** @@ -48,6 +50,7 @@ public final class SkillModelConverter { skill.setDescription(imported.getDescription()); skill.setMetadataJson(imported.getMetadata().getValues()); skill.setSkillContent(imported.getSkillContent()); + skill.setResources(imported.getResources().stream().map(SkillModelConverter::fromAgentResource).toList()); skill.setReferences(imported.getReferences().stream() .map(item -> fromAgentReference(null, null, item)) .toList()); @@ -64,61 +67,46 @@ public final class SkillModelConverter { } /** - * 转换 reference 列表。 + * 转换通用资源列表到 M18 标准模型。 * - * @param references reference 实体 - * @return easy-agents-skill reference + * @param resources EasyFlow 通用资源 + * @return M18 通用资源 */ - public static List toAgentReferences(List references) { - return references == null ? List.of() : references.stream().map(item -> { - com.easyagents.skill.model.SkillReference target = new com.easyagents.skill.model.SkillReference(); - target.setPath(item.getPath()); - target.setName(item.getName()); - target.setContent(item.getContent()); - target.setContentHash(item.getContentHash()); - target.setSize(item.getSize() == null ? 0L : item.getSize()); - target.setMetadata(new SkillMetadata(item.getMetadataJson())); + public static List toAgentResources(List resources) { + return resources == null ? List.of() : resources.stream().map(source -> { + com.easyagents.skill.model.SkillResource target = new com.easyagents.skill.model.SkillResource(); + target.setPath(source.getNormalizedPath() == null ? source.getPath() : source.getNormalizedPath()); + target.setKind(parseResourceKind(source.getKind())); + target.setMediaType(source.getMediaType()); + target.setTextContent(source.getTextContent()); + target.setContentRef(source.getContentRef()); + target.setContentHash(source.getContentHash()); + target.setSize(source.getSize() == null ? 0L : source.getSize()); + target.setMetadata(new SkillMetadata(source.getMetadataJson())); return target; }).toList(); } /** - * 转换 script 列表。 + * 转换 M18 通用资源到 EasyFlow 持久化模型。 * - * @param scripts script 实体 - * @return easy-agents-skill script + * @param source M18 通用资源 + * @return EasyFlow 通用资源 */ - public static List toAgentScripts(List scripts) { - return scripts == null ? List.of() : scripts.stream().map(item -> { - com.easyagents.skill.model.SkillScript target = new com.easyagents.skill.model.SkillScript(); - target.setPath(item.getPath()); - target.setLanguage(parseLanguage(item.getLanguage())); - target.setContent(item.getContent()); - target.setContentHash(item.getContentHash()); - target.setSize(item.getSize() == null ? 0L : item.getSize()); - target.setMetadata(new SkillMetadata(item.getMetadataJson())); - return target; - }).toList(); - } - - /** - * 转换 asset 列表。 - * - * @param assets asset 实体 - * @return easy-agents-skill asset - */ - public static List toAgentAssets(List assets) { - return assets == null ? List.of() : assets.stream().map(item -> { - com.easyagents.skill.model.SkillAsset target = new com.easyagents.skill.model.SkillAsset(); - target.setPath(item.getPath()); - target.setName(item.getName()); - target.setMediaType(item.getMediaType()); - target.setContentRef(item.getContentRef()); - target.setContentHash(item.getContentHash()); - target.setSize(item.getSize() == null ? 0L : item.getSize()); - target.setMetadata(new SkillMetadata(item.getMetadataJson())); - return target; - }).toList(); + public static SkillResource fromAgentResource(com.easyagents.skill.model.SkillResource source) { + SkillResource target = new SkillResource(); + target.setPath(source.getPath()); + target.setNormalizedPath(source.getPath()); + target.setKind(source.getKind().name()); + target.setLanguage(resolveResourceLanguage(source)); + target.setMediaType(source.getMediaType()); + target.setIsText(source.isText()); + target.setTextContent(source.getTextContent()); + target.setContentRef(source.getContentRef()); + target.setContentHash(source.getContentHash()); + target.setSize(source.getSize()); + target.setMetadataJson(source.getMetadata().getValues()); + return target; } /** @@ -188,14 +176,22 @@ public final class SkillModelConverter { return target; } - private static SkillScriptLanguage parseLanguage(String language) { - if (language == null || language.isBlank()) { - return SkillScriptLanguage.UNKNOWN; + private static com.easyagents.skill.model.SkillResourceKind parseResourceKind(String kind) { + if (kind == null || kind.isBlank()) { + return com.easyagents.skill.model.SkillResourceKind.OTHER; } try { - return SkillScriptLanguage.valueOf(language); + return com.easyagents.skill.model.SkillResourceKind.valueOf(kind); } catch (IllegalArgumentException ignored) { - return SkillScriptLanguage.UNKNOWN; + return com.easyagents.skill.model.SkillResourceKind.OTHER; } } + + private static String resolveResourceLanguage(com.easyagents.skill.model.SkillResource resource) { + if (resource.getKind() == com.easyagents.skill.model.SkillResourceKind.SCRIPT) { + SkillScriptLanguage language = SkillScriptLanguage.fromPath(resource.getPath()); + return language == SkillScriptLanguage.UNKNOWN ? null : language.name(); + } + return resource.getKind() == com.easyagents.skill.model.SkillResourceKind.REFERENCE ? "MARKDOWN" : null; + } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillResourceModelAdapter.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillResourceModelAdapter.java new file mode 100644 index 00000000..9a9f5014 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillResourceModelAdapter.java @@ -0,0 +1,162 @@ +package tech.easyflow.skill.support; + +import com.easyagents.skill.model.SkillResourceKind; +import com.easyagents.skill.model.SkillScriptLanguage; +import com.easyagents.skill.util.SkillPaths; +import com.easyagents.skill.util.SkillResources; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillAsset; +import tech.easyflow.skill.entity.SkillReference; +import tech.easyflow.skill.entity.SkillResource; +import tech.easyflow.skill.entity.SkillScript; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; + +/** + * 通用 Skill 资源与试验版三类资源视图之间的兼容适配器。 + */ +public final class SkillResourceModelAdapter { + + private SkillResourceModelAdapter() { + } + + /** + * 将 Skill 入参中的通用资源或旧资源视图归一化为通用资源。 + * + * @param skill Skill 聚合 + * @return 通用资源列表 + */ + public static List toResources(Skill skill) { + if (skill.getResources() != null) { + return new ArrayList<>(skill.getResources()); + } + List resources = new ArrayList<>(); + if (skill.getReferences() != null) { + for (SkillReference reference : skill.getReferences()) { + SkillResource resource = textResource(reference.getPath(), SkillResourceKind.REFERENCE, + "MARKDOWN", "text/markdown", reference.getContent(), reference.getContentHash(), + reference.getSize(), reference.getMetadataJson()); + resources.add(resource); + } + } + if (skill.getScripts() != null) { + for (SkillScript script : skill.getScripts()) { + SkillResource resource = textResource(script.getPath(), SkillResourceKind.SCRIPT, + script.getLanguage(), "text/plain", script.getContent(), script.getContentHash(), + script.getSize(), script.getMetadataJson()); + resources.add(resource); + } + } + if (skill.getAssets() != null) { + for (SkillAsset asset : skill.getAssets()) { + SkillResource resource = new SkillResource(); + resource.setPath(asset.getPath()); + resource.setNormalizedPath(SkillPaths.normalize(asset.getPath())); + resource.setKind(SkillResourceKind.ASSET.name()); + resource.setMediaType(asset.getMediaType()); + resource.setIsText(false); + resource.setContentRef(asset.getContentRef()); + resource.setContentHash(asset.getContentHash()); + resource.setSize(asset.getSize()); + resource.setMetadataJson(asset.getMetadataJson()); + resources.add(resource); + } + } + return resources; + } + + /** + * 根据通用资源回填旧版 reference/script/asset 只读兼容视图。 + * + * @param skill Skill 聚合 + * @param resources 通用资源 + */ + public static void fillCompatibilityViews(Skill skill, List resources) { + List references = new ArrayList<>(); + List scripts = new ArrayList<>(); + List assets = new ArrayList<>(); + for (SkillResource resource : resources == null ? List.of() : resources) { + SkillResourceKind kind = parseKind(resource.getKind(), resource.getNormalizedPath()); + if (kind == SkillResourceKind.REFERENCE) { + SkillReference reference = new SkillReference(); + reference.setId(resource.getId()); + reference.setTenantId(resource.getTenantId()); + reference.setSkillId(resource.getSkillId()); + reference.setPath(resource.getNormalizedPath()); + reference.setName(SkillPaths.fileName(resource.getNormalizedPath())); + reference.setContent(resource.getTextContent()); + reference.setContentHash(resource.getContentHash()); + reference.setSize(resource.getSize()); + reference.setMetadataJson(resource.getMetadataJson()); + references.add(reference); + } else if (kind == SkillResourceKind.SCRIPT) { + SkillScript script = new SkillScript(); + script.setId(resource.getId()); + script.setTenantId(resource.getTenantId()); + script.setSkillId(resource.getSkillId()); + script.setPath(resource.getNormalizedPath()); + script.setLanguage(resource.getLanguage()); + script.setContent(resource.getTextContent()); + script.setContentHash(resource.getContentHash()); + script.setSize(resource.getSize()); + script.setMetadataJson(resource.getMetadataJson()); + scripts.add(script); + } else if (!Boolean.TRUE.equals(resource.getIsText())) { + SkillAsset asset = new SkillAsset(); + asset.setId(resource.getId()); + asset.setTenantId(resource.getTenantId()); + asset.setSkillId(resource.getSkillId()); + asset.setPath(resource.getNormalizedPath()); + asset.setName(SkillPaths.fileName(resource.getNormalizedPath())); + asset.setMediaType(resource.getMediaType()); + asset.setContentRef(resource.getContentRef()); + asset.setContentHash(resource.getContentHash()); + asset.setSize(resource.getSize()); + asset.setMetadataJson(resource.getMetadataJson()); + assets.add(asset); + } + } + references.sort(Comparator.comparing(SkillReference::getPath)); + scripts.sort(Comparator.comparing(SkillScript::getPath)); + assets.sort(Comparator.comparing(SkillAsset::getPath)); + skill.setReferences(references); + skill.setScripts(scripts); + skill.setAssets(assets); + } + + private static SkillResource textResource(String path, + SkillResourceKind kind, + String language, + String mediaType, + String content, + String hash, + Long size, + java.util.Map metadata) { + SkillResource resource = new SkillResource(); + resource.setPath(path); + resource.setNormalizedPath(SkillPaths.normalize(path)); + resource.setKind(kind.name()); + resource.setLanguage(language); + resource.setMediaType(mediaType); + resource.setIsText(true); + resource.setTextContent(content); + resource.setContentHash(hash); + resource.setSize(size); + resource.setMetadataJson(metadata == null ? new LinkedHashMap<>() : metadata); + return resource; + } + + private static SkillResourceKind parseKind(String value, String path) { + if (value != null) { + try { + return SkillResourceKind.valueOf(value); + } catch (IllegalArgumentException ignored) { + // 旧数据或外部扩展类型按路径与文本属性安全降级。 + } + } + return SkillResources.classify(path); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/validation/SkillValidationIssue.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/validation/SkillValidationIssue.java new file mode 100644 index 00000000..47755985 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/validation/SkillValidationIssue.java @@ -0,0 +1,48 @@ +package tech.easyflow.skill.validation; + +/** + * Skill 包或能力配置的结构化校验问题。 + */ +public class SkillValidationIssue { + + private String severity; + private String code; + private String message; + private String path; + private Integer line; + private Integer column; + private String suggestion; + + /** + * 创建校验问题。 + * + * @param severity 严重级别 + * @param code 问题编码 + * @param message 可执行的错误说明 + * @param path 文件或配置路径 + * @return 校验问题 + */ + public static SkillValidationIssue of(String severity, String code, String message, String path) { + SkillValidationIssue issue = new SkillValidationIssue(); + issue.setSeverity(severity); + issue.setCode(code); + issue.setMessage(message); + issue.setPath(path); + return issue; + } + + public String getSeverity() { return severity; } + public void setSeverity(String severity) { this.severity = severity; } + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public Integer getLine() { return line; } + public void setLine(Integer line) { this.line = line; } + public Integer getColumn() { return column; } + public void setColumn(Integer column) { this.column = column; } + public String getSuggestion() { return suggestion; } + public void setSuggestion(String suggestion) { this.suggestion = suggestion; } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/validation/SkillValidationResult.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/validation/SkillValidationResult.java new file mode 100644 index 00000000..b341b65b --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/validation/SkillValidationResult.java @@ -0,0 +1,18 @@ +package tech.easyflow.skill.validation; + +import java.util.ArrayList; +import java.util.List; + +/** + * Skill 全量校验结果。 + */ +public class SkillValidationResult { + + private boolean valid; + private List issues = new ArrayList<>(); + + public boolean isValid() { return valid; } + public void setValid(boolean valid) { this.valid = valid; } + public List getIssues() { return issues; } + public void setIssues(List issues) { this.issues = issues == null ? new ArrayList<>() : new ArrayList<>(issues); } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityBindingServiceImplTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityBindingServiceImplTest.java new file mode 100644 index 00000000..7a6cac3d --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityBindingServiceImplTest.java @@ -0,0 +1,730 @@ +package tech.easyflow.skill.capability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import tech.easyflow.ai.permission.McpAccessPermissionChecker; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.mapper.SkillCapabilityBindingMapper; +import tech.easyflow.skill.mapper.SkillMapper; +import tech.easyflow.skill.validation.SkillValidationIssue; +import tech.easyflow.skill.validation.SkillValidationResult; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * {@link SkillCapabilityBindingServiceImpl} 能力命名、MCP 选择、安全配置和权限测试。 + */ +public class SkillCapabilityBindingServiceImplTest { + + private static final BigInteger SKILL_ID = BigInteger.valueOf(101); + + private SkillMapper skillMapper; + private SkillCapabilityTargetAccessService targetAccessService; + private McpAccessPermissionChecker mcpAccessPermissionChecker; + private ResourceAccessService resourceAccessService; + private SkillCapabilityBindingServiceImpl service; + private Skill skill; + private MockedStatic saToken; + + /** + * 初始化能力绑定服务及默认可用目标。 + */ + @Before + public void setUp() { + skillMapper = mock(SkillMapper.class); + targetAccessService = mock(SkillCapabilityTargetAccessService.class); + mcpAccessPermissionChecker = mock(McpAccessPermissionChecker.class); + resourceAccessService = mock(ResourceAccessService.class); + service = new SkillCapabilityBindingServiceImpl( + skillMapper, targetAccessService, mcpAccessPermissionChecker, + resourceAccessService, new ObjectMapper()); + skill = new Skill(); + skill.setId(SKILL_ID); + skill.setTenantId(BigInteger.ONE); + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(7)); + account.setTenantId(BigInteger.ONE); + saToken = mockStatic(SaTokenUtil.class); + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(skill); + when(targetAccessService.requireUsableTarget(any(SkillCapabilityBinding.class), anyBoolean())) + .thenReturn(target(List.of("alpha", "beta"))); + } + + /** + * 释放静态登录态 Mock。 + */ + @After + public void tearDown() { + saToken.close(); + } + + /** + * 验证非 MCP 能力的 runtimeName 按大小写不敏感规则判重。 + */ + @Test + public void duplicateRuntimeNamesAreRejectedCaseInsensitively() { + SkillCapabilityBinding first = binding("WORKFLOW", 1, "RunFlow"); + SkillCapabilityBinding second = binding("PLUGIN_ITEM", 2, "runflow"); + + SkillValidationResult result = service.validateBindings(SKILL_ID, List.of(first, second), false); + + assertFalse(result.isValid()); + assertTrue(hasIssue(result, "RUNTIME_NAME_DUPLICATE")); + } + + /** + * 验证 MCP SELECTED 工具会确定性去重、排序并固化最终工具名。 + */ + @Test + public void selectedMcpToolsAreDeduplicatedAndSorted() { + SkillCapabilityBinding binding = mcpBinding("demo", List.of("beta", "alpha", "alpha")); + + SkillValidationResult result = service.validateBindings(SKILL_ID, List.of(binding), true); + + assertTrue(result.getIssues().toString(), result.isValid()); + assertEquals(List.of("alpha", "beta"), binding.getSelectedToolNamesJson()); + assertEquals(List.of("alpha", "beta"), binding.getResolvedToolNames()); + } + + /** + * 验证保存 MCP 绑定会重新校验目标权限,并在任何删除或写入发生前拒绝无权用户。 + */ + @Test + public void replacingMcpBindingsRejectsMissingTargetPermissionBeforePersistence() { + SkillCapabilityBinding binding = mcpBinding("securedMcp", List.of("alpha")); + when(targetAccessService.requireUsableTarget(binding, false)) + .thenThrow(new BusinessException(403, 403, "无权限查询或使用 MCP")); + + BusinessException exception = assertThrows(BusinessException.class, + () -> service.replaceBindings(SKILL_ID, List.of(binding))); + + assertEquals(403, exception.getHttpStatus()); + verify(mcpAccessPermissionChecker).assertCanUseMcp(); + verify(skillMapper, never()).updateByQuery(any(Skill.class), any(QueryWrapper.class)); + } + + /** + * 验证禁用且尚未映射的 MCP 绑定也不能绕过保存时的 MCP 模块权限。 + */ + @Test + public void disabledUnmappedMcpStillRequiresPermissionOnSave() { + SkillCapabilityBinding binding = mcpBinding("securedMcp", List.of("alpha")); + binding.setTargetId(null); + binding.setTargetLogicalRef("mcp:unmapped"); + binding.setEnabled(false); + doThrow(new BusinessException(403, 403, "无权限查询或使用 MCP")) + .when(mcpAccessPermissionChecker).assertCanUseMcp(); + + BusinessException exception = assertThrows(BusinessException.class, + () -> service.replaceBindings(SKILL_ID, List.of(binding))); + + assertEquals(403, exception.getHttpStatus()); + verify(targetAccessService, never()).requireUsableTarget(any(), anyBoolean()); + verify(skillMapper, never()).updateByQuery(any(Skill.class), any(QueryWrapper.class)); + } + + /** + * 验证发布快照会重新校验 MCP 权限,不能沿用保存时或前端传入的授权状态。 + */ + @Test + public void publishingMcpBindingRevalidatesTargetPermission() { + SkillCapabilityBinding binding = mcpBinding("securedMcp", List.of("alpha")); + SkillCapabilityBindingServiceImpl publishService = spy(service); + doReturn(List.of(binding)).when(publishService).list(any(QueryWrapper.class)); + doThrow(new BusinessException(403, 403, "无权限查询或使用 MCP")) + .when(mcpAccessPermissionChecker).assertCanUseMcp(); + + BusinessException exception = assertThrows(BusinessException.class, + () -> publishService.buildPublishSnapshot(SKILL_ID)); + + assertEquals(403, exception.getHttpStatus()); + verify(mcpAccessPermissionChecker).assertCanUseMcp(); + verify(targetAccessService, never()).requireUsableTarget(any(), anyBoolean()); + } + + /** + * 验证草稿能力 hash 只覆盖持久化配置,不受发布时解析工具清单影响。 + */ + @Test + public void draftCapabilityHashIgnoresTransientResolvedTools() { + SkillCapabilityBinding saved = mcpBinding("demo", List.of("beta", "alpha")); + saved.setSortNo(0); + SkillValidationResult validation = service.validateBindings(SKILL_ID, List.of(saved), false); + assertTrue(validation.getIssues().toString(), validation.isValid()); + String responseHash = service.calculateHash(List.of(saved)); + + SkillCapabilityBinding reloaded = mcpBinding("demo", List.of("alpha", "beta")); + reloaded.setSortNo(0); + // targetLogicalRef 是校验后持久化的稳定配置,模拟数据库回读时应与已保存值一致。 + reloaded.setTargetLogicalRef(saved.getTargetLogicalRef()); + reloaded.setHitlEnabled(saved.getHitlEnabled()); + reloaded.setResolvedToolNames(List.of()); + String persistedHash = service.calculateHash(List.of(reloaded)); + + assertEquals(responseHash, persistedHash); + reloaded.setResolvedToolNames(List.of("changed-after-publish")); + assertEquals(persistedHash, service.calculateHash(List.of(reloaded))); + } + + /** + * 能力批量写入失败属于服务端持久化故障,应返回 5xx。 + */ + @Test + public void replacePersistenceFailureUsesServerErrorStatus() { + SkillCapabilityBindingServiceImpl failingService = spy(service); + doReturn(0L).when(failingService).count(any(QueryWrapper.class)); + doReturn(false).when(failingService).saveBatch(any(List.class)); + + BusinessException exception = assertThrows(BusinessException.class, + () -> failingService.replaceBindings( + SKILL_ID, List.of(binding("WORKFLOW", 1, "runFlow")))); + + assertEquals(500, exception.getHttpStatus()); + } + + /** + * 清空能力绑定时必须删除全部旧记录,并将能力摘要归零为确定性的空列表 hash。 + */ + @Test + public void clearingBindingsDeletesAllRowsAndResetsSummary() { + SkillCapabilityBindingMapper bindingMapper = mock(SkillCapabilityBindingMapper.class); + SkillCapabilityBindingServiceImpl clearingService = spy(service); + String emptyCapabilityHash = "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"; + doReturn(bindingMapper).when(clearingService).getMapper(); + doReturn(2L).when(clearingService).count(any(QueryWrapper.class)); + doReturn(List.of()).when(clearingService).list(any(QueryWrapper.class)); + when(bindingMapper.deleteByQuery(any(QueryWrapper.class))).thenReturn(2); + when(skillMapper.updateByQuery(any(Skill.class), any(QueryWrapper.class))).thenReturn(1); + + List result = clearingService.replaceBindings(SKILL_ID, List.of()); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(Skill.class); + verify(clearingService, times(1)).count(any(QueryWrapper.class)); + verify(bindingMapper, times(1)).deleteByQuery(any(QueryWrapper.class)); + verify(skillMapper).updateByQuery(updateCaptor.capture(), any(QueryWrapper.class)); + assertTrue(result.isEmpty()); + assertEquals(Integer.valueOf(0), updateCaptor.getValue().getCapabilityCount()); + assertEquals(emptyCapabilityHash, updateCaptor.getValue().getCapabilityHash()); + } + + /** + * 验证 MCP SELECTED 空选择和已消失工具都会返回明确结构化错误。 + */ + @Test + public void selectedMcpRequiresToolsAndRejectsMissingToolsOnPublish() { + SkillCapabilityBinding empty = mcpBinding("empty", List.of()); + SkillValidationResult emptyResult = service.validateBindings(SKILL_ID, List.of(empty), false); + assertTrue(hasIssue(emptyResult, "MCP_TOOL_SELECTION_EMPTY")); + + when(targetAccessService.requireUsableTarget(any(SkillCapabilityBinding.class), eq(true))) + .thenReturn(target(List.of("alpha"))); + SkillCapabilityBinding missing = mcpBinding("missing", List.of("alpha", "removed")); + SkillValidationResult missingResult = service.validateBindings(SKILL_ID, List.of(missing), true); + + assertFalse(missingResult.isValid()); + assertTrue(hasIssue(missingResult, "MCP_TOOL_MISSING")); + assertFalse(issue(missingResult, "MCP_TOOL_MISSING").getMessage().contains("removed")); + } + + /** + * 验证客户端提交的敏感或未知 options 被拒绝,并只留下安全白名单字段。 + */ + @Test + public void sensitiveAndUnknownClientOptionsAreRejected() { + SkillCapabilityBinding binding = binding("WORKFLOW", 1, "safeFlow"); + Map options = new LinkedHashMap<>(); + options.put("timeoutMs", 2_000); + options.put("token", "secret"); + options.put("customOption", true); + options.put("readOnly", Map.of("nested", "unsafe")); + binding.setOptionsJson(options); + + SkillValidationResult result = service.validateBindings(SKILL_ID, List.of(binding), false); + + assertFalse(result.isValid()); + assertTrue(hasIssue(result, "CAPABILITY_OPTIONS_UNSAFE")); + assertEquals(Map.of("timeoutMs", 2_000), binding.getOptionsJson()); + } + + /** + * 验证导入预览会报告 manifest 静态配置问题,同时不把待映射目标本身视为错误。 + */ + @Test + public void importPreviewReportsStaticErrorsWithoutBlockingUnresolvedTargets() { + SkillCapabilityBinding first = unresolvedBinding("WORKFLOW", "workflow:first", "sharedName"); + first.setSelectionMode("SELECTED"); + first.setSelectedToolNamesJson(List.of("search")); + first.setOptionsJson(Map.of("timeoutMs", 99, "retryCount", 11)); + SkillCapabilityBinding second = unresolvedBinding("PLUGIN_ITEM", "plugin-item:demo/tool", "sharedName"); + SkillCapabilityBinding mcp = unresolvedBinding("MCP", "mcp:demo", "mcpTools"); + mcp.setSelectionMode("SELECTED"); + mcp.setSelectedToolNamesJson(List.of()); + mcp.setExecutionMode("SYNC"); + + SkillValidationResult result = service.validateImportBindings(List.of(first, second, mcp)); + + assertFalse(result.isValid()); + assertTrue(hasIssue(result, "CAPABILITY_OPTION_VALUE_INVALID")); + assertTrue(hasIssue(result, "RUNTIME_NAME_DUPLICATE")); + assertTrue(hasIssue(result, "MCP_SELECTION_MODE_NOT_ALLOWED")); + assertTrue(hasIssue(result, "MCP_TOOL_SELECTION_NOT_ALLOWED")); + assertTrue(hasIssue(result, "MCP_EXECUTION_MODE_NOT_ALLOWED")); + assertTrue(hasIssue(result, "MCP_TOOL_SELECTION_EMPTY")); + assertFalse(hasIssue(result, "TARGET_UNRESOLVED")); + verify(targetAccessService, never()).requireUsableTarget(any(), anyBoolean()); + verifyNoInteractions(skillMapper, resourceAccessService); + } + + /** + * 验证有效的未映射能力可通过导入静态校验,留待映射步骤处理。 + */ + @Test + public void importPreviewAcceptsValidUnresolvedBinding() { + SkillCapabilityBinding binding = unresolvedBinding( + "WORKFLOW", "workflow:portable-flow", "portableFlow"); + + SkillValidationResult result = service.validateImportBindings(List.of(binding)); + + assertTrue(result.getIssues().toString(), result.isValid()); + assertTrue(result.getIssues().isEmpty()); + verify(targetAccessService, never()).requireUsableTarget(any(), anyBoolean()); + verifyNoInteractions(skillMapper, resourceAccessService); + } + + /** + * 验证自动映射成功的目标仍执行可用性和当前操作者授权校验。 + */ + @Test + public void importPreviewRevalidatesResolvedTargetPermission() { + SkillCapabilityBinding binding = binding("WORKFLOW", 92, "securedFlow"); + binding.setTargetLogicalRef("workflow:secured-flow"); + when(targetAccessService.requireUsableTarget(binding, false)) + .thenThrow(new BusinessException(403, 403, "无权限使用绑定工作流")); + + SkillValidationResult result = service.validateImportBindings(List.of(binding)); + + assertFalse(result.isValid()); + assertTrue(hasIssue(result, "TARGET_NO_PERMISSION")); + verify(targetAccessService).requireUsableTarget(binding, false); + verifyNoInteractions(skillMapper, resourceAccessService); + } + + /** + * 验证发布快照复用发布校验得到的目标摘要,并缓存同一目标的重复绑定查询。 + */ + @Test + public void publishSnapshotReusesValidatedTargetWithinRequest() { + SkillCapabilityBinding first = binding("WORKFLOW", 93, "firstFlow"); + SkillCapabilityBinding second = binding("WORKFLOW", 93, "secondFlow"); + SkillCapabilityBindingServiceImpl publishService = spy(new SkillCapabilityBindingServiceImpl( + skillMapper, targetAccessService, mcpAccessPermissionChecker, + resourceAccessService, new ObjectMapper())); + doReturn(List.of(first, second)).when(publishService).list(any(QueryWrapper.class)); + + List> snapshots = publishService.buildPublishSnapshot(SKILL_ID); + + assertEquals(2, snapshots.size()); + assertEquals("target", snapshots.get(0).get("targetName")); + assertEquals("target", snapshots.get(1).get("targetName")); + verify(targetAccessService, times(1)).requireUsableTarget(any(SkillCapabilityBinding.class), eq(false)); + } + + /** + * 发布快照必须移除凭据式目标元数据,并将非法逻辑引用降级为不可解析引用。 + * + * @throws Exception JSON 序列化失败 + */ + @Test + public void publishSnapshotSanitizesPortableTargetMetadata() throws Exception { + SkillCapabilityBinding binding = binding("WORKFLOW", 94, "secureFlow"); + SkillCapabilityTarget unsafeTarget = target(List.of()); + unsafeTarget.setName("https://user:password@example.test/flow"); + unsafeTarget.setLogicalRef("workflow:../../private"); + unsafeTarget.setRevision("/Users/admin/.config/secret"); + when(targetAccessService.requireUsableTarget(binding, false)).thenReturn(unsafeTarget); + SkillCapabilityBindingServiceImpl publishService = spy(new SkillCapabilityBindingServiceImpl( + skillMapper, targetAccessService, mcpAccessPermissionChecker, + resourceAccessService, new ObjectMapper())); + doReturn(List.of(binding)).when(publishService).list(any(QueryWrapper.class)); + + Map snapshot = publishService.buildPublishSnapshot(SKILL_ID).get(0); + String json = new ObjectMapper().writeValueAsString(snapshot); + + assertNull(snapshot.get("targetName")); + assertNull(snapshot.get("targetRevision")); + assertEquals("unresolved:workflow", snapshot.get("targetLogicalRef")); + assertFalse(json.contains("password")); + assertFalse(json.contains("/Users/admin")); + } + + /** + * 验证单项配置 4 KiB 和 MCP 选择 200 项的计数限额。 + */ + @Test + public void capabilityConfigAndSelectedToolLimitsAreReported() { + SkillCapabilityBinding oversizedConfig = binding("WORKFLOW", 1, "largeConfig"); + oversizedConfig.setOptionsJson(Map.of("timeoutMs", "x".repeat(5_000))); + SkillValidationResult configResult = service.validateBindings( + SKILL_ID, List.of(oversizedConfig), false); + assertTrue(hasIssue(configResult, "CAPABILITY_CONFIG_TOO_LARGE")); + + List tools = IntStream.range(0, 201) + .mapToObj(index -> String.format("tool%03d", index)) + .toList(); + SkillCapabilityBinding oversizedSelection = mcpBinding("many", tools); + SkillValidationResult selectionResult = service.validateBindings( + SKILL_ID, List.of(oversizedSelection), false); + + assertTrue(hasIssue(selectionResult, "MCP_TOOL_SELECTION_LIMIT")); + } + + /** + * 验证保存、导入和发布共用的能力校验会精确报告 HITL 字符串中的凭据。 + */ + @Test + public void sensitiveHitlValueIsRejectedWithExactPath() { + SkillCapabilityBinding binding = unresolvedBinding( + "WORKFLOW", "workflow:portable-flow", "portableFlow"); + binding.setHitlConfigJson(Map.of( + "title", "人工确认", + "prompt", "Authorization: Bearer actual-secret-value")); + + SkillValidationResult result = service.validateImportBindings(List.of(binding)); + + assertFalse(result.isValid()); + SkillValidationIssue issue = result.getIssues().stream() + .filter(item -> "SENSITIVE_VALUE_DETECTED".equals(item.getCode())) + .findFirst() + .orElseThrow(); + assertEquals("capabilities[0].hitlConfigJson.prompt", issue.getPath()); + assertFalse(issue.getMessage().contains("actual-secret-value")); + } + + /** + * 验证保存、导入和发布共用校验覆盖运行时名称、目标引用和工具名。 + */ + @Test + public void sensitiveBindingStringsAreRejectedWithExactPaths() { + SkillCapabilityBinding runtimeBinding = unresolvedBinding( + "WORKFLOW", "workflow:portable-flow", "sk-proj-abcdefghijklmnopqrstuvwxyz123456"); + SkillCapabilityBinding targetBinding = unresolvedBinding( + "WORKFLOW", "workflow:sk-proj-abcdefghijklmnopqrstuvwxyz123456", "portableFlow"); + SkillCapabilityBinding toolBinding = unresolvedBinding("MCP", "mcp:portable", "portableMcp"); + toolBinding.setEnabled(false); + toolBinding.setSelectionMode("SELECTED"); + toolBinding.setSelectedToolNamesJson(List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456")); + + SkillValidationResult result = service.validateImportBindings( + List.of(runtimeBinding, targetBinding, toolBinding)); + + assertFalse(result.isValid()); + List sensitivePaths = result.getIssues().stream() + .filter(item -> "SENSITIVE_VALUE_DETECTED".equals(item.getCode())) + .map(SkillValidationIssue::getPath) + .toList(); + assertTrue(sensitivePaths.contains("capabilities[0].runtimeName")); + assertTrue(sensitivePaths.contains("capabilities[1].targetLogicalRef")); + assertTrue(sensitivePaths.contains("capabilities[2].selectedToolNamesJson[0]")); + assertTrue(result.getIssues().stream().noneMatch( + item -> item.getMessage().contains("sk-proj-"))); + } + + /** + * 验证禁用能力也不能将凭据式工具名写入发布快照。 + */ + @Test + public void publishSnapshotRejectsCredentialInDisabledBinding() { + SkillCapabilityBinding binding = unresolvedBinding("MCP", "mcp:portable", "portableMcp"); + binding.setEnabled(false); + binding.setSelectionMode("SELECTED"); + binding.setSelectedToolNamesJson(List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456")); + SkillCapabilityBindingServiceImpl publishService = spy(service); + doReturn(List.of(binding)).when(publishService).list(any(QueryWrapper.class)); + + BusinessException exception = assertThrows( + BusinessException.class, () -> publishService.buildPublishSnapshot(SKILL_ID)); + + assertFalse(exception.getMessage().contains("sk-proj-")); + } + + /** + * 验证目标解析阶段返回的凭据式 MCP 工具名不能进入发布快照。 + */ + @Test + public void publishValidationRejectsCredentialFromResolvedMcpTools() { + SkillCapabilityBinding binding = mcpBinding("portableMcp", List.of("alpha")); + when(targetAccessService.requireUsableTarget(binding, true)).thenReturn( + target(List.of("alpha", "sk-proj-abcdefghijklmnopqrstuvwxyz123456"))); + + SkillValidationResult result = service.validateBindings(SKILL_ID, List.of(binding), true); + + assertFalse(result.isValid()); + SkillValidationIssue issue = result.getIssues().stream() + .filter(item -> "SENSITIVE_VALUE_DETECTED".equals(item.getCode())) + .findFirst() + .orElseThrow(); + assertEquals("capabilities[0].resolvedToolNames[1]", issue.getPath()); + assertFalse(issue.getMessage().contains("sk-proj-")); + } + + /** + * 验证列表和详情读取边界会移除历史数据库中的凭据式展示值。 + */ + @Test + public void listBindingsRedactsLegacyCredentialValues() { + SkillCapabilityBinding binding = binding( + "WORKFLOW", 95, "sk-proj-abcdefghijklmnopqrstuvwxyz123456"); + binding.setSelectedToolNamesJson(List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456")); + binding.setResolvedToolNames(List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456")); + binding.setHitlConfigJson(Map.of("prompt", "Bearer actual-secret-value")); + binding.setOptionsJson(Map.of("timeoutMs", "token=actual-secret-value")); + SkillCapabilityBindingServiceImpl listService = spy(service); + doReturn(List.of(binding)).when(listService).list(any(QueryWrapper.class)); + + SkillCapabilityBinding result = listService.listBindings(SKILL_ID).get(0); + + assertNull(result.getRuntimeName()); + assertTrue(result.getSelectedToolNamesJson().isEmpty()); + assertTrue(result.getResolvedToolNames().isEmpty()); + assertTrue(result.getHitlConfigJson().isEmpty()); + assertTrue(result.getOptionsJson().isEmpty()); + } + + /** + * 验证 replaceBindings 在进入持久化前拒绝超过 200 项的能力列表。 + */ + @Test + public void replaceRejectsMoreThanTwoHundredBindings() { + List bindings = new ArrayList<>(); + for (int index = 0; index < 201; index++) { + bindings.add(binding("WORKFLOW", index + 1, "flow" + index)); + } + + assertThrows(BusinessException.class, () -> service.replaceBindings(SKILL_ID, bindings)); + verify(resourceAccessService).assertAccess( + CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限管理 Skill 能力绑定"); + } + + /** + * 验证传入待保存 bindings 的校验必须执行 MANAGE 权限,不允许降级为 READ。 + */ + @Test + public void validatingClientBindingsRequiresManagePermission() { + SkillCapabilityBinding binding = binding("WORKFLOW", 1, "managedFlow"); + + service.validateBindings(SKILL_ID, List.of(binding), false); + + verify(resourceAccessService).assertAccess( + eq(CategoryResourceType.SKILL), same(skill), eq(ResourceAction.MANAGE), anyString()); + } + + /** + * 验证只有 READ 权限的用户读取绑定时看不到当前环境目标 ID 和无权限目标残留名称。 + */ + @Test + public void visibleBindingsRedactTargetIdentityWithoutManagePermission() { + SkillCapabilityBinding binding = binding("WORKFLOW", 81, "readOnlyFlow"); + binding.setTargetLogicalRef("workflow:private-flow"); + binding.setTargetName("stale-private-name"); + binding.setSelectedToolNamesJson(List.of("stale-private-selected-tool")); + binding.setResolvedToolNames(List.of("stale-private-tool")); + SkillCapabilityBindingServiceImpl viewService = spy(new SkillCapabilityBindingServiceImpl( + skillMapper, targetAccessService, mcpAccessPermissionChecker, + resourceAccessService, new ObjectMapper())); + doReturn(List.of(binding)).when(viewService).list(any(QueryWrapper.class)); + when(resourceAccessService.canAccess( + CategoryResourceType.SKILL, skill, ResourceAction.MANAGE)).thenReturn(false); + when(targetAccessService.requireUsableTarget(binding, false)) + .thenThrow(new BusinessException(403, 403, "无权限使用目标")); + + List result = viewService.listVisibleBindings(SKILL_ID); + + assertEquals(1, result.size()); + assertNull(result.get(0).getTargetId()); + assertNull(result.get(0).getTargetLogicalRef()); + assertNull(result.get(0).getTargetName()); + assertTrue(result.get(0).getSelectedToolNamesJson().isEmpty()); + assertTrue(result.get(0).getResolvedToolNames().isEmpty()); + assertEquals("NO_PERMISSION", result.get(0).getTargetStatus()); + } + + /** + * 验证拥有 MANAGE 权限的用户读取绑定时仍可获得目标 ID 用于编辑。 + */ + @Test + public void visibleBindingsKeepTargetIdWithManagePermission() { + SkillCapabilityBinding binding = binding("WORKFLOW", 82, "managedFlow"); + SkillCapabilityBindingServiceImpl viewService = spy(new SkillCapabilityBindingServiceImpl( + skillMapper, targetAccessService, mcpAccessPermissionChecker, + resourceAccessService, new ObjectMapper())); + doReturn(List.of(binding)).when(viewService).list(any(QueryWrapper.class)); + when(resourceAccessService.canAccess( + CategoryResourceType.SKILL, skill, ResourceAction.MANAGE)).thenReturn(true); + + List result = viewService.listVisibleBindings(SKILL_ID); + + assertEquals(BigInteger.valueOf(82), result.get(0).getTargetId()); + assertEquals("AVAILABLE", result.get(0).getTargetStatus()); + } + + /** + * 验证 Skill MANAGE 权限不能替代 MCP 查询权限,目标标识和工具元数据仍需脱敏。 + */ + @Test + public void visibleBindingsRedactMcpTargetWithoutTargetPermissionEvenWhenSkillManageable() { + SkillCapabilityBinding binding = mcpBinding("privateMcp", List.of("private_tool")); + binding.setTargetLogicalRef("mcp:private-server"); + binding.setTargetName("private-server"); + binding.setResolvedToolNames(List.of("private_tool")); + SkillCapabilityBindingServiceImpl viewService = spy(new SkillCapabilityBindingServiceImpl( + skillMapper, targetAccessService, mcpAccessPermissionChecker, + resourceAccessService, new ObjectMapper())); + doReturn(List.of(binding)).when(viewService).list(any(QueryWrapper.class)); + when(resourceAccessService.canAccess( + CategoryResourceType.SKILL, skill, ResourceAction.MANAGE)).thenReturn(true); + when(targetAccessService.requireUsableTarget(binding, false)) + .thenThrow(new BusinessException(403, 403, "无权限查询或使用 MCP")); + + List result = viewService.listVisibleBindings(SKILL_ID); + + assertEquals(1, result.size()); + assertEquals("NO_PERMISSION", result.get(0).getTargetStatus()); + assertNull(result.get(0).getTargetId()); + assertNull(result.get(0).getTargetLogicalRef()); + assertNull(result.get(0).getTargetName()); + assertTrue(result.get(0).getSelectedToolNamesJson().isEmpty()); + assertTrue(result.get(0).getResolvedToolNames().isEmpty()); + } + + /** + * 创建基础能力绑定。 + * + * @param type 能力类型 + * @param targetId 目标 ID + * @param runtimeName 运行时名称 + * @return 能力绑定 + */ + private SkillCapabilityBinding binding(String type, long targetId, String runtimeName) { + SkillCapabilityBinding binding = new SkillCapabilityBinding(); + binding.setCapabilityType(type); + binding.setTargetId(BigInteger.valueOf(targetId)); + binding.setRuntimeName(runtimeName); + binding.setEnabled(true); + binding.setHitlConfigJson(new LinkedHashMap<>()); + binding.setOptionsJson(new LinkedHashMap<>()); + return binding; + } + + /** + * 创建 MCP SELECTED 能力绑定。 + * + * @param runtimeName 命名空间 + * @param selectedTools 已选工具 + * @return MCP 绑定 + */ + private SkillCapabilityBinding mcpBinding(String runtimeName, List selectedTools) { + SkillCapabilityBinding binding = binding("MCP", 10, runtimeName); + binding.setSelectionMode("SELECTED"); + binding.setSelectedToolNamesJson(selectedTools); + return binding; + } + + /** + * 创建等待导入映射的能力绑定。 + * + * @param type 能力类型 + * @param logicalRef 可移植逻辑引用 + * @param runtimeName 运行时名称 + * @return 未映射能力绑定 + */ + private SkillCapabilityBinding unresolvedBinding(String type, String logicalRef, String runtimeName) { + SkillCapabilityBinding binding = new SkillCapabilityBinding(); + binding.setCapabilityType(type); + binding.setTargetLogicalRef(logicalRef); + binding.setRuntimeName(runtimeName); + binding.setEnabled(true); + binding.setHitlConfigJson(new LinkedHashMap<>()); + binding.setOptionsJson(new LinkedHashMap<>()); + return binding; + } + + /** + * 创建可用能力目标。 + * + * @param toolNames MCP 工具名 + * @return 目标摘要 + */ + private SkillCapabilityTarget target(List toolNames) { + SkillCapabilityTarget target = new SkillCapabilityTarget(); + target.setName("target"); + target.setLogicalRef("target://demo"); + target.setRevision("r1"); + target.setStatus("AVAILABLE"); + target.setToolNames(toolNames); + return target; + } + + /** + * 判断校验结果是否包含指定问题码。 + * + * @param result 校验结果 + * @param code 问题码 + * @return 包含时为 true + */ + private boolean hasIssue(SkillValidationResult result, String code) { + return result.getIssues().stream().anyMatch(item -> code.equals(item.getCode())); + } + + /** + * 获取指定问题码的首个问题。 + * + * @param result 校验结果 + * @param code 问题码 + * @return 校验问题 + */ + private SkillValidationIssue issue(SkillValidationResult result, String code) { + return result.getIssues().stream() + .filter(item -> code.equals(item.getCode())) + .findFirst() + .orElseThrow(); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityMalformedInputTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityMalformedInputTest.java new file mode 100644 index 00000000..a9c52bfc --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityMalformedInputTest.java @@ -0,0 +1,204 @@ +package tech.easyflow.skill.capability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import tech.easyflow.ai.permission.McpAccessPermissionChecker; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.mapper.SkillMapper; +import tech.easyflow.skill.validation.SkillValidationResult; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * 能力绑定畸形客户端输入的结构化诊断测试。 + */ +public class SkillCapabilityMalformedInputTest { + + private static final BigInteger SKILL_ID = BigInteger.valueOf(101); + + private SkillCapabilityBindingServiceImpl service; + + /** + * 初始化具有当前租户上下文的被测服务。 + */ + @Before + public void setUp() { + SkillMapper skillMapper = mock(SkillMapper.class); + Skill skill = new Skill(); + skill.setId(SKILL_ID); + skill.setTenantId(BigInteger.ONE); + when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(skill); + SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class); + SkillCapabilityTarget target = new SkillCapabilityTarget(); + target.setName("target"); + target.setLogicalRef("target:demo"); + target.setStatus("AVAILABLE"); + target.setToolNames(List.of("search")); + when(targetAccessService.requireUsableTarget(any(SkillCapabilityBinding.class), anyBoolean())) + .thenReturn(target); + service = new SkillCapabilityBindingServiceImpl( + skillMapper, targetAccessService, mock(McpAccessPermissionChecker.class), + mock(ResourceAccessService.class), new ObjectMapper()); + } + + /** + * 验证 MCP 工具数组中的 null 返回结构化错误,不触发排序空指针。 + */ + @Test + public void nullMcpToolNameShouldReturnStructuredIssue() { + SkillCapabilityBinding binding = binding("MCP"); + binding.setSelectionMode("SELECTED"); + List tools = new ArrayList<>(); + tools.add("search"); + tools.add(null); + binding.setSelectedToolNamesJson(tools); + + SkillValidationResult result = validate(binding); + + assertFalse(result.isValid()); + assertTrue(result.getIssues().stream() + .anyMatch(issue -> "MCP_TOOL_NAME_INVALID".equals(issue.getCode()))); + } + + /** + * 验证非法执行模式进入结构化问题列表,不以枚举异常中断校验。 + */ + @Test + public void invalidExecutionModeShouldReturnStructuredIssue() { + SkillCapabilityBinding binding = binding("WORKFLOW"); + binding.setExecutionMode("INVALID_MODE"); + + SkillValidationResult result = validate(binding); + + assertFalse(result.isValid()); + assertTrue(result.getIssues().stream() + .anyMatch(issue -> issue.getPath() != null && issue.getPath().endsWith("executionMode"))); + } + + /** + * 验证非法 MCP 选择模式进入结构化问题列表。 + */ + @Test + public void invalidSelectionModeShouldReturnStructuredIssue() { + SkillCapabilityBinding binding = binding("MCP"); + binding.setSelectionMode("INVALID_MODE"); + + SkillValidationResult result = validate(binding); + + assertFalse(result.isValid()); + assertTrue(result.getIssues().stream() + .anyMatch(issue -> issue.getPath() != null && issue.getPath().endsWith("selectionMode"))); + } + + /** + * 验证禁用且未映射的 MCP 仍执行选择模式静态校验,不能借 targetId 为空绕过。 + */ + @Test + public void disabledUnresolvedMcpShouldStillValidateSelectionMode() { + SkillCapabilityBinding binding = unresolvedBinding("MCP", "mcp:missing"); + binding.setSelectionMode("INVALID_MODE"); + + SkillValidationResult result = validate(binding); + + assertFalse(result.isValid()); + assertTrue(result.getIssues().stream() + .anyMatch(issue -> "MCP_SELECTION_MODE_INVALID".equals(issue.getCode()))); + } + + /** + * 验证禁用且未映射的 MCP 仍拒绝非法工具名,避免恶意值持久化并再次导出。 + */ + @Test + public void disabledUnresolvedMcpShouldStillValidateToolNames() { + SkillCapabilityBinding binding = unresolvedBinding("MCP", "mcp:missing"); + binding.setSelectionMode("SELECTED"); + binding.setSelectedToolNamesJson(List.of("invalid tool name")); + + SkillValidationResult result = validate(binding); + + assertFalse(result.isValid()); + assertTrue(result.getIssues().stream() + .anyMatch(issue -> "MCP_TOOL_NAME_INVALID".equals(issue.getCode()))); + } + + /** + * 验证禁用且未映射的非 MCP 能力仍执行 executionMode 静态校验。 + */ + @Test + public void disabledUnresolvedWorkflowShouldStillValidateExecutionMode() { + SkillCapabilityBinding binding = unresolvedBinding("WORKFLOW", "workflow:missing"); + binding.setExecutionMode("INVALID_MODE"); + + SkillValidationResult result = validate(binding); + + assertFalse(result.isValid()); + assertTrue(result.getIssues().stream() + .anyMatch(issue -> "EXECUTION_MODE_INVALID".equals(issue.getCode()))); + } + + /** + * 验证逻辑引用 scheme 必须与能力类型一致。 + */ + @Test + public void unresolvedLogicalRefShouldMatchCapabilityType() { + SkillCapabilityBinding binding = unresolvedBinding("MCP", "workflow:wrong-type"); + binding.setSelectionMode("ALL"); + + SkillValidationResult result = validate(binding); + + assertFalse(result.isValid()); + assertTrue(result.getIssues().stream() + .anyMatch(issue -> "TARGET_LOGICAL_REF_INVALID".equals(issue.getCode()))); + } + + private SkillValidationResult validate(SkillCapabilityBinding binding) { + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account()); + return service.validateBindings(SKILL_ID, List.of(binding), false); + } + } + + private SkillCapabilityBinding binding(String type) { + SkillCapabilityBinding binding = new SkillCapabilityBinding(); + binding.setCapabilityType(type); + binding.setTargetId(BigInteger.valueOf(9)); + binding.setRuntimeName("demoTool"); + binding.setEnabled(true); + binding.setHitlConfigJson(Map.of()); + binding.setOptionsJson(Map.of()); + return binding; + } + + private SkillCapabilityBinding unresolvedBinding(String type, String logicalRef) { + SkillCapabilityBinding binding = binding(type); + binding.setTargetId(null); + binding.setTargetLogicalRef(logicalRef); + binding.setEnabled(false); + return binding; + } + + private LoginAccount account() { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(7)); + account.setTenantId(BigInteger.ONE); + return account; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityTenantAndValidationTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityTenantAndValidationTest.java new file mode 100644 index 00000000..4bf6b94f --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityTenantAndValidationTest.java @@ -0,0 +1,242 @@ +package tech.easyflow.skill.capability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Test; +import org.mockito.MockedStatic; +import tech.easyflow.ai.entity.Mcp; +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.permission.McpAccessPermissionChecker; +import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper; +import tech.easyflow.ai.service.McpService; +import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.PluginService; +import tech.easyflow.ai.service.PluginVisibilityService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.enums.SkillCapabilityType; +import tech.easyflow.skill.mapper.SkillCapabilityBindingMapper; +import tech.easyflow.skill.mapper.SkillMapper; +import tech.easyflow.skill.validation.SkillValidationIssue; +import tech.easyflow.skill.validation.SkillValidationResult; +import tech.easyflow.system.service.ResourceAccessService; +import tech.easyflow.system.service.CategoryPermissionService; + +import java.math.BigInteger; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Skill 能力绑定的租户边界和严格校验回归测试。 + */ +public class SkillCapabilityTenantAndValidationTest { + + /** + * 验证当前用户即使拥有全局插件可见范围,也不能绑定其他租户的插件工具项。 + */ + @Test + public void pluginItemShouldNeverCrossTenantBoundary() { + PluginItemService pluginItemService = mock(PluginItemService.class); + PluginService pluginService = mock(PluginService.class); + PluginVisibilityService pluginVisibilityService = mock(PluginVisibilityService.class); + PluginItem item = new PluginItem(); + item.setId(BigInteger.valueOf(11)); + item.setPluginId(BigInteger.valueOf(22)); + item.setName("tool"); + item.setStatus(1); + item.setServiceStatus(1); + Plugin plugin = new Plugin(); + plugin.setId(BigInteger.valueOf(22)); + plugin.setTenantId(2L); + plugin.setCreatedBy(8L); + plugin.setName("other-tenant-plugin"); + when(pluginItemService.getOne(any(QueryWrapper.class))).thenReturn(item); + // 即使底层查询实现错误地返回了跨租户对象,服务层防御检查仍必须拒绝。 + when(pluginService.getOne(any(QueryWrapper.class))).thenReturn(plugin); + when(pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())).thenReturn(true); + when(pluginService.preparePluginForCurrentUser(plugin)).thenReturn(plugin); + SkillCapabilityTargetAccessServiceImpl service = new SkillCapabilityTargetAccessServiceImpl( + mock(WorkflowService.class), pluginItemService, pluginService, pluginVisibilityService, + mock(McpService.class), mock(McpAccessPermissionChecker.class), mock(ResourceAccessService.class), + mock(WorkflowVisibilityQueryHelper.class), mock(CategoryPermissionService.class)); + SkillCapabilityBinding binding = binding("PLUGIN_ITEM", item.getId(), "tool"); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1)); + BusinessException exception = assertThrows(BusinessException.class, + () -> service.requireUsableTarget(binding, false)); + assertEquals(403, exception.getHttpStatus()); + } + verify(pluginService, never()).preparePluginForCurrentUser(plugin); + } + + /** + * 验证包含凭据式 URL 的 MCP 标题不会被复制进跨环境逻辑引用。 + */ + @Test + public void unsafeMcpTitleShouldBecomeUnresolvedLogicalRef() { + McpService mcpService = mock(McpService.class); + Mcp mcp = new Mcp(); + mcp.setId(BigInteger.valueOf(31)); + mcp.setTenantId(BigInteger.ONE); + mcp.setStatus(true); + mcp.setTitle("https://user:secret@example.test/mcp?token=must-not-enter"); + when(mcpService.getOne(any(QueryWrapper.class))).thenReturn(mcp); + McpAccessPermissionChecker mcpPermissionChecker = mock(McpAccessPermissionChecker.class); + SkillCapabilityTargetAccessServiceImpl service = new SkillCapabilityTargetAccessServiceImpl( + mock(WorkflowService.class), mock(PluginItemService.class), mock(PluginService.class), + mock(PluginVisibilityService.class), mcpService, mcpPermissionChecker, mock(ResourceAccessService.class), + mock(WorkflowVisibilityQueryHelper.class), mock(CategoryPermissionService.class)); + SkillCapabilityBinding binding = binding("MCP", mcp.getId(), "mcpTool"); + + SkillCapabilityTarget target; + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1)); + target = service.requireUsableTarget(binding, false); + } + + assertEquals("unresolved:mcp", target.getLogicalRef()); + assertFalse(target.getLogicalRef().contains("secret")); + assertFalse(target.getLogicalRef().contains("token")); + } + + /** + * 验证 MCP 候选、工具解析、目标绑定和增强导入映射都在访问数据前校验 MCP 模块权限。 + */ + @Test + public void mcpOperationsRejectCallerWithoutMcpQueryPermissionBeforeDataAccess() { + McpService mcpService = mock(McpService.class); + McpAccessPermissionChecker permissionChecker = mock(McpAccessPermissionChecker.class); + doThrow(new BusinessException(403, 403, "无权限查询或使用 MCP")) + .when(permissionChecker).assertCanUseMcp(); + SkillCapabilityTargetAccessServiceImpl service = new SkillCapabilityTargetAccessServiceImpl( + mock(WorkflowService.class), mock(PluginItemService.class), mock(PluginService.class), + mock(PluginVisibilityService.class), mcpService, permissionChecker, + mock(ResourceAccessService.class), mock(WorkflowVisibilityQueryHelper.class), + mock(CategoryPermissionService.class)); + SkillCapabilityBinding binding = binding("MCP", BigInteger.valueOf(31), "mcpTool"); + + BusinessException candidates = assertThrows(BusinessException.class, + () -> service.listCandidates(SkillCapabilityType.MCP, null)); + BusinessException tools = assertThrows(BusinessException.class, + () -> service.getMcpTools(BigInteger.valueOf(31))); + BusinessException bindingAccess = assertThrows(BusinessException.class, + () -> service.requireUsableTarget(binding, false)); + BusinessException importMapping = assertThrows(BusinessException.class, + () -> service.resolveLogicalRef(SkillCapabilityType.MCP, "mcp:demo")); + + assertEquals(403, candidates.getHttpStatus()); + assertEquals(403, tools.getHttpStatus()); + assertEquals(403, bindingAccess.getHttpStatus()); + assertEquals(403, importMapping.getHttpStatus()); + verify(permissionChecker, times(4)).assertCanUseMcp(); + verifyNoInteractions(mcpService); + } + + /** + * 验证省略 HITL 和 options 时按空配置处理,不产生不安全配置误报。 + */ + @Test + public void nullSafeConfigsShouldBeNormalizedToEmptyMaps() { + SkillMapper skillMapper = mock(SkillMapper.class); + SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class); + Skill skill = skill(101, 1); + when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(skill); + when(targetAccessService.requireUsableTarget(any(SkillCapabilityBinding.class), anyBoolean())) + .thenReturn(target()); + SkillCapabilityBindingServiceImpl service = new SkillCapabilityBindingServiceImpl( + skillMapper, targetAccessService, mock(McpAccessPermissionChecker.class), + mock(ResourceAccessService.class), new ObjectMapper()); + SkillCapabilityBinding binding = binding("WORKFLOW", BigInteger.valueOf(9), "workflowTool"); + + SkillValidationResult result; + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1)); + result = service.validateBindings(skill.getId(), List.of(binding), false); + } + + assertTrue(result.getIssues().toString(), result.isValid()); + assertTrue(binding.getHitlConfigJson().isEmpty()); + assertTrue(binding.getOptionsJson().isEmpty()); + } + + /** + * 验证目标 USE 权限失败时,即使绑定被禁用也不能作为 warning 绕过保存校验。 + */ + @Test + public void disabledBindingShouldNotBypassTargetPermission() { + SkillMapper skillMapper = mock(SkillMapper.class); + SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class); + Skill skill = skill(101, 1); + when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(skill); + when(targetAccessService.requireUsableTarget(any(SkillCapabilityBinding.class), anyBoolean())) + .thenThrow(new BusinessException(403, 403, "无权限使用目标")); + SkillCapabilityBindingServiceImpl service = new SkillCapabilityBindingServiceImpl( + skillMapper, targetAccessService, mock(McpAccessPermissionChecker.class), + mock(ResourceAccessService.class), new ObjectMapper()); + SkillCapabilityBinding binding = binding("WORKFLOW", BigInteger.valueOf(9), "workflowTool"); + binding.setEnabled(false); + + SkillValidationResult result; + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1)); + result = service.validateBindings(skill.getId(), List.of(binding), false); + } + + assertFalse(result.isValid()); + SkillValidationIssue issue = result.getIssues().stream() + .filter(item -> "TARGET_NO_PERMISSION".equals(item.getCode())) + .findFirst().orElseThrow(); + assertEquals("ERROR", issue.getSeverity()); + } + + private SkillCapabilityBinding binding(String type, BigInteger targetId, String runtimeName) { + SkillCapabilityBinding binding = new SkillCapabilityBinding(); + binding.setCapabilityType(type); + binding.setTargetId(targetId); + binding.setRuntimeName(runtimeName); + binding.setEnabled(true); + return binding; + } + + private Skill skill(long id, long tenantId) { + Skill skill = new Skill(); + skill.setId(BigInteger.valueOf(id)); + skill.setTenantId(BigInteger.valueOf(tenantId)); + return skill; + } + + private SkillCapabilityTarget target() { + SkillCapabilityTarget target = new SkillCapabilityTarget(); + target.setName("target"); + target.setLogicalRef("workflow:target"); + target.setStatus("AVAILABLE"); + return target; + } + + private LoginAccount account(long accountId, long tenantId) { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(accountId)); + account.setTenantId(BigInteger.valueOf(tenantId)); + return account; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/file/SkillFileServiceImplTransactionTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/file/SkillFileServiceImplTransactionTest.java new file mode 100644 index 00000000..49d9e62e --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/file/SkillFileServiceImplTransactionTest.java @@ -0,0 +1,442 @@ +package tech.easyflow.skill.file; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillResource; +import tech.easyflow.skill.service.SkillResourceService; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.store.DBSkillContentStore; +import tech.easyflow.system.service.ResourceAccessService; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link SkillFileServiceImpl} 上传事务入口与失败回滚契约测试。 + */ +public class SkillFileServiceImplTransactionTest { + + private static final BigInteger SKILL_ID = BigInteger.valueOf(101); + private static final String NEW_CONTENT_REF = "sha256:" + "a".repeat(64); + + private SkillService skillService; + private SkillResourceService skillResourceService; + private DBSkillContentStore contentStore; + private SkillFileServiceImpl service; + private MockedStatic saToken; + + /** + * 初始化上传服务。 + */ + @Before + public void setUp() { + skillService = mock(SkillService.class); + skillResourceService = mock(SkillResourceService.class); + contentStore = mock(DBSkillContentStore.class); + service = new SkillFileServiceImpl( + skillService, + skillResourceService, + contentStore, + mock(ResourceAccessService.class)); + Skill skill = new Skill(); + skill.setId(SKILL_ID); + skill.setTenantId(BigInteger.ONE); + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(7)); + account.setTenantId(BigInteger.ONE); + saToken = mockStatic(SaTokenUtil.class); + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + when(skillService.getOne(any(QueryWrapper.class))).thenReturn(skill); + when(skillResourceService.list(any(QueryWrapper.class))).thenReturn(List.of()); + when(skillResourceService.listDescriptors(any(BigInteger.class), any(BigInteger.class))) + .thenReturn(List.of()); + when(contentStore.put(any(MultipartFile.class), anyString())).thenReturn(NEW_CONTENT_REF); + } + + /** + * 释放静态登录态 Mock。 + */ + @After + public void tearDown() { + saToken.close(); + } + + /** + * 验证资源持久化失败时不手工 release 新引用,引用计数应由同一外层事务回滚。 + */ + @Test + public void failedUploadDoesNotDoubleReleaseNewReference() { + when(skillResourceService.save(any(SkillResource.class))).thenReturn(false); + + assertThrows(BusinessException.class, () -> service.uploadAsset( + SKILL_ID, "assets/file.bin", multipart("file.bin", "content"))); + + verify(contentStore).put(any(MultipartFile.class), anyString()); + verify(contentStore, never()).release(NEW_CONTENT_REF); + } + + /** + * 验证 uploadAsset 自身成为事务代理入口,不依赖类内调用 uploadResource 的注解。 + * + * @throws Exception 反射读取方法失败 + */ + @Test + public void uploadAssetIsTransactionalEntry() throws Exception { + Method method = SkillFileServiceImpl.class.getMethod( + "uploadAsset", BigInteger.class, String.class, MultipartFile.class); + + assertTrue(method.isAnnotationPresent(Transactional.class)); + } + + /** + * 验证空 Skill 的文件树仍稳定返回三个标准目录。 + */ + @Test + public void treeAlwaysContainsStandardDirectories() { + List roots = service.tree(SKILL_ID); + + assertEquals(List.of("SKILL.md", "references", "scripts", "assets"), + roots.stream().map(SkillFileNode::getPath).toList()); + assertEquals(List.of("SKILL", "DIRECTORY", "DIRECTORY", "DIRECTORY"), + roots.stream().map(SkillFileNode::getType).toList()); + } + + /** + * 验证脚本上传按严格 UTF-8 文本保存,不进入二进制内容仓库。 + */ + @Test + public void scriptUploadStoresCanonicalTextRepresentation() { + AtomicReference savedResource = new AtomicReference<>(); + when(skillResourceService.list(any(QueryWrapper.class))) + .thenAnswer(invocation -> savedResource.get() == null + ? List.of() : List.of(savedResource.get())); + when(skillResourceService.save(any(SkillResource.class))).thenAnswer(invocation -> { + SkillResource resource = invocation.getArgument(0); + resource.setId(BigInteger.valueOf(9)); + savedResource.set(resource); + return true; + }); + + SkillFileContent result = service.uploadResource( + SKILL_ID, "scripts/tool.py", multipart("tool.py", "print('ok')\n")); + + SkillResource resource = savedResource.get(); + assertTrue(resource.getIsText()); + assertEquals("SCRIPT", resource.getKind()); + assertEquals("PYTHON", resource.getLanguage()); + assertEquals("print('ok')\n", resource.getTextContent()); + assertNull(resource.getContentRef()); + assertTrue(result.getIsText()); + verify(contentStore, never()).put(any(MultipartFile.class), anyString()); + } + + /** + * 验证脚本上传拒绝非法 UTF-8,且失败前不会写入资源或二进制仓库。 + */ + @Test + public void scriptUploadRejectsMalformedUtf8() { + MultipartFile file = new TestMultipartFile("bad.py", new byte[]{(byte) 0xC3, (byte) 0x28}); + + BusinessException exception = assertThrows(BusinessException.class, + () -> service.uploadResource(SKILL_ID, "scripts/bad.py", file)); + + assertTrue(exception.getMessage().contains("严格 UTF-8")); + verify(skillResourceService, never()).save(any(SkillResource.class)); + verify(contentStore, never()).put(any(MultipartFile.class), anyString()); + } + + /** + * 验证二进制资源重命名到 scripts 后转为文本,并释放原内容引用。 + */ + @Test + public void binaryRenameToScriptConvertsAndReleasesContent() { + String oldRef = "sha256:" + "b".repeat(64); + String sourceHash = "b".repeat(64); + SkillResource resource = resource( + "assets/tool.bin", false, null, oldRef, sourceHash, 12L); + when(skillResourceService.list(any(QueryWrapper.class))) + .thenReturn(List.of(), List.of(resource), List.of(resource), List.of(resource)); + when(skillResourceService.update(eq(resource), any(QueryWrapper.class))).thenReturn(true); + when(contentStore.open(oldRef)).thenReturn( + new ByteArrayInputStream("print('ok')\n".getBytes(StandardCharsets.UTF_8))); + SkillFileRenameRequest request = renameRequest( + "assets/tool.bin", "scripts/tool.py", sourceHash); + + SkillFileContent result = service.renameFile(request); + + assertTrue(resource.getIsText()); + assertEquals("scripts/tool.py", resource.getNormalizedPath()); + assertEquals("SCRIPT", resource.getKind()); + assertEquals("PYTHON", resource.getLanguage()); + assertNull(resource.getContentRef()); + assertEquals("print('ok')\n", resource.getTextContent()); + assertTrue(result.getIsText()); + verify(contentStore).release(oldRef); + } + + /** + * 验证文本资源重命名到 assets 后转为二进制内容引用。 + */ + @Test + public void textRenameToAssetConvertsToBinaryRepresentation() { + String sourceHash = "c".repeat(64); + SkillResource resource = resource( + "references/guide.md", true, "# Guide\n", null, sourceHash, 8L); + when(skillResourceService.list(any(QueryWrapper.class))) + .thenReturn(List.of(), List.of(resource), List.of(resource), List.of(resource)); + when(skillResourceService.update(eq(resource), any(QueryWrapper.class))).thenReturn(true); + when(contentStore.put(any(byte[].class))).thenReturn(NEW_CONTENT_REF); + SkillFileRenameRequest request = renameRequest( + "references/guide.md", "assets/guide.md", sourceHash); + + SkillFileContent result = service.renameFile(request); + + assertFalse(resource.getIsText()); + assertEquals("ASSET", resource.getKind()); + assertEquals(NEW_CONTENT_REF, resource.getContentRef()); + assertNull(resource.getTextContent()); + assertFalse(result.getIsText()); + verify(contentStore).put("# Guide\n".getBytes(StandardCharsets.UTF_8)); + } + + /** + * 验证 assets 路径不能通过文本创建入口形成非规范表示。 + */ + @Test + public void createTextAssetIsRejected() { + SkillFileSaveRequest request = new SkillFileSaveRequest(); + request.setSkillId(SKILL_ID); + request.setPath("assets/readme.txt"); + request.setContent("text"); + + BusinessException exception = assertThrows(BusinessException.class, + () -> service.createTextFile(request)); + + assertTrue(exception.getMessage().contains("二进制文件管理")); + verify(skillResourceService, never()).save(any(SkillResource.class)); + } + + /** + * 验证未知脚本扩展名仍可保真保存,并退化为无语言高亮的文本脚本。 + */ + @Test + public void unrecognizedScriptExtensionUsesPlainTextRepresentation() { + AtomicReference savedResource = new AtomicReference<>(); + when(skillResourceService.list(any(QueryWrapper.class))) + .thenAnswer(invocation -> savedResource.get() == null + ? List.of() : List.of(savedResource.get())); + when(skillResourceService.save(any(SkillResource.class))).thenAnswer(invocation -> { + SkillResource resource = invocation.getArgument(0); + resource.setId(BigInteger.valueOf(10)); + savedResource.set(resource); + return true; + }); + SkillFileSaveRequest request = new SkillFileSaveRequest(); + request.setSkillId(SKILL_ID); + request.setPath("scripts/run.rb"); + request.setContent("puts 'ok'\n"); + + SkillFileContent result = service.createTextFile(request); + + assertEquals("SCRIPT", savedResource.get().getKind()); + assertTrue(savedResource.get().getIsText()); + assertNull(savedResource.get().getLanguage()); + assertEquals("text/plain", savedResource.get().getMediaType()); + assertEquals("puts 'ok'\n", result.getContent()); + } + + /** + * 验证非 Markdown Reference 保存后仍保留按扩展名识别的媒体类型。 + */ + @Test + public void jsonReferenceSavePreservesJsonRepresentation() { + String sourceHash = "d".repeat(64); + SkillResource resource = resource( + "references/data.json", true, "{}", null, sourceHash, 2L); + resource.setKind("REFERENCE"); + resource.setLanguage(null); + resource.setMediaType("application/json"); + when(skillResourceService.list(any(QueryWrapper.class))) + .thenReturn(List.of(resource), List.of(resource)); + when(skillResourceService.update(eq(resource), any(QueryWrapper.class))).thenReturn(true); + SkillFileSaveRequest request = new SkillFileSaveRequest(); + request.setSkillId(SKILL_ID); + request.setPath("references/data.json"); + request.setContent("{\"ok\":true}\n"); + request.setExpectedContentHash(sourceHash); + + SkillFileContent result = service.saveContent(request); + + assertEquals("REFERENCE", resource.getKind()); + assertEquals("application/json", resource.getMediaType()); + assertNull(resource.getLanguage()); + assertEquals("application/json", result.getMediaType()); + assertEquals("{\"ok\":true}\n", result.getContent()); + } + + /** + * 验证保存 SKILL.md 时不会提前修改当前会话中的持久化实体,避免一级缓存导致版本误判。 + */ + @Test + public void skillMarkdownSaveUsesDetachedUpdateForOptimisticCheck() { + String oldContent = "---\nname: demo\ndescription: old\n---\n\n# Old\n"; + String newContent = "---\nname: demo\ndescription: new\n---\n\n# New\n"; + String oldHash = com.easyagents.skill.util.SkillHashes.sha256Hex( + oldContent.getBytes(StandardCharsets.UTF_8)); + Skill persisted = new Skill(); + persisted.setId(SKILL_ID); + persisted.setTenantId(BigInteger.ONE); + persisted.setCategoryId(BigInteger.valueOf(3)); + persisted.setDisplayName("演示 Skill"); + persisted.setEnabled(false); + persisted.setVisibilityScope("DEPT"); + persisted.setSkillContent(oldContent); + persisted.getMetadataJson().put("owner", "qa"); + AtomicReference updateRef = new AtomicReference<>(); + when(skillService.getOne(any(QueryWrapper.class))).thenReturn(persisted); + when(skillService.updateDraftIfContentMatches(any(Skill.class), eq(oldHash))) + .thenAnswer(invocation -> { + updateRef.set(invocation.getArgument(0)); + return persisted; + }); + SkillFileSaveRequest request = new SkillFileSaveRequest(); + request.setSkillId(SKILL_ID); + request.setPath("SKILL.md"); + request.setContent(newContent); + request.setExpectedContentHash(oldHash); + + service.saveContent(request); + + Skill update = updateRef.get(); + assertNotSame(persisted, update); + assertEquals(oldContent, persisted.getSkillContent()); + assertEquals(newContent, update.getSkillContent()); + assertEquals(persisted.getCategoryId(), update.getCategoryId()); + assertEquals(persisted.getDisplayName(), update.getDisplayName()); + assertEquals(persisted.getEnabled(), update.getEnabled()); + assertEquals(persisted.getVisibilityScope(), update.getVisibilityScope()); + assertNotSame(persisted.getMetadataJson(), update.getMetadataJson()); + assertEquals(persisted.getMetadataJson(), update.getMetadataJson()); + } + + /** + * 创建内存上传文件。 + * + * @param filename 文件名 + * @param content 文件内容 + * @return MultipartFile + */ + private MultipartFile multipart(String filename, String content) { + return new TestMultipartFile(filename, content.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 创建测试资源。 + * + * @param path 路径 + * @param text 是否文本 + * @param textContent 文本内容 + * @param contentRef 内容引用 + * @param contentHash 内容 hash + * @param size 字节数 + * @return 资源实体 + */ + private SkillResource resource(String path, + boolean text, + String textContent, + String contentRef, + String contentHash, + long size) { + SkillResource resource = new SkillResource(); + resource.setId(BigInteger.valueOf(8)); + resource.setTenantId(BigInteger.ONE); + resource.setSkillId(SKILL_ID); + resource.setPath(path); + resource.setNormalizedPath(path); + resource.setIsText(text); + resource.setTextContent(textContent); + resource.setContentRef(contentRef); + resource.setContentHash(contentHash); + resource.setSize(size); + return resource; + } + + /** + * 创建重命名请求。 + * + * @param path 原路径 + * @param newPath 新路径 + * @param hash 预期内容 hash + * @return 重命名请求 + */ + private SkillFileRenameRequest renameRequest(String path, String newPath, String hash) { + SkillFileRenameRequest request = new SkillFileRenameRequest(); + request.setSkillId(SKILL_ID); + request.setPath(path); + request.setNewPath(newPath); + request.setExpectedContentHash(hash); + return request; + } + + /** + * 简单内存 MultipartFile 测试替身。 + */ + private static final class TestMultipartFile implements MultipartFile { + + private final String filename; + private final byte[] bytes; + + /** + * 创建测试文件。 + * + * @param filename 文件名 + * @param bytes 内容 + */ + private TestMultipartFile(String filename, byte[] bytes) { + this.filename = filename; + this.bytes = bytes; + } + + @Override public String getName() { return "file"; } + @Override public String getOriginalFilename() { return filename; } + @Override public String getContentType() { return "application/octet-stream"; } + @Override public boolean isEmpty() { return bytes.length == 0; } + @Override public long getSize() { return bytes.length; } + @Override public byte[] getBytes() { return bytes.clone(); } + @Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); } + @Override public void transferTo(File destination) throws IOException { + org.springframework.util.FileCopyUtils.copy(bytes, destination); + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowBundleReaderEntryLimitTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowBundleReaderEntryLimitTest.java new file mode 100644 index 00000000..00263316 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowBundleReaderEntryLimitTest.java @@ -0,0 +1,174 @@ +package tech.easyflow.skill.imports; + +import com.easyagents.skill.model.SkillPackageLimits; +import com.easyagents.skill.exception.SkillPackageException; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; +import org.junit.Test; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; + +/** + * {@link EasyFlowBundleReader} 外层 ZIP 文件数量边界测试。 + */ +public class EasyFlowBundleReaderEntryLimitTest { + + /** + * 验证标准包最大文件数之外允许额外携带一个 EasyFlow manifest。 + */ + @Test + public void containsManifestAllowsOneManifestBeyondStandardEntryLimit() { + int standardEntryLimit = SkillPackageLimits.defaults().getMaxEntryCount(); + byte[] bundle = bundle(standardEntryLimit); + EasyFlowBundleReader reader = new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class)); + + assertTrue(reader.containsManifest(new ByteArrayInputStream(bundle))); + } + + /** + * 验证外层 ZIP 不能借 manifest 配额多携带第二个普通文件。 + */ + @Test + public void containsManifestRejectsMoreThanOneEntryBeyondStandardLimit() { + int standardEntryLimit = SkillPackageLimits.defaults().getMaxEntryCount(); + byte[] bundle = bundle(standardEntryLimit + 1); + EasyFlowBundleReader reader = new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class)); + + assertThrows(BusinessException.class, + () -> reader.containsManifest(new ByteArrayInputStream(bundle))); + } + + /** + * 损坏的增强包属于客户端输入错误,不能伪装成服务端存储故障。 + */ + @Test + public void corruptedBundleUsesClientErrorStatus() { + EasyFlowBundleReader reader = new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class)); + + BusinessException detectionError = assertThrows(BusinessException.class, + () -> reader.containsManifest(new ByteArrayInputStream("not-a-zip".getBytes(StandardCharsets.UTF_8)))); + BusinessException prepareError = assertThrows(BusinessException.class, + () -> reader.prepare(new ByteArrayInputStream("not-a-zip".getBytes(StandardCharsets.UTF_8)))); + + assertEquals(400, detectionError.getHttpStatus()); + assertEquals(400, prepareError.getHttpStatus()); + } + + /** + * manifest 之后的非法原始文件名字节也必须被完整扫描并返回稳定错误码。 + */ + @Test + public void invalidUtf8EntryNameUsesStablePackageCode() { + EasyFlowBundleReader reader = new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class)); + + SkillPackageException detectionError = assertThrows(SkillPackageException.class, + () -> reader.containsManifest(new ByteArrayInputStream(invalidUtf8EntryNameBundle()))); + SkillPackageException prepareError = assertThrows(SkillPackageException.class, + () -> reader.prepare(new ByteArrayInputStream(invalidUtf8EntryNameBundle()))); + + assertEquals("INVALID_UTF8_ENTRY_NAME", detectionError.getCode()); + assertEquals("INVALID_UTF8_ENTRY_NAME", prepareError.getCode()); + } + + /** + * 外层 ZIP 中央目录 CRC 被篡改时必须在重新打包前拒绝,并返回稳定错误码。 + */ + @Test + public void crcMismatchUsesStablePackageCode() { + byte[] corrupted = tamperFirstCentralDirectoryCrc(bundle(1)); + EasyFlowBundleReader reader = new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class)); + + SkillPackageException detectionError = assertThrows(SkillPackageException.class, + () -> reader.containsManifest(new ByteArrayInputStream(corrupted))); + SkillPackageException prepareError = assertThrows(SkillPackageException.class, + () -> reader.prepare(new ByteArrayInputStream(corrupted))); + + assertEquals("CRC_MISMATCH", detectionError.getCode()); + assertEquals("CRC_MISMATCH", prepareError.getCode()); + assertTrue(detectionError.getPath().startsWith("skills/")); + } + + /** + * 创建将 manifest 放在末尾的增强包,以覆盖完整枚举边界。 + * + * @param standardEntries 普通文件数量 + * @return 增强包字节 + */ + private byte[] bundle(int standardEntries) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) { + for (int index = 0; index < standardEntries; index++) { + zip.putNextEntry(new ZipEntry(String.format( + "skills/demo-skill/assets/file-%04d.txt", index))); + zip.closeEntry(); + } + zip.putNextEntry(new ZipEntry(EasyFlowSkillManifestCodec.MANIFEST_PATH)); + zip.write("{}".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + return bytes.toByteArray(); + } catch (Exception exception) { + throw new IllegalStateException("创建增强包文件数量边界样例失败", exception); + } + } + + /** + * 创建 manifest 位于非法文件名前方的恶意增强包,验证检测流程不会提前返回。 + * + * @return 恶意增强包字节 + */ + private byte[] invalidUtf8EntryNameBundle() { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipArchiveOutputStream output = new ZipArchiveOutputStream(bytes)) { + output.setEncoding(StandardCharsets.ISO_8859_1.name()); + output.setUseLanguageEncodingFlag(false); + output.setCreateUnicodeExtraFields(ZipArchiveOutputStream.UnicodeExtraFieldPolicy.NEVER); + + ZipArchiveEntry manifest = new ZipArchiveEntry(EasyFlowSkillManifestCodec.MANIFEST_PATH); + output.putArchiveEntry(manifest); + output.write("{}".getBytes(StandardCharsets.UTF_8)); + output.closeArchiveEntry(); + + ZipArchiveEntry invalidName = new ZipArchiveEntry("skills/demo-skill/assets/\u00ff.bin"); + output.putArchiveEntry(invalidName); + output.write(new byte[]{1}); + output.closeArchiveEntry(); + output.finish(); + } + return bytes.toByteArray(); + } catch (Exception exception) { + throw new IllegalStateException("创建非法 UTF-8 文件名增强包失败", exception); + } + } + + /** + * 篡改首个中央目录条目的 CRC 字段。 + * + * @param source 原始 ZIP + * @return 篡改后的 ZIP + */ + private byte[] tamperFirstCentralDirectoryCrc(byte[] source) { + byte[] bytes = Arrays.copyOf(source, source.length); + for (int index = 0; index <= bytes.length - 20; index++) { + if (bytes[index] == 0x50 && bytes[index + 1] == 0x4B + && bytes[index + 2] == 0x01 && bytes[index + 3] == 0x02) { + bytes[index + 16] ^= 0x01; + return bytes; + } + } + throw new IllegalStateException("未找到 ZIP 中央目录"); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowManifestStrictInputTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowManifestStrictInputTest.java new file mode 100644 index 00000000..611d8546 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowManifestStrictInputTest.java @@ -0,0 +1,84 @@ +package tech.easyflow.skill.imports; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService; + +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; + +/** + * EasyFlow manifest 输入侧敏感配置拒绝测试。 + */ +public class EasyFlowManifestStrictInputTest { + + /** + * 验证导入包中的凭据键会被明确拒绝,不能依靠静默清洗掩盖不合规包。 + */ + @Test + public void decodeShouldRejectCredentialFieldsInsideCapability() { + EasyFlowSkillManifestCodec codec = new EasyFlowSkillManifestCodec( + new ObjectMapper(), mock(SkillCapabilityTargetAccessService.class)); + byte[] manifest = """ + { + "schemaVersion": "1.0", + "skills": [ + { + "packageRoot": "demo-skill", + "packageHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "capabilities": [ + { + "bindingKey": "demo-skill:0", + "capabilityType": "MCP", + "runtimeName": "demo", + "targetLogicalRef": "mcp:demo", + "enabled": false, + "token": "must-not-be-accepted" + } + ] + } + ] + } + """.getBytes(StandardCharsets.UTF_8); + + assertThrows(BusinessException.class, () -> codec.decode(manifest)); + } + + /** + * 验证 options 内出现认证字段时也会在输入边界被拒绝。 + */ + @Test + public void decodeShouldRejectCredentialFieldsInsideOptions() { + EasyFlowSkillManifestCodec codec = new EasyFlowSkillManifestCodec( + new ObjectMapper(), mock(SkillCapabilityTargetAccessService.class)); + byte[] manifest = """ + { + "schemaVersion": "1.0", + "skills": [ + { + "packageRoot": "demo-skill", + "packageHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "capabilities": [ + { + "bindingKey": "demo-skill:0", + "capabilityType": "WORKFLOW", + "runtimeName": "demo", + "targetLogicalRef": "workflow:demo", + "enabled": true, + "options": { + "timeoutMs": 3000, + "authorization": "Bearer must-not-be-accepted" + } + } + ] + } + ] + } + """.getBytes(StandardCharsets.UTF_8); + + assertThrows(BusinessException.class, () -> codec.decode(manifest)); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowSkillManifestCodecTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowSkillManifestCodecTest.java new file mode 100644 index 00000000..ba4532f0 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowSkillManifestCodecTest.java @@ -0,0 +1,406 @@ +package tech.easyflow.skill.imports; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Before; +import org.junit.Test; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.capability.SkillCapabilityTarget; +import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillCapabilityBinding; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * {@link EasyFlowSkillManifestCodec} 安全白名单与输入限额测试。 + */ +public class EasyFlowSkillManifestCodecTest { + + private ObjectMapper objectMapper; + private SkillCapabilityTargetAccessService targetAccessService; + private EasyFlowSkillManifestCodec codec; + + /** + * 初始化 manifest 编解码器。 + */ + @Before + public void setUp() { + objectMapper = new ObjectMapper(); + targetAccessService = mock(SkillCapabilityTargetAccessService.class); + codec = new EasyFlowSkillManifestCodec(objectMapper, targetAccessService); + } + + /** + * 验证增强 manifest 仅导出 HITL/options 白名单字段,不泄露 Token、Header 与嵌套凭据。 + * + * @throws Exception JSON 解析失败 + */ + @Test + public void encodeExportsOnlySafeCapabilityConfiguration() throws Exception { + SkillCapabilityBinding binding = unresolvedBinding(); + Map hitl = new LinkedHashMap<>(); + hitl.put("prompt", "确认执行"); + hitl.put("token", "hitl-secret"); + hitl.put("headers", Map.of("Authorization", "Bearer nested-secret")); + binding.setHitlConfigJson(hitl); + Map options = new LinkedHashMap<>(); + options.put("timeoutMs", 3_000); + options.put("retryCount", 2); + options.put("apiKey", "api-secret"); + options.put("authorization", "Bearer option-secret"); + options.put("readOnly", List.of("complex-value-must-be-dropped")); + binding.setOptionsJson(options); + Skill skill = skillWithBindings(List.of(binding)); + + byte[] encoded = codec.encode(List.of(skill)); + String json = new String(encoded, StandardCharsets.UTF_8); + Map manifest = objectMapper.readValue(encoded, new TypeReference<>() { }); + Map encodedBinding = firstBinding(manifest); + + assertFalse(json.contains("hitl-secret")); + assertFalse(json.contains("nested-secret")); + assertFalse(json.contains("api-secret")); + assertFalse(json.contains("option-secret")); + assertFalse(json.contains("complex-value-must-be-dropped")); + assertEquals(Map.of("prompt", "确认执行"), encodedBinding.get("hitlConfig")); + assertEquals(Map.of("timeoutMs", 3_000, "retryCount", 2), encodedBinding.get("options")); + assertEquals("unresolved:mcp", encodedBinding.get("targetLogicalRef")); + assertFalse(encodedBinding.containsKey("targetId")); + verifyNoInteractions(targetAccessService); + } + + /** + * 验证目标服务返回的凭据 URI、查询 Token 和绝对路径不会进入增强导出。 + * + * @throws Exception JSON 解析失败 + */ + @Test + public void encodeDowngradesUnsafeResolvedTargetMetadata() throws Exception { + SkillCapabilityBinding binding = unresolvedBinding(); + binding.setTargetId(java.math.BigInteger.valueOf(91)); + binding.setEnabled(true); + binding.setTargetLogicalRef("mcp:https://user:secret@example.test?token=stored-secret"); + SkillCapabilityTarget target = new SkillCapabilityTarget(); + target.setLogicalRef("mcp:https://user:secret@example.test?token=resolved-secret"); + target.setName("https://user:secret@example.test/service"); + target.setRevision("/Users/operator/.config/easyflow/credential.json"); + when(targetAccessService.requireUsableTarget(binding, false)).thenReturn(target); + + byte[] encoded = codec.encode(List.of(skillWithBindings(List.of(binding)))); + String json = new String(encoded, StandardCharsets.UTF_8); + Map encodedBinding = firstBinding( + objectMapper.readValue(encoded, new TypeReference<>() { })); + + assertEquals("unresolved:mcp", encodedBinding.get("targetLogicalRef")); + assertFalse(encodedBinding.containsKey("targetName")); + assertFalse(encodedBinding.containsKey("targetRevision")); + assertFalse(encodedBinding.containsKey("targetId")); + assertFalse(json.contains("secret")); + assertFalse(json.contains("/Users/operator")); + } + + /** + * 验证编码和解码都拒绝超过 1 MiB 的 manifest。 + */ + @Test + public void manifestByteLimitIsEnforcedOnEncodeAndDecode() { + SkillCapabilityBinding binding = unresolvedBinding(); + binding.setHitlConfigJson(Map.of("prompt", "x".repeat((int) EasyFlowSkillManifestCodec.MAX_MANIFEST_BYTES))); + + assertThrows(BusinessException.class, + () -> codec.encode(List.of(skillWithBindings(List.of(binding))))); + assertThrows(BusinessException.class, + () -> codec.decode(new byte[(int) EasyFlowSkillManifestCodec.MAX_MANIFEST_BYTES + 1])); + } + + /** + * 验证版本和 skills 基础结构必须存在。 + */ + @Test + public void decodeRejectsUnsupportedVersionAndMissingSkills() { + assertThrows(BusinessException.class, + () -> codec.decode("{\"schemaVersion\":\"2.0\",\"skills\":[]}".getBytes(StandardCharsets.UTF_8))); + assertThrows(BusinessException.class, + () -> codec.decode("{\"schemaVersion\":\"1.0\"}".getBytes(StandardCharsets.UTF_8))); + } + + /** + * 验证 targetLogicalRef 只接受能力类型对应的严格逻辑段语法。 + * + * @throws Exception JSON 生成失败 + */ + @Test + public void decodeRejectsUrlAndQueryInsideTargetLogicalRef() throws Exception { + assertRejectedField("targetLogicalRef", "mcp:https://example.test/service"); + assertRejectedField("targetLogicalRef", "mcp:demo?access_token=must-not-enter"); + assertRejectedField("targetLogicalRef", "mcp:/Users/operator/.config/mcp.json"); + } + + /** + * 验证 targetName 和 targetRevision 拒绝凭据 URI、认证查询参数及绝对路径。 + * + * @throws Exception JSON 生成失败 + */ + @Test + public void decodeRejectsUnsafeTargetNameAndRevision() throws Exception { + assertRejectedField("targetName", "https://user:password@example.test/service"); + assertRejectedField("targetName", "C:\\Users\\operator\\mcp.json"); + assertRejectedField("targetRevision", "https://example.test/revision?token=must-not-enter"); + assertRejectedField("targetRevision", "%2FUsers%2Foperator%2Fcredential.json"); + assertRejectedField("targetRevision", "https%253A%252F%252Fexample.test%253Ftoken%253Dencoded-secret"); + } + + /** + * 验证增强导入拒绝允许字段字符串内部的实际凭据,并保留精确问题路径。 + * + * @throws Exception JSON 生成失败 + */ + @Test + public void decodeRejectsCredentialInsideAllowedHitlString() throws Exception { + Map binding = validManifestBinding(); + binding.put("hitlConfig", Map.of("prompt", "Authorization: Bearer actual-secret-value")); + byte[] manifest = manifestWithBinding(binding); + + SkillManifestValidationException exception = assertThrows( + SkillManifestValidationException.class, () -> codec.decode(manifest)); + + assertEquals("SENSITIVE_VALUE_DETECTED", exception.getValidationCode()); + assertEquals("skills[0].capabilities[0].hitlConfig.prompt", exception.getPath()); + assertFalse(exception.getMessage().contains("actual-secret-value")); + } + + /** + * 验证增强导入会扫描运行时名称、工具名和目标逻辑引用等全部字符串面。 + * + * @throws Exception JSON 生成失败 + */ + @Test + public void decodeRejectsCredentialsAcrossAllBindingStrings() throws Exception { + Map runtimeBinding = validManifestBinding(); + runtimeBinding.put("runtimeName", "sk-proj-abcdefghijklmnopqrstuvwxyz123456"); + assertSensitivePath(runtimeBinding, "skills[0].capabilities[0].runtimeName"); + + Map toolBinding = validManifestBinding(); + toolBinding.put("selectedToolNames", List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456")); + assertSensitivePath(toolBinding, "skills[0].capabilities[0].selectedToolNames[0]"); + + Map targetBinding = validManifestBinding(); + targetBinding.put("targetLogicalRef", "mcp:sk-proj-abcdefghijklmnopqrstuvwxyz123456"); + assertSensitivePath(targetBinding, "skills[0].capabilities[0].targetLogicalRef"); + } + + /** + * 验证 options 只接受协议定义的数值和布尔类型。 + * + * @throws Exception JSON 生成失败 + */ + @Test + public void decodeRejectsStringTypedOptionsWithExactPath() throws Exception { + Map binding = validManifestBinding(); + binding.put("options", Map.of("timeoutMs", "3000")); + + SkillManifestValidationException exception = assertThrows( + SkillManifestValidationException.class, () -> codec.decode(manifestWithBinding(binding))); + + assertEquals("CAPABILITY_OPTION_VALUE_INVALID", exception.getValidationCode()); + assertEquals("skills[0].capabilities[0].options.timeoutMs", exception.getPath()); + } + + /** + * 验证非法枚举错误使用稳定消息且不回显原始输入。 + * + * @throws Exception JSON 生成失败 + */ + @Test + public void decodeDoesNotEchoInvalidEnumValue() throws Exception { + Map binding = validManifestBinding(); + binding.put("capabilityType", "UNSUPPORTED_PRIVATE_VALUE"); + + SkillManifestValidationException exception = assertThrows( + SkillManifestValidationException.class, () -> codec.decode(manifestWithBinding(binding))); + + assertEquals("CAPABILITY_TYPE_INVALID", exception.getValidationCode()); + assertEquals("skills[0].capabilities[0].capabilityType", exception.getPath()); + assertFalse(exception.getMessage().contains("UNSUPPORTED_PRIVATE_VALUE")); + } + + /** + * 验证增强导出遇到遗留脏 HITL 配置时直接失败且不回显凭据。 + */ + @Test + public void encodeRejectsDirtyHitlCredentialInsteadOfExportingIt() { + SkillCapabilityBinding binding = unresolvedBinding(); + binding.setHitlConfigJson(Map.of("description", "token=actual-secret-value")); + + SkillManifestValidationException exception = assertThrows( + SkillManifestValidationException.class, + () -> codec.encode(List.of(skillWithBindings(List.of(binding))))); + + assertEquals("SENSITIVE_VALUE_DETECTED", exception.getValidationCode()); + assertFalse(exception.getMessage().contains("actual-secret-value")); + } + + /** + * 验证增强包输入不能携带当前环境数据库目标 ID。 + * + * @throws Exception JSON 生成失败 + */ + @Test + public void decodeRejectsInternalTargetId() throws Exception { + assertRejectedField("targetId", 99887766); + } + + /** + * 验证 Codec 边界拒绝超过导入上限的 Skill 数量。 + * + * @throws Exception JSON 生成失败 + */ + @Test + public void decodeRejectsMoreThanOneHundredSkills() throws Exception { + List> skills = new ArrayList<>(); + for (int index = 0; index < 101; index++) { + skills.add(Map.of("packageRoot", "skill-" + index, "capabilities", List.of())); + } + byte[] bytes = objectMapper.writeValueAsBytes(Map.of("schemaVersion", "1.0", "skills", skills)); + + assertThrows(BusinessException.class, () -> codec.decode(bytes)); + } + + /** + * 验证 Codec 边界拒绝超长 packageRoot 字段。 + * + * @throws Exception JSON 生成失败 + */ + @Test + public void decodeRejectsOversizedPackageRoot() throws Exception { + Map skill = Map.of( + "packageRoot", "s".repeat(129), + "capabilities", List.of()); + byte[] bytes = objectMapper.writeValueAsBytes( + Map.of("schemaVersion", "1.0", "skills", List.of(skill))); + + assertThrows(BusinessException.class, () -> codec.decode(bytes)); + } + + /** + * 创建包含未解析能力的 Skill。 + * + * @param bindings 能力绑定 + * @return Skill + */ + private Skill skillWithBindings(List bindings) { + Skill skill = new Skill(); + skill.setName("demo-skill"); + skill.setPackageHash("package-hash"); + skill.setCapabilityBindings(bindings); + return skill; + } + + /** + * 创建无需读取目标资源的未解析能力绑定。 + * + * @return 能力绑定 + */ + private SkillCapabilityBinding unresolvedBinding() { + SkillCapabilityBinding binding = new SkillCapabilityBinding(); + binding.setCapabilityType("MCP"); + binding.setTargetLogicalRef("mcp://demo"); + binding.setRuntimeName("demo_mcp"); + binding.setEnabled(false); + binding.setSelectionMode("SELECTED"); + binding.setSelectedToolNamesJson(List.of("search")); + binding.setHitlEnabled(true); + binding.setSortNo(0); + return binding; + } + + /** + * 构造单绑定 manifest 并断言指定覆盖字段被拒绝。 + * + * @param field 覆盖字段 + * @param value 覆盖值 + * @throws Exception JSON 生成失败 + */ + private void assertRejectedField(String field, Object value) throws Exception { + Map binding = validManifestBinding(); + binding.put(field, value); + byte[] manifest = manifestWithBinding(binding); + + assertThrows(BusinessException.class, () -> codec.decode(manifest)); + } + + /** + * 断言单绑定中的凭据值被拒绝并保留精确路径。 + * + * @param binding 待编码能力对象 + * @param expectedPath 预期问题路径 + * @throws Exception JSON 生成失败 + */ + private void assertSensitivePath(Map binding, String expectedPath) throws Exception { + SkillManifestValidationException exception = assertThrows( + SkillManifestValidationException.class, () -> codec.decode(manifestWithBinding(binding))); + assertEquals("SENSITIVE_VALUE_DETECTED", exception.getValidationCode()); + assertEquals(expectedPath, exception.getPath()); + assertFalse(exception.getMessage().contains("sk-proj-")); + } + + /** + * 创建单绑定的合法 manifest 能力对象。 + * + * @return 可按测试覆盖字段的能力对象 + */ + private Map validManifestBinding() { + Map binding = new LinkedHashMap<>(); + binding.put("bindingKey", "demo-skill:0"); + binding.put("capabilityType", "MCP"); + binding.put("runtimeName", "demo_mcp"); + binding.put("enabled", false); + binding.put("selectionMode", "SELECTED"); + binding.put("selectedToolNames", List.of("search")); + binding.put("targetLogicalRef", "mcp:demo"); + return binding; + } + + /** + * 将单个能力对象封装为合法 manifest JSON。 + * + * @param binding 能力对象 + * @return manifest JSON 字节 + * @throws Exception JSON 生成失败 + */ + private byte[] manifestWithBinding(Map binding) throws Exception { + Map skill = new LinkedHashMap<>(); + skill.put("packageRoot", "demo-skill"); + skill.put("packageHash", "a".repeat(64)); + skill.put("capabilities", List.of(binding)); + return objectMapper.writeValueAsBytes( + Map.of("schemaVersion", "1.0", "skills", List.of(skill))); + } + + /** + * 获取编码结果中的首个能力绑定。 + * + * @param manifest manifest + * @return 能力绑定映射 + */ + @SuppressWarnings("unchecked") + private Map firstBinding(Map manifest) { + List> skills = (List>) manifest.get("skills"); + List> bindings = (List>) skills.get(0).get("capabilities"); + assertTrue(!bindings.isEmpty()); + return bindings.get(0); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillExportFormatIsolationTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillExportFormatIsolationTest.java new file mode 100644 index 00000000..c3deb146 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillExportFormatIsolationTest.java @@ -0,0 +1,192 @@ +package tech.easyflow.skill.imports; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.easyagents.skill.util.SkillHashes; +import org.junit.Before; +import org.junit.Test; +import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.entity.SkillResource; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.store.DBSkillContentStore; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.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; + +/** + * 标准 Skill 包与 EasyFlow 增强包的格式隔离回归测试。 + */ +public class SkillExportFormatIsolationTest { + + private static final String SKILL_ID = "987654321012345678"; + private static final String TARGET_ID = "998877665544332211"; + private static final byte[] BINARY_BYTES = new byte[]{0, 1, 2, 3, 127, -1}; + + private SkillExportServiceImpl exportService; + private SkillService skillService; + + /** + * 初始化包含平台能力绑定的 Skill。 + */ + @Before + public void setUp() { + Skill skill = new Skill(); + skill.setId(new BigInteger(SKILL_ID)); + skill.setTenantId(BigInteger.valueOf(55667788)); + skill.setCategoryId(BigInteger.valueOf(66778899)); + skill.setCurrentApprovalInstanceId(BigInteger.valueOf(77889900)); + skill.setName("demo-skill"); + skill.setDescription("Demo skill"); + skill.setSkillContent(""" + --- + name: demo-skill + description: Demo skill + --- + # Demo + """); + skill.setPackageHash("a".repeat(64)); + String binaryHash = SkillHashes.sha256Hex(BINARY_BYTES); + String binaryRef = "sha256:" + binaryHash; + SkillResource reference = new SkillResource(); + reference.setPath("references/guide.md"); + reference.setNormalizedPath("references/guide.md"); + reference.setKind("REFERENCE"); + reference.setMediaType("text/markdown"); + reference.setIsText(true); + reference.setTextContent("# Guide\nportable text\n"); + reference.setContentHash(SkillHashes.sha256Hex( + reference.getTextContent().getBytes(StandardCharsets.UTF_8))); + reference.setSize((long) reference.getTextContent().getBytes(StandardCharsets.UTF_8).length); + SkillResource binary = new SkillResource(); + binary.setPath("assets/data.bin"); + binary.setNormalizedPath("assets/data.bin"); + binary.setKind("ASSET"); + binary.setMediaType("application/octet-stream"); + binary.setIsText(false); + binary.setContentRef(binaryRef); + binary.setContentHash(binaryHash); + binary.setSize((long) BINARY_BYTES.length); + skill.setResources(List.of(reference, binary)); + SkillCapabilityBinding binding = new SkillCapabilityBinding(); + binding.setCapabilityType("MCP"); + binding.setTargetId(new BigInteger(TARGET_ID)); + binding.setRuntimeName("demo"); + binding.setTargetLogicalRef("mcp:demo"); + binding.setEnabled(false); + binding.setSelectionMode("SELECTED"); + binding.setSelectedToolNamesJson(List.of("search")); + binding.setOptionsJson(Map.of("timeoutMs", 3000, "token", "must-not-leak")); + skill.setCapabilityBindings(List.of(binding)); + skillService = mock(SkillService.class); + when(skillService.getPackageDetail(skill.getId())).thenReturn(skill); + when(skillService.getDetail(skill.getId())).thenReturn(skill); + EasyFlowSkillManifestCodec manifestCodec = new EasyFlowSkillManifestCodec( + new ObjectMapper(), mock(SkillCapabilityTargetAccessService.class)); + DBSkillContentStore contentStore = mock(DBSkillContentStore.class); + when(contentStore.exists(binaryRef)).thenReturn(true); + when(contentStore.open(binaryRef)).thenAnswer(ignored -> new ByteArrayInputStream(BINARY_BYTES)); + exportService = new SkillExportServiceImpl(skillService, contentStore, manifestCodec); + } + + /** + * 验证标准 ZIP 只包含标准 Skill 内容,不携带 EasyFlow manifest 或平台能力配置。 + */ + @Test + public void standardExportShouldExcludePlatformManifestAndBindings() { + when(skillService.getDetail(new BigInteger(SKILL_ID))) + .thenThrow(new IllegalStateException("capability target unavailable")); + + Map entries = export(SkillImportFormat.STANDARD); + String allText = text(entries); + + assertTrue(entries.keySet().stream().anyMatch(path -> path.endsWith("/SKILL.md"))); + assertTrue(entries.keySet().stream().anyMatch(path -> path.endsWith("/references/"))); + assertTrue(entries.keySet().stream().anyMatch(path -> path.endsWith("/scripts/"))); + assertTrue(entries.keySet().stream().anyMatch(path -> path.endsWith("/assets/"))); + assertTrue(entries.keySet().stream().anyMatch(path -> path.endsWith("/references/guide.md"))); + assertTrue(entries.entrySet().stream().anyMatch(entry -> entry.getKey().endsWith("/assets/data.bin") + && java.util.Arrays.equals(BINARY_BYTES, entry.getValue()))); + assertFalse(entries.containsKey(EasyFlowSkillManifestCodec.MANIFEST_PATH)); + assertFalse(allText.contains("targetLogicalRef")); + assertFalse(allText.contains("must-not-leak")); + assertFalse(allText.contains(SKILL_ID)); + assertFalse(allText.contains(TARGET_ID)); + assertFalse(entries.keySet().stream().anyMatch(path -> path.contains(SKILL_ID))); + assertFalse(entries.keySet().stream().anyMatch(path -> path.contains(TARGET_ID))); + verify(skillService).getPackageDetail(new BigInteger(SKILL_ID)); + verify(skillService, never()).getDetail(new BigInteger(SKILL_ID)); + } + + /** + * 验证增强包具有独立 manifest,且敏感 options 不会进入导出内容。 + */ + @Test + public void easyFlowExportShouldContainSafeManifestAndStandardSkillTree() { + Map entries = export(SkillImportFormat.EASYFLOW); + String manifest = new String(entries.get(EasyFlowSkillManifestCodec.MANIFEST_PATH), StandardCharsets.UTF_8); + + assertTrue(entries.keySet().stream().anyMatch(path -> path.startsWith("skills/") && path.endsWith("/SKILL.md"))); + assertTrue(entries.keySet().stream().anyMatch(path -> path.startsWith("skills/") + && path.endsWith("/references/"))); + assertTrue(entries.keySet().stream().anyMatch(path -> path.startsWith("skills/") + && path.endsWith("/scripts/"))); + assertTrue(entries.keySet().stream().anyMatch(path -> path.startsWith("skills/") + && path.endsWith("/assets/"))); + assertTrue(entries.entrySet().stream().anyMatch(entry -> entry.getKey().startsWith("skills/") + && entry.getKey().endsWith("/assets/data.bin") + && java.util.Arrays.equals(BINARY_BYTES, entry.getValue()))); + assertTrue(manifest.contains("targetLogicalRef")); + assertTrue(manifest.contains("timeoutMs")); + assertFalse(manifest.contains("must-not-leak")); + assertFalse(manifest.contains("targetId")); + assertFalse(manifest.contains(SKILL_ID)); + assertFalse(manifest.contains(TARGET_ID)); + assertFalse(entries.keySet().stream().anyMatch(path -> path.contains(SKILL_ID))); + assertFalse(entries.keySet().stream().anyMatch(path -> path.contains(TARGET_ID))); + } + + private Map export(SkillImportFormat format) { + try (SkillExportArtifact artifact = exportService.prepare(List.of(new BigInteger(SKILL_ID)), format)) { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + artifact.transferTo(bytes); + return unzip(bytes.toByteArray()); + } + } + + private Map unzip(byte[] bytes) { + try { + Map entries = new LinkedHashMap<>(); + try (ZipInputStream zip = new ZipInputStream( + new ByteArrayInputStream(bytes), StandardCharsets.UTF_8)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + entries.put(entry.getName(), entry.isDirectory() ? new byte[0] : zip.readAllBytes()); + } + } + return entries; + } catch (Exception exception) { + throw new IllegalStateException("读取测试导出包失败", exception); + } + } + + private String text(Map entries) { + StringBuilder result = new StringBuilder(); + entries.values().forEach(bytes -> result.append(new String(bytes, StandardCharsets.UTF_8))); + return result.toString(); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillExportRoundTripTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillExportRoundTripTest.java new file mode 100644 index 00000000..022a8131 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillExportRoundTripTest.java @@ -0,0 +1,227 @@ +package tech.easyflow.skill.imports; + +import com.easyagents.skill.util.SkillHashes; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.skill.capability.SkillCapabilityBindingService; +import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillImportStage; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.store.DBSkillContentStore; +import tech.easyflow.system.service.ResourceAccessService; +import tech.easyflow.skill.entity.SkillResource; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * 多 Skill 增强导出的可移植路径与 preview round-trip 测试。 + */ +public class SkillExportRoundTripTest { + + /** + * 验证多 Skill `.efskill` 不在路径中暴露数据库 ID,且可被增强导入 preview 完整解析。 + */ + @Test + public void multiSkillEasyFlowBundleShouldRoundTripWithoutDatabaseIds() throws IOException { + BigInteger firstId = new BigInteger("987654321012345678"); + BigInteger secondId = new BigInteger("887766554433221100"); + Skill first = portableSkill(firstId, "alpha-skill"); + Skill second = portableSkill(secondId, "beta-skill"); + SkillService exportSkillService = mock(SkillService.class); + when(exportSkillService.getPackageDetail(firstId)).thenReturn(first); + when(exportSkillService.getPackageDetail(secondId)).thenReturn(second); + when(exportSkillService.getDetail(firstId)).thenReturn(first); + when(exportSkillService.getDetail(secondId)).thenReturn(second); + SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class); + EasyFlowSkillManifestCodec manifestCodec = new EasyFlowSkillManifestCodec( + new ObjectMapper(), targetAccessService); + SkillExportServiceImpl exportService = new SkillExportServiceImpl( + exportSkillService, mock(DBSkillContentStore.class), manifestCodec); + + byte[] standard = export(exportService, List.of(firstId, secondId), SkillImportFormat.STANDARD); + Set standardPaths = paths(standard); + assertTrue(standardPaths.stream().anyMatch(path -> path.endsWith("alpha-skill/SKILL.md"))); + assertTrue(standardPaths.stream().anyMatch(path -> path.endsWith("beta-skill/SKILL.md"))); + assertFalse(standardPaths.stream().anyMatch(path -> path.contains(firstId.toString()))); + assertFalse(standardPaths.stream().anyMatch(path -> path.contains(secondId.toString()))); + + byte[] bundle = export(exportService, List.of(firstId, secondId), SkillImportFormat.EASYFLOW); + Set paths = paths(bundle); + + assertTrue(paths.contains(EasyFlowSkillManifestCodec.MANIFEST_PATH)); + assertTrue(paths.stream().anyMatch(path -> path.endsWith("alpha-skill/SKILL.md"))); + assertTrue(paths.stream().anyMatch(path -> path.endsWith("beta-skill/SKILL.md"))); + assertFalse(paths.stream().anyMatch(path -> path.contains(firstId.toString()))); + assertFalse(paths.stream().anyMatch(path -> path.contains(secondId.toString()))); + + FileStorageService fileStorageService = mock(FileStorageService.class); + String storedPath = "skill-imports/round-trip.efskill"; + when(fileStorageService.save(any(MultipartFile.class), anyString())).thenReturn(storedPath); + when(fileStorageService.readStream(storedPath)) + .thenAnswer(ignored -> new ByteArrayInputStream(bundle)); + SkillImportStage stage = new SkillImportStage(); + stage.setImportToken("a".repeat(32)); + stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000)); + SkillImportStageStore stageStore = mock(SkillImportStageStore.class); + when(stageStore.create(anyString(), anyString(), any(SkillImportFormat.class))).thenReturn(stage); + SkillService importSkillService = mock(SkillService.class); + when(importSkillService.list(any(QueryWrapper.class))).thenReturn(List.of()); + SkillImportServiceImpl importService = new SkillImportServiceImpl( + importSkillService, + mock(SkillCapabilityBindingService.class), + targetAccessService, + mock(DBSkillContentStore.class), + fileStorageService, + stageStore, + new EasyFlowBundleReader(manifestCodec), + mock(ResourceAccessService.class)); + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(7)); + account.setTenantId(BigInteger.ONE); + + SkillImportPreview preview; + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + preview = importService.preview(new TestMultipartFile("skills.efskill", bundle)); + } + + assertEquals(SkillImportFormat.EASYFLOW.name(), preview.getFormat()); + assertEquals(2, preview.getSkills().size()); + assertEquals(Set.of("alpha-skill", "beta-skill"), preview.getSkills().stream() + .map(SkillImportPreviewItem::getPackageRoot).collect(java.util.stream.Collectors.toSet())); + assertTrue(preview.getSkills().stream().allMatch(item -> item.getFiles().stream() + .anyMatch(file -> "SKILL.md".equals(file.getPath()) && file.isText()))); + assertTrue(preview.getSkills().stream().allMatch(item -> item.getFiles().stream() + .anyMatch(file -> "examples/readme.md".equals(file.getPath()) + && "EXAMPLE".equals(file.getKind()) && file.isText()))); + } + + /** + * 创建仅含入口文档的可移植 Skill。 + * + * @param id 数据库 ID + * @param name 标准 Skill 名称 + * @return Skill 实体 + */ + private Skill portableSkill(BigInteger id, String name) { + String content = "---\nname: " + name + "\ndescription: Portable " + name + "\n---\n# " + name + "\n"; + String exampleContent = "# Example\n"; + String exampleHash = SkillHashes.sha256Hex(exampleContent.getBytes(StandardCharsets.UTF_8)); + String canonical = "SKILL.md\n" + + SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)) + "\n" + + "examples/readme.md\n" + exampleHash + "\n"; + SkillResource example = new SkillResource(); + example.setPath("examples/readme.md"); + example.setNormalizedPath("examples/readme.md"); + example.setKind("EXAMPLE"); + example.setMediaType("text/markdown"); + example.setIsText(true); + example.setTextContent(exampleContent); + example.setContentHash(exampleHash); + example.setSize((long) exampleContent.getBytes(StandardCharsets.UTF_8).length); + Skill skill = new Skill(); + skill.setId(id); + skill.setName(name); + skill.setDescription("Portable " + name); + skill.setSkillContent(content); + skill.setPackageHash(SkillHashes.sha256Hex(canonical.getBytes(StandardCharsets.UTF_8))); + skill.setResources(List.of(example)); + skill.setCapabilityBindings(List.of()); + return skill; + } + + /** + * 导出指定格式的包字节。 + * + * @param service 导出服务 + * @param ids Skill ID + * @param format 包格式 + * @return 导出包字节 + */ + private byte[] export(SkillExportServiceImpl service, + List ids, + SkillImportFormat format) { + try (SkillExportArtifact artifact = service.prepare(ids, format)) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + artifact.transferTo(output); + return output.toByteArray(); + } + } + + /** + * 读取 ZIP 非目录项路径。 + * + * @param bytes ZIP 字节 + * @return 条目路径 + */ + private Set paths(byte[] bytes) { + try { + Set result = new LinkedHashSet<>(); + try (ZipInputStream zip = new ZipInputStream( + new ByteArrayInputStream(bytes), StandardCharsets.UTF_8)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + if (!entry.isDirectory()) { + result.add(entry.getName()); + } + } + } + return result; + } catch (IOException exception) { + throw new IllegalStateException("读取 round-trip 导出包失败", exception); + } + } + + /** + * 内存 MultipartFile 测试替身。 + */ + private static final class TestMultipartFile implements MultipartFile { + + private final String filename; + private final byte[] bytes; + + private TestMultipartFile(String filename, byte[] bytes) { + this.filename = filename; + this.bytes = bytes; + } + + @Override public String getName() { return "file"; } + @Override public String getOriginalFilename() { return filename; } + @Override public String getContentType() { return "application/vnd.easyflow.skill+zip"; } + @Override public boolean isEmpty() { return bytes.length == 0; } + @Override public long getSize() { return bytes.length; } + @Override public byte[] getBytes() { return bytes.clone(); } + @Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); } + @Override public void transferTo(File destination) throws IOException { + org.springframework.util.FileCopyUtils.copy(bytes, destination); + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportConflictPrivacyTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportConflictPrivacyTest.java new file mode 100644 index 00000000..977aa115 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportConflictPrivacyTest.java @@ -0,0 +1,258 @@ +package tech.easyflow.skill.imports; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Test; +import org.mockito.MockedStatic; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.capability.SkillCapabilityBindingService; +import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillImportStage; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.store.DBSkillContentStore; +import tech.easyflow.system.service.ResourceAccessService; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link SkillImportServiceImpl} 同名冲突隐私回归测试。 + */ +public class SkillImportConflictPrivacyTest { + + private static final BigInteger ACCOUNT_ID = BigInteger.valueOf(7); + private static final BigInteger TENANT_ID = BigInteger.ONE; + private static final String IMPORT_TOKEN = "c".repeat(32); + private static final String SKILL_NAME = "private-skill"; + private static final String STORED_PATH = "skill-imports/private-skill.zip"; + + /** + * 验证无管理权的草稿和已发布 Skill 在预览中完全使用相同冲突结果。 + */ + @Test + public void previewRedactsUnauthorizedDraftAndPublishedConflicts() { + Skill draft = existing("private-draft", PublishStatus.DRAFT); + Skill published = existing("private-published", PublishStatus.PUBLISHED); + SkillService skillService = mock(SkillService.class); + when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of(draft, published)); + ResourceAccessService accessService = mock(ResourceAccessService.class); + SkillImportServiceImpl service = service(skillService, accessService, + mock(FileStorageService.class), mock(SkillImportStageStore.class)); + + SkillImportPreview preview; + try (MockedStatic ignored = login()) { + preview = service.previewStandardForTest(new ByteArrayInputStream( + standardPackage(List.of(draft.getName(), published.getName())))); + } + + assertEquals(2, preview.getSkills().size()); + for (SkillImportPreviewItem item : preview.getSkills()) { + assertEquals("NAME_UNAVAILABLE", item.getConflictReason()); + assertFalse(item.getOverwriteAllowed()); + } + } + + /** + * 验证确认阶段不会根据无权 Skill 的草稿或发布状态返回不同结果。 + */ + @Test + public void confirmRejectsUnauthorizedDraftAndPublishedWithSameResult() { + BusinessException draft = confirmAgainstUnauthorized(PublishStatus.DRAFT); + BusinessException published = confirmAgainstUnauthorized(PublishStatus.PUBLISHED); + + assertNameUnavailable(draft); + assertNameUnavailable(published); + assertEquals(draft.getMessage(), published.getMessage()); + } + + /** + * 验证预查后发生的并发唯一键冲突与无权同名冲突使用同一公开结果。 + */ + @Test + public void confirmMapsConcurrentUniqueNameConflictToNameUnavailable() { + SkillService skillService = mock(SkillService.class); + when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of()); + when(skillService.saveDraft(any(Skill.class))) + .thenThrow(new BusinessException(409, 4092, "当前租户已存在同名 Skill")); + FileStorageService storage = storedPackage(); + SkillImportStageStore stageStore = stageStore(); + SkillImportServiceImpl service = service(skillService, mock(ResourceAccessService.class), storage, stageStore); + SkillImportConfirmRequest request = confirmRequest(SkillImportConflictStrategy.REJECT); + + BusinessException exception; + try (MockedStatic ignored = login()) { + exception = assertThrows(BusinessException.class, () -> service.confirm(request)); + } + + assertNameUnavailable(exception); + } + + /** + * 针对指定发布状态执行一次无管理权限的覆盖确认。 + * + * @param status 已存在 Skill 的发布状态 + * @return 确认阶段抛出的名称不可用异常 + */ + private BusinessException confirmAgainstUnauthorized(PublishStatus status) { + Skill existing = existing(SKILL_NAME, status); + SkillService skillService = mock(SkillService.class); + when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of(existing)); + ResourceAccessService accessService = mock(ResourceAccessService.class); + SkillImportServiceImpl service = service(skillService, accessService, storedPackage(), stageStore()); + + BusinessException exception; + try (MockedStatic ignored = login()) { + exception = assertThrows(BusinessException.class, + () -> service.confirm(confirmRequest(SkillImportConflictStrategy.OVERWRITE))); + } + verify(skillService, never()).overwriteImportedDraft(any(Skill.class)); + return exception; + } + + /** + * 创建仅包含当前测试所需依赖的导入服务。 + * + * @param skillService Skill 管理服务 + * @param accessService 资源访问服务 + * @param storage 文件存储服务 + * @param stageStore 导入暂存服务 + * @return 导入服务实例 + */ + private SkillImportServiceImpl service(SkillService skillService, + ResourceAccessService accessService, + FileStorageService storage, + SkillImportStageStore stageStore) { + return new SkillImportServiceImpl(skillService, mock(SkillCapabilityBindingService.class), + mock(SkillCapabilityTargetAccessService.class), mock(DBSkillContentStore.class), storage, stageStore, + mock(EasyFlowBundleReader.class), accessService); + } + + /** + * 创建指定名称和发布状态的既有 Skill。 + * + * @param name Skill 名称 + * @param status 发布状态 + * @return Skill 测试数据 + */ + private Skill existing(String name, PublishStatus status) { + Skill skill = new Skill(); + skill.setId(BigInteger.valueOf(Math.abs(name.hashCode()))); + skill.setTenantId(TENANT_ID); + skill.setName(name); + skill.setPublishStatus(status.getCode()); + return skill; + } + + /** + * 创建导入确认请求。 + * + * @param strategy 名称冲突处理策略 + * @return 导入确认请求 + */ + private SkillImportConfirmRequest confirmRequest(SkillImportConflictStrategy strategy) { + SkillImportConfirmRequest request = new SkillImportConfirmRequest(); + request.setImportToken(IMPORT_TOKEN); + request.setConflictStrategy(strategy.name()); + return request; + } + + /** + * 创建可消费固定导入记录的暂存服务替身。 + * + * @return 导入暂存服务替身 + */ + private SkillImportStageStore stageStore() { + SkillImportStageStore stageStore = mock(SkillImportStageStore.class); + SkillImportStage stage = new SkillImportStage(); + stage.setImportToken(IMPORT_TOKEN); + stage.setFilePath(STORED_PATH); + stage.setFormat(SkillImportFormat.STANDARD.name()); + stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000)); + when(stageStore.consume(IMPORT_TOKEN)).thenReturn(stage); + return stageStore; + } + + /** + * 创建返回标准 Skill 测试包的文件存储替身。 + * + * @return 文件存储服务替身 + */ + private FileStorageService storedPackage() { + byte[] bytes = standardPackage(List.of(SKILL_NAME)); + FileStorageService storage = mock(FileStorageService.class); + try { + when(storage.readStream(STORED_PATH)).thenAnswer(ignored -> new ByteArrayInputStream(bytes)); + } catch (java.io.IOException exception) { + throw new IllegalStateException("创建导入包存储测试替身失败", exception); + } + return storage; + } + + /** + * 构造包含指定 Skill 名称的标准 ZIP 包。 + * + * @param names Skill 名称列表 + * @return ZIP 包字节 + */ + private byte[] standardPackage(List names) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) { + for (String name : names) { + zip.putNextEntry(new ZipEntry(name + "/SKILL.md")); + zip.write(("---\nname: " + name + "\ndescription: Privacy fixture\n---\n# Privacy\n") + .getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + } + return bytes.toByteArray(); + } catch (Exception exception) { + throw new IllegalStateException("创建导入冲突测试包失败", exception); + } + } + + /** + * 创建固定租户与账号的登录上下文。 + * + * @return 可自动关闭的静态方法替身 + */ + private MockedStatic login() { + LoginAccount account = new LoginAccount(); + account.setId(ACCOUNT_ID); + account.setTenantId(TENANT_ID); + MockedStatic saToken = mockStatic(SaTokenUtil.class); + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + return saToken; + } + + /** + * 断言异常为稳定、无资源细节的名称不可用结果。 + * + * @param exception 待校验的业务异常 + */ + private void assertNameUnavailable(BusinessException exception) { + assertEquals(409, exception.getHttpStatus()); + assertEquals(4092, exception.getErrorCode()); + assertEquals("Skill 名称不可用:" + SKILL_NAME, exception.getMessage()); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportServiceImplPreviewTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportServiceImplPreviewTest.java new file mode 100644 index 00000000..4279b5fa --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportServiceImplPreviewTest.java @@ -0,0 +1,430 @@ +package tech.easyflow.skill.imports; + +import com.mybatisflex.core.query.QueryWrapper; +import com.easyagents.skill.util.SkillHashes; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.ai.permission.McpAccessPermissionChecker; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.capability.SkillCapabilityBindingService; +import tech.easyflow.skill.capability.SkillCapabilityBindingServiceImpl; +import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService; +import tech.easyflow.skill.entity.SkillImportStage; +import tech.easyflow.skill.enums.SkillCapabilityType; +import tech.easyflow.skill.mapper.SkillMapper; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.store.DBSkillContentStore; +import tech.easyflow.system.service.ResourceAccessService; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link SkillImportServiceImpl} 只读导入预检契约测试。 + */ +public class SkillImportServiceImplPreviewTest { + + /** + * 验证可解析但校验失败的包返回完整问题列表,二进制资源不会触发正式提交。 + */ + @Test + public void previewReturnsValidationReportForParseableInvalidPackage() { + SkillService skillService = mock(SkillService.class); + when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of()); + SkillImportServiceImpl service = new SkillImportServiceImpl( + skillService, + mock(SkillCapabilityBindingService.class), + mock(SkillCapabilityTargetAccessService.class), + mock(DBSkillContentStore.class), + mock(FileStorageService.class), + mock(SkillImportStageStore.class), + mock(EasyFlowBundleReader.class), + mock(ResourceAccessService.class)); + + SkillImportPreview preview; + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(7)); + account.setTenantId(BigInteger.ONE); + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + preview = service.previewStandardForTest(new ByteArrayInputStream(invalidPackage())); + } + + assertEquals("STANDARD", preview.getFormat()); + assertEquals(1, preview.getSkills().size()); + assertEquals(1, preview.getSkills().get(0).getAssetCount()); + assertTrue(preview.getIssues().stream().anyMatch(issue -> "INVALID_NAME".equals(issue.getCode()))); + } + + /** + * 验证非法 EasyFlow Bundle 也返回结构化预览问题,不生成一次性导入令牌。 + */ + @Test + public void previewReturnsStructuredIssueForInvalidEasyFlowBundle() throws Exception { + FileStorageService fileStorageService = mock(FileStorageService.class); + EasyFlowBundleReader bundleReader = mock(EasyFlowBundleReader.class); + SkillImportStageStore stageStore = mock(SkillImportStageStore.class); + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(128L); + when(file.getOriginalFilename()).thenReturn("invalid.efskill"); + when(fileStorageService.save(file, "skill-imports/1")).thenReturn("stored-invalid-bundle"); + when(fileStorageService.readStream("stored-invalid-bundle")) + .thenAnswer(invocation -> new ByteArrayInputStream(new byte[]{1, 2, 3})); + when(bundleReader.containsManifest(any())).thenReturn(true); + when(bundleReader.prepare(any())).thenThrow(new BusinessException("EasyFlow manifest 包含敏感字段")); + SkillImportServiceImpl service = new SkillImportServiceImpl( + mock(SkillService.class), mock(SkillCapabilityBindingService.class), + mock(SkillCapabilityTargetAccessService.class), mock(DBSkillContentStore.class), + fileStorageService, stageStore, bundleReader, mock(ResourceAccessService.class)); + + SkillImportPreview preview; + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(7)); + account.setTenantId(BigInteger.ONE); + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + preview = service.preview(file); + } + + assertEquals("EASYFLOW", preview.getFormat()); + assertTrue(preview.getSkills().isEmpty()); + assertTrue(preview.getImportToken() == null); + assertTrue(preview.getIssues().stream() + .anyMatch(issue -> "EASYFLOW_BUNDLE_INVALID".equals(issue.getCode()))); + } + + /** + * 验证敏感值 manifest 异常保留稳定问题码和精确路径,且响应不回显凭据。 + * + * @throws Exception 测试输入流构造失败 + */ + @Test + public void previewKeepsSensitiveManifestIssueCodeAndPath() throws Exception { + FileStorageService fileStorageService = mock(FileStorageService.class); + EasyFlowBundleReader bundleReader = mock(EasyFlowBundleReader.class); + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(128L); + when(file.getOriginalFilename()).thenReturn("unsafe.efskill"); + when(fileStorageService.save(file, "skill-imports/1")).thenReturn("stored-unsafe-bundle"); + when(fileStorageService.readStream("stored-unsafe-bundle")) + .thenAnswer(invocation -> new ByteArrayInputStream(new byte[]{1, 2, 3})); + when(bundleReader.containsManifest(any())).thenReturn(true); + when(bundleReader.prepare(any())).thenThrow(new SkillManifestValidationException( + "SENSITIVE_VALUE_DETECTED", "skills[0].capabilities[0].hitlConfig.prompt", + "EasyFlow Skill manifest 不能包含认证凭据")); + SkillImportServiceImpl service = new SkillImportServiceImpl( + mock(SkillService.class), mock(SkillCapabilityBindingService.class), + mock(SkillCapabilityTargetAccessService.class), mock(DBSkillContentStore.class), + fileStorageService, mock(SkillImportStageStore.class), bundleReader, + mock(ResourceAccessService.class)); + + SkillImportPreview preview; + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(7)); + account.setTenantId(BigInteger.ONE); + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + preview = service.preview(file); + } + + assertEquals("SENSITIVE_VALUE_DETECTED", preview.getIssues().get(0).getCode()); + assertEquals("skills[0].capabilities[0].hitlConfig.prompt", preview.getIssues().get(0).getPath()); + assertTrue(preview.getIssues().get(0).getMessage().contains("不能包含认证凭据")); + } + + /** + * 验证正式服务契约只暴露上传预览、单次确认和取消,不保留无令牌直导入口。 + */ + @Test + public void publicImportContractHasNoTokenBypass() { + List declaredMethods = Arrays.stream(SkillImportService.class.getDeclaredMethods()) + .map(java.lang.reflect.Method::getName) + .sorted() + .toList(); + + assertEquals(List.of("cancel", "confirm", "preview"), declaredMethods); + } + + /** + * 损坏的标准包与增强包都应通过 preview 返回结构化问题,而非中断为服务器错误。 + * + * @throws Exception 模拟文件存储流配置失败 + */ + @Test + public void corruptedArchivesReturnStructuredPreviewIssues() throws Exception { + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(7)); + account.setTenantId(BigInteger.ONE); + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + + SkillImportPreview standard = previewBrokenArchive("broken.zip"); + SkillImportPreview enhanced = previewBrokenArchive("broken.efskill"); + + assertTrue(standard.getIssues().stream() + .anyMatch(issue -> "STANDARD_PACKAGE_INVALID".equals(issue.getCode()))); + assertTrue(enhanced.getIssues().stream() + .anyMatch(issue -> "EASYFLOW_BUNDLE_INVALID".equals(issue.getCode()))); + } + } + + /** + * 构建损坏归档的上传预览。 + * + * @param filename 上传文件名 + * @return 结构化失败预览 + * @throws Exception 模拟文件存储流配置失败 + */ + private SkillImportPreview previewBrokenArchive(String filename) throws Exception { + byte[] bytes = "not-a-zip".getBytes(StandardCharsets.UTF_8); + String storedPath = "skill-imports/" + filename; + FileStorageService fileStorageService = mock(FileStorageService.class); + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn((long) bytes.length); + when(file.getOriginalFilename()).thenReturn(filename); + when(fileStorageService.save(file, "skill-imports/1")).thenReturn(storedPath); + when(fileStorageService.readStream(storedPath)) + .thenAnswer(invocation -> new ByteArrayInputStream(bytes)); + SkillImportServiceImpl service = new SkillImportServiceImpl( + mock(SkillService.class), mock(SkillCapabilityBindingService.class), + mock(SkillCapabilityTargetAccessService.class), mock(DBSkillContentStore.class), + fileStorageService, mock(SkillImportStageStore.class), + new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class)), + mock(ResourceAccessService.class)); + return service.preview(file); + } + + /** + * 验证增强包预览聚合能力绑定静态问题,并保留未解析目标供后续映射。 + */ + @Test + public void enhancedPreviewReturnsStructuredCapabilityIssuesWithoutRejectingUnresolvedTargets() throws Exception { + byte[] bundle = invalidCapabilityBundle(); + String storedPath = "skill-imports/static-capability-preview.efskill"; + FileStorageService fileStorageService = mock(FileStorageService.class); + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn((long) bundle.length); + when(file.getOriginalFilename()).thenReturn("static-capability-preview.efskill"); + when(fileStorageService.save(file, "skill-imports/1")).thenReturn(storedPath); + when(fileStorageService.readStream(storedPath)) + .thenAnswer(invocation -> new ByteArrayInputStream(bundle)); + SkillImportStage stage = new SkillImportStage(); + stage.setImportToken("b".repeat(32)); + stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000)); + SkillImportStageStore stageStore = mock(SkillImportStageStore.class); + when(stageStore.create(storedPath, "static-capability-preview.efskill", SkillImportFormat.EASYFLOW)) + .thenReturn(stage); + SkillService skillService = mock(SkillService.class); + when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of()); + SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class); + ResourceAccessService resourceAccessService = mock(ResourceAccessService.class); + ObjectMapper objectMapper = new ObjectMapper(); + SkillCapabilityBindingService capabilityService = new SkillCapabilityBindingServiceImpl( + mock(SkillMapper.class), targetAccessService, mock(McpAccessPermissionChecker.class), + resourceAccessService, objectMapper); + EasyFlowSkillManifestCodec manifestCodec = new EasyFlowSkillManifestCodec(objectMapper, targetAccessService); + SkillImportServiceImpl service = new SkillImportServiceImpl( + skillService, capabilityService, targetAccessService, mock(DBSkillContentStore.class), + fileStorageService, stageStore, new EasyFlowBundleReader(manifestCodec), resourceAccessService); + + SkillImportPreview preview; + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(7)); + account.setTenantId(BigInteger.ONE); + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + preview = service.preview(file); + } + + assertEquals("EASYFLOW", preview.getFormat()); + assertEquals("b".repeat(32), preview.getImportToken()); + assertEquals(3, preview.getCapabilityMappings().size()); + assertTrue(preview.getCapabilityMappings().stream() + .allMatch(mapping -> "UNRESOLVED".equals(mapping.getStatus()))); + assertTrue(preview.getIssues().stream() + .anyMatch(issue -> "CAPABILITY_OPTION_VALUE_INVALID".equals(issue.getCode()))); + assertTrue(preview.getIssues().stream() + .anyMatch(issue -> "RUNTIME_NAME_DUPLICATE".equals(issue.getCode()))); + assertTrue(preview.getIssues().stream() + .anyMatch(issue -> "MCP_SELECTION_MODE_NOT_ALLOWED".equals(issue.getCode()))); + assertTrue(preview.getIssues().stream() + .anyMatch(issue -> "MCP_EXECUTION_MODE_NOT_ALLOWED".equals(issue.getCode()))); + assertTrue(preview.getIssues().stream() + .anyMatch(issue -> "MCP_TOOL_SELECTION_EMPTY".equals(issue.getCode()))); + assertTrue(preview.getIssues().stream() + .filter(issue -> (issue.getCode() != null && issue.getCode().startsWith("MCP_")) + || "CAPABILITY_OPTION_VALUE_INVALID".equals(issue.getCode()) + || "RUNTIME_NAME_DUPLICATE".equals(issue.getCode())) + .allMatch(issue -> issue.getPath().startsWith("skills[demo-skill].capabilities["))); + assertTrue(preview.getIssues().stream() + .noneMatch(issue -> "TARGET_UNRESOLVED".equals(issue.getCode()))); + } + + /** + * 增强导入自动映射 MCP 时必须传播权限拒绝,不能降级为未解析映射或包结构问题。 + * + * @throws Exception 模拟文件存储流配置失败 + */ + @Test + public void enhancedPreviewPropagatesMcpPermissionDenial() throws Exception { + byte[] bundle = invalidCapabilityBundle(); + String storedPath = "skill-imports/mcp-permission-preview.efskill"; + FileStorageService fileStorageService = mock(FileStorageService.class); + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn((long) bundle.length); + when(file.getOriginalFilename()).thenReturn("mcp-permission-preview.efskill"); + when(fileStorageService.save(file, "skill-imports/1")).thenReturn(storedPath); + when(fileStorageService.readStream(storedPath)) + .thenAnswer(invocation -> new ByteArrayInputStream(bundle)); + SkillImportStageStore stageStore = mock(SkillImportStageStore.class); + SkillService skillService = mock(SkillService.class); + when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of()); + SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class); + when(targetAccessService.resolveLogicalRef(SkillCapabilityType.MCP, "mcp:demo")) + .thenThrow(new BusinessException(403, 403, "无权限查询或使用 MCP")); + ObjectMapper objectMapper = new ObjectMapper(); + SkillImportServiceImpl service = new SkillImportServiceImpl( + skillService, mock(SkillCapabilityBindingService.class), targetAccessService, + mock(DBSkillContentStore.class), fileStorageService, stageStore, + new EasyFlowBundleReader(new EasyFlowSkillManifestCodec(objectMapper, targetAccessService)), + mock(ResourceAccessService.class)); + + BusinessException exception; + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(7)); + account.setTenantId(BigInteger.ONE); + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + exception = assertThrows(BusinessException.class, () -> service.preview(file)); + } + + assertEquals(403, exception.getHttpStatus()); + verify(targetAccessService).resolveLogicalRef(SkillCapabilityType.MCP, "mcp:demo"); + verify(stageStore, never()).create(any(), any(), any()); + verify(fileStorageService).delete(storedPath); + } + + /** + * 创建包含二进制资源和非法名称的可解析标准包。 + * + * @return ZIP 字节 + */ + private byte[] invalidPackage() { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) { + writeEntry(zip, "invalid-skill/SKILL.md", ("---\n" + + "name: Invalid Name\n" + + "description: Invalid preview fixture\n" + + "---\n# Invalid\n").getBytes(StandardCharsets.UTF_8)); + writeEntry(zip, "invalid-skill/assets/data.bin", new byte[]{0, 1, 2}); + } + return bytes.toByteArray(); + } catch (Exception exception) { + throw new IllegalStateException("创建导入预检测试包失败", exception); + } + } + + /** + * 创建包含多类能力静态配置错误、但结构与安全边界合法的增强包。 + * + * @return `.efskill` 字节 + */ + private byte[] invalidCapabilityBundle() { + try { + String name = "demo-skill"; + String content = "---\nname: demo-skill\ndescription: Capability preview fixture\n---\n# Demo\n"; + Map first = new java.util.LinkedHashMap<>(); + first.put("bindingKey", "demo-skill:0"); + first.put("capabilityType", "WORKFLOW"); + first.put("runtimeName", "sharedTool"); + first.put("selectionMode", "SELECTED"); + first.put("selectedToolNames", List.of("search")); + first.put("targetLogicalRef", "workflow:first"); + first.put("options", Map.of("timeoutMs", 99)); + Map second = new java.util.LinkedHashMap<>(); + second.put("bindingKey", "demo-skill:1"); + second.put("capabilityType", "PLUGIN_ITEM"); + second.put("runtimeName", "sharedTool"); + second.put("targetLogicalRef", "plugin-item:demo/tool"); + second.put("options", Map.of("retryCount", 11)); + Map third = new java.util.LinkedHashMap<>(); + third.put("bindingKey", "demo-skill:2"); + third.put("capabilityType", "MCP"); + third.put("runtimeName", "mcpTools"); + third.put("selectionMode", "SELECTED"); + third.put("selectedToolNames", List.of()); + third.put("executionMode", "SYNC"); + third.put("targetLogicalRef", "mcp:demo"); + Map manifest = Map.of( + "schemaVersion", "1.0", + "skills", List.of(Map.of( + "packageRoot", name, + "packageHash", packageHash(content), + "capabilities", List.of(first, second, third)))); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) { + writeEntry(zip, EasyFlowSkillManifestCodec.MANIFEST_PATH, + new ObjectMapper().writeValueAsBytes(manifest)); + writeEntry(zip, "skills/" + name + "/SKILL.md", content.getBytes(StandardCharsets.UTF_8)); + } + return bytes.toByteArray(); + } catch (Exception exception) { + throw new IllegalStateException("创建增强能力预检测试包失败", exception); + } + } + + /** + * 计算仅含入口文档的 Skill 包 hash。 + * + * @param content SKILL.md 内容 + * @return 包 hash + */ + private String packageHash(String content) { + String canonical = "SKILL.md\n" + + SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)) + "\n"; + return SkillHashes.sha256Hex(canonical.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 写入 ZIP 文件项。 + * + * @param zip ZIP 输出流 + * @param path 包内路径 + * @param content 文件内容 + * @throws Exception 写入失败 + */ + private void writeEntry(ZipOutputStream zip, String path, byte[] content) throws Exception { + zip.putNextEntry(new ZipEntry(path)); + zip.write(content); + zip.closeEntry(); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportServiceImplQueryEfficiencyTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportServiceImplQueryEfficiencyTest.java new file mode 100644 index 00000000..a94a99e7 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportServiceImplQueryEfficiencyTest.java @@ -0,0 +1,389 @@ +package tech.easyflow.skill.imports; + +import com.easyagents.skill.util.SkillHashes; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.skill.capability.SkillCapabilityBindingService; +import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillImportStage; +import tech.easyflow.skill.enums.SkillCapabilityType; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.store.DBSkillContentStore; +import tech.easyflow.skill.validation.SkillValidationResult; +import tech.easyflow.system.service.ResourceAccessService; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link SkillImportServiceImpl} 批量查询与请求内能力映射缓存测试。 + */ +public class SkillImportServiceImplQueryEfficiencyTest { + + private static final BigInteger TENANT_ID = BigInteger.ONE; + private static final BigInteger ACCOUNT_ID = BigInteger.valueOf(7); + private static final String STORED_PATH = "skill-imports/query-efficiency.efskill"; + private static final String IMPORT_TOKEN = "a".repeat(32); + private static final String SHARED_LOGICAL_REF = "workflow:shared-flow"; + + /** + * 验证多 Skill 预览只执行一次名称批量查询,同时保留逐项冲突标记语义。 + */ + @Test + public void previewLoadsAllNameConflictsWithOneQuery() { + SkillService skillService = mock(SkillService.class); + Skill existing = new Skill(); + existing.setName("beta-skill"); + when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of(existing)); + SkillImportServiceImpl service = service(skillService, + mock(SkillCapabilityTargetAccessService.class), + mock(SkillCapabilityBindingService.class), + mock(FileStorageService.class), + mock(SkillImportStageStore.class)); + + SkillImportPreview preview; + try (MockedStatic saToken = login()) { + preview = service.previewStandardForTest(new ByteArrayInputStream(standardPackage(List.of( + "alpha-skill", "beta-skill", "gamma-skill")))); + } + + verify(skillService, times(1)).list(any(QueryWrapper.class)); + assertEquals(3, preview.getSkills().size()); + assertFalse(preview.getSkills().get(0).isConflict()); + assertTrue(preview.getSkills().get(1).isConflict()); + assertFalse(preview.getSkills().get(2).isConflict()); + } + + /** + * 验证增强导入预览会缓存未匹配的逻辑引用,重复绑定不会重复访问目标解析服务。 + */ + @Test + public void enhancedPreviewCachesUnresolvedLogicalRefWithinRequest() { + byte[] bundle = enhancedPackage(); + SkillService skillService = mock(SkillService.class); + when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of()); + SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class); + FileStorageService fileStorageService = storedBundle(bundle); + SkillImportStageStore stageStore = mock(SkillImportStageStore.class); + when(stageStore.create(anyString(), anyString(), any(SkillImportFormat.class))) + .thenReturn(stage(STORED_PATH)); + SkillCapabilityBindingService bindingService = mock(SkillCapabilityBindingService.class); + SkillValidationResult validBindings = new SkillValidationResult(); + validBindings.setValid(true); + when(bindingService.validateImportBindings(any())).thenReturn(validBindings); + SkillImportServiceImpl service = service(skillService, targetAccessService, + bindingService, fileStorageService, stageStore); + MultipartFile file = upload(bundle); + + SkillImportPreview preview; + try (MockedStatic saToken = login()) { + preview = service.preview(file); + } + + verify(targetAccessService, times(1)) + .resolveLogicalRef(SkillCapabilityType.WORKFLOW, SHARED_LOGICAL_REF); + assertEquals(2, preview.getCapabilityMappings().size()); + assertTrue(preview.getCapabilityMappings().stream() + .allMatch(mapping -> "UNRESOLVED".equals(mapping.getStatus()))); + } + + /** + * 验证多 Skill 导入确认也只执行一次名称批量查询。 + */ + @Test + public void confirmLoadsAllNameConflictsWithOneQuery() { + byte[] skillPackage = standardPackage(List.of("alpha-skill", "beta-skill", "gamma-skill")); + SkillService skillService = mock(SkillService.class); + when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of()); + AtomicInteger nextId = new AtomicInteger(40); + when(skillService.saveDraft(any(Skill.class))).thenAnswer(invocation -> { + Skill skill = invocation.getArgument(0); + skill.setId(BigInteger.valueOf(nextId.incrementAndGet())); + return skill; + }); + FileStorageService fileStorageService = storedBundle(skillPackage); + SkillImportStageStore stageStore = mock(SkillImportStageStore.class); + when(stageStore.consume(IMPORT_TOKEN)).thenReturn(stage(STORED_PATH, SkillImportFormat.STANDARD)); + SkillImportServiceImpl service = service(skillService, + mock(SkillCapabilityTargetAccessService.class), + mock(SkillCapabilityBindingService.class), fileStorageService, stageStore); + SkillImportConfirmRequest request = new SkillImportConfirmRequest(); + request.setImportToken(IMPORT_TOKEN); + request.setConflictStrategy(SkillImportConflictStrategy.REJECT.name()); + + List imported; + try (MockedStatic saToken = login()) { + imported = service.confirm(request); + } + + verify(skillService, times(1)).list(any(QueryWrapper.class)); + verify(skillService, times(3)).saveDraft(any(Skill.class)); + assertEquals(3, imported.size()); + } + + /** + * 验证增强导入确认会缓存成功匹配的逻辑引用,并将同一结果用于全部重复绑定。 + */ + @Test + public void enhancedConfirmCachesResolvedLogicalRefWithinRequest() { + byte[] bundle = enhancedPackage(); + SkillService skillService = mock(SkillService.class); + when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of()); + AtomicReference savedSkill = new AtomicReference<>(); + BigInteger skillId = BigInteger.valueOf(41); + when(skillService.saveDraft(any(Skill.class))).thenAnswer(invocation -> { + Skill skill = invocation.getArgument(0); + skill.setId(skillId); + savedSkill.set(skill); + return skill; + }); + when(skillService.getDetail(skillId)).thenAnswer(ignored -> savedSkill.get()); + SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class); + BigInteger targetId = BigInteger.valueOf(73); + when(targetAccessService.resolveLogicalRef(SkillCapabilityType.WORKFLOW, SHARED_LOGICAL_REF)) + .thenReturn(targetId); + SkillCapabilityBindingService bindingService = mock(SkillCapabilityBindingService.class); + FileStorageService fileStorageService = storedBundle(bundle); + SkillImportStageStore stageStore = mock(SkillImportStageStore.class); + when(stageStore.consume(IMPORT_TOKEN)).thenReturn(stage(STORED_PATH, SkillImportFormat.EASYFLOW)); + SkillImportServiceImpl service = service(skillService, targetAccessService, + bindingService, fileStorageService, stageStore); + SkillImportConfirmRequest request = new SkillImportConfirmRequest(); + request.setImportToken(IMPORT_TOKEN); + request.setConflictStrategy(SkillImportConflictStrategy.REJECT.name()); + + List imported; + try (MockedStatic saToken = login()) { + imported = service.confirm(request); + } + + verify(targetAccessService, times(1)) + .resolveLogicalRef(SkillCapabilityType.WORKFLOW, SHARED_LOGICAL_REF); + verify(bindingService, times(1)).replaceBindings(eq(skillId), + org.mockito.ArgumentMatchers.argThat(bindings -> bindings.size() == 2 + && bindings.stream().allMatch(binding -> targetId.equals(binding.getTargetId())))); + assertEquals(1, imported.size()); + } + + /** + * 创建待测服务。 + * + * @param skillService Skill 服务 + * @param targetAccessService 能力目标服务 + * @param bindingService 能力绑定服务 + * @param fileStorageService 文件存储服务 + * @param stageStore 导入会话仓库 + * @return 待测导入服务 + */ + private SkillImportServiceImpl service(SkillService skillService, + SkillCapabilityTargetAccessService targetAccessService, + SkillCapabilityBindingService bindingService, + FileStorageService fileStorageService, + SkillImportStageStore stageStore) { + EasyFlowSkillManifestCodec manifestCodec = new EasyFlowSkillManifestCodec( + new ObjectMapper(), targetAccessService); + return new SkillImportServiceImpl(skillService, bindingService, targetAccessService, + mock(DBSkillContentStore.class), fileStorageService, stageStore, + new EasyFlowBundleReader(manifestCodec), mock(ResourceAccessService.class)); + } + + /** + * 创建标准 Skill ZIP。 + * + * @param names Skill 名称 + * @return ZIP 字节 + */ + private byte[] standardPackage(List names) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) { + for (String name : names) { + writeEntry(zip, name + "/SKILL.md", skillContent(name).getBytes(StandardCharsets.UTF_8)); + } + } + return bytes.toByteArray(); + } catch (Exception exception) { + throw new IllegalStateException("创建标准 Skill 测试包失败", exception); + } + } + + /** + * 创建包含两个相同逻辑引用绑定的增强 Skill 包。 + * + * @return `.efskill` 字节 + */ + private byte[] enhancedPackage() { + try { + String name = "alpha-skill"; + String content = skillContent(name); + Map firstBinding = binding("alpha-workflow-one", "alphaFlowOne"); + Map secondBinding = binding("alpha-workflow-two", "alphaFlowTwo"); + Map manifest = Map.of( + "schemaVersion", "1.0", + "skills", List.of(Map.of( + "packageRoot", name, + "packageHash", packageHash(content), + "capabilities", List.of(firstBinding, secondBinding)))); + byte[] manifestBytes = new ObjectMapper().writeValueAsBytes(manifest); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) { + writeEntry(zip, EasyFlowSkillManifestCodec.MANIFEST_PATH, manifestBytes); + writeEntry(zip, "skills/" + name + "/SKILL.md", content.getBytes(StandardCharsets.UTF_8)); + } + return bytes.toByteArray(); + } catch (Exception exception) { + throw new IllegalStateException("创建增强 Skill 测试包失败", exception); + } + } + + /** + * 创建能力绑定 manifest 项。 + * + * @param bindingKey 绑定键 + * @param runtimeName 运行时名称 + * @return manifest 项 + */ + private Map binding(String bindingKey, String runtimeName) { + return Map.of( + "bindingKey", bindingKey, + "capabilityType", SkillCapabilityType.WORKFLOW.name(), + "runtimeName", runtimeName, + "targetLogicalRef", SHARED_LOGICAL_REF); + } + + /** + * 创建标准入口文档。 + * + * @param name Skill 名称 + * @return Markdown 内容 + */ + private String skillContent(String name) { + return "---\nname: " + name + "\ndescription: Query efficiency fixture for " + + name + "\n---\n# " + name + "\n"; + } + + /** + * 计算仅含入口文档的标准包 hash。 + * + * @param content 入口文档 + * @return 包 hash + */ + private String packageHash(String content) { + String canonical = "SKILL.md\n" + + SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)) + "\n"; + return SkillHashes.sha256Hex(canonical.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 创建每次都返回新输入流的文件存储 mock。 + * + * @param bundle 增强包字节 + * @return 文件存储 mock + */ + private FileStorageService storedBundle(byte[] bundle) { + FileStorageService storage = mock(FileStorageService.class); + when(storage.save(any(MultipartFile.class), anyString())).thenReturn(STORED_PATH); + try { + when(storage.readStream(STORED_PATH)).thenAnswer(ignored -> new ByteArrayInputStream(bundle)); + } catch (java.io.IOException exception) { + throw new IllegalStateException("创建文件存储测试替身失败", exception); + } + return storage; + } + + /** + * 创建上传文件 mock。 + * + * @param bundle 增强包字节 + * @return 上传文件 + */ + private MultipartFile upload(byte[] bundle) { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn((long) bundle.length); + when(file.getOriginalFilename()).thenReturn("skills.efskill"); + return file; + } + + /** + * 创建增强导入会话。 + * + * @param path 文件路径 + * @return 导入会话 + */ + private SkillImportStage stage(String path) { + return stage(path, SkillImportFormat.EASYFLOW); + } + + /** + * 创建指定格式的导入会话。 + * + * @param path 文件路径 + * @param format 包格式 + * @return 导入会话 + */ + private SkillImportStage stage(String path, SkillImportFormat format) { + SkillImportStage stage = new SkillImportStage(); + stage.setImportToken(IMPORT_TOKEN); + stage.setFilePath(path); + stage.setFormat(format.name()); + stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000)); + return stage; + } + + /** + * 建立当前租户登录态静态 mock。 + * + * @return 静态 mock 句柄 + */ + private MockedStatic login() { + LoginAccount account = new LoginAccount(); + account.setId(ACCOUNT_ID); + account.setTenantId(TENANT_ID); + MockedStatic saToken = mockStatic(SaTokenUtil.class); + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + return saToken; + } + + /** + * 写入 ZIP 条目。 + * + * @param zip ZIP 输出流 + * @param path 条目路径 + * @param content 条目内容 + * @throws Exception 写入失败 + */ + private void writeEntry(ZipOutputStream zip, String path, byte[] content) throws Exception { + zip.putNextEntry(new ZipEntry(path)); + zip.write(content); + zip.closeEntry(); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportStageStoreTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportStageStoreTest.java new file mode 100644 index 00000000..effb4fc8 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportStageStoreTest.java @@ -0,0 +1,235 @@ +package tech.easyflow.skill.imports; + +import com.alicp.jetcache.AutoReleaseLock; +import com.alicp.jetcache.Cache; +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Test; +import org.mockito.MockedStatic; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.SkillImportStage; +import tech.easyflow.skill.mapper.SkillImportStageMapper; + +import java.math.BigInteger; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link SkillImportStageStore} 单次令牌、归属和清理边界测试。 + */ +public class SkillImportStageStoreTest { + + private static final String TOKEN = "a".repeat(32); + private static final BigInteger TENANT_ID = BigInteger.ONE; + private static final BigInteger ACCOUNT_ID = BigInteger.valueOf(7); + + /** + * 验证归属正确的待处理令牌只能原子进入处理中状态。 + */ + @Test + public void consumeMarksOwnedPendingStageAsProcessing() { + Fixture fixture = fixture(); + SkillImportStage stage = pendingStage(); + when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(stage); + when(fixture.mapper.consume(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), + any(Date.class), any(Date.class))).thenReturn(1); + + SkillImportStage consumed; + try (MockedStatic login = login()) { + consumed = fixture.store.consume(TOKEN); + } + + assertEquals("PROCESSING", consumed.getStatus()); + assertTrue(consumed.getExpiresAt().after(new Date())); + verify(fixture.cache).remove("skill:import:" + TOKEN); + } + + /** + * 验证其他用户或租户不能探测并消费已有令牌。 + */ + @Test + public void consumeRejectsStageOwnedByAnotherAccount() { + Fixture fixture = fixture(); + when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null); + when(fixture.mapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L); + + BusinessException exception; + try (MockedStatic login = login()) { + exception = assertThrows(BusinessException.class, () -> fixture.store.consume(TOKEN)); + } + + assertEquals(403, exception.getHttpStatus()); + verify(fixture.mapper, never()).consume(anyString(), any(), any(), any(), any()); + } + + /** + * 验证过期令牌在状态更新前被拒绝。 + */ + @Test + public void consumeRejectsExpiredStage() { + Fixture fixture = fixture(); + SkillImportStage stage = pendingStage(); + stage.setExpiresAt(new Date(System.currentTimeMillis() - 1)); + when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(stage); + + try (MockedStatic login = login()) { + assertThrows(BusinessException.class, () -> fixture.store.consume(TOKEN)); + } + + verify(fixture.mapper, never()).consume(anyString(), any(), any(), any(), any()); + } + + /** + * 验证数据库原子更新失败时按重复或过期消费处理。 + */ + @Test + public void consumeRejectsAlreadyConsumedStage() { + Fixture fixture = fixture(); + when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pendingStage()); + when(fixture.mapper.consume(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), + any(Date.class), any(Date.class))).thenReturn(0); + + BusinessException exception; + try (MockedStatic login = login()) { + exception = assertThrows(BusinessException.class, () -> fixture.store.consume(TOKEN)); + } + + assertTrue(exception.getMessage().contains("已过期或已被使用")); + } + + /** + * 验证取消待处理会话会删除临时文件、数据库索引和缓存索引。 + */ + @Test + public void cancelCleansOwnedPendingStage() { + Fixture fixture = fixture(); + when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pendingStage()); + when(fixture.mapper.beginCancel(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), any(Date.class))).thenReturn(1); + when(fixture.mapper.finishCancel(TOKEN, TENANT_ID, ACCOUNT_ID)).thenReturn(1); + + try (MockedStatic login = login()) { + fixture.store.cancel(TOKEN); + } + + verify(fixture.fileStorage).delete("skill-imports/demo.zip"); + verify(fixture.mapper).beginCancel(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), any(Date.class)); + verify(fixture.mapper).finishCancel(TOKEN, TENANT_ID, ACCOUNT_ID); + verify(fixture.cache).remove("skill:import:" + TOKEN); + org.mockito.InOrder order = inOrder(fixture.mapper, fixture.fileStorage); + order.verify(fixture.mapper).beginCancel(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), any(Date.class)); + order.verify(fixture.fileStorage).delete("skill-imports/demo.zip"); + order.verify(fixture.mapper).finishCancel(TOKEN, TENANT_ID, ACCOUNT_ID); + } + + /** + * 验证文件删除失败时令牌已不可再消费,过期 PROCESSING 索引留给定时任务重试。 + */ + @Test + public void cancelFileFailureLeavesExpiredNonConsumableStage() { + Fixture fixture = fixture(); + when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pendingStage()); + when(fixture.mapper.beginCancel(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), any(Date.class))).thenReturn(1); + org.mockito.Mockito.doThrow(new RuntimeException("storage unavailable")) + .when(fixture.fileStorage).delete("skill-imports/demo.zip"); + + try (MockedStatic login = login()) { + assertThrows(BusinessException.class, () -> fixture.store.cancel(TOKEN)); + } + + verify(fixture.mapper, never()).finishCancel(anyString(), any(), any()); + verify(fixture.cache).remove("skill:import:" + TOKEN); + } + + /** + * 验证重试取消时物理文件已经不存在也能继续删除遗留索引。 + */ + @Test + public void cancelTreatsAlreadyAbsentFileAsSuccessfulCleanup() { + Fixture fixture = fixture(); + when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pendingStage()); + when(fixture.mapper.beginCancel(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), any(Date.class))).thenReturn(1); + when(fixture.mapper.finishCancel(TOKEN, TENANT_ID, ACCOUNT_ID)).thenReturn(1); + org.mockito.Mockito.doThrow(new RuntimeException( + "already absent", new java.nio.file.NoSuchFileException("skill-imports/demo.zip"))) + .when(fixture.fileStorage).delete("skill-imports/demo.zip"); + + try (MockedStatic login = login()) { + fixture.store.cancel(TOKEN); + } + + verify(fixture.mapper).finishCancel(TOKEN, TENANT_ID, ACCOUNT_ID); + verify(fixture.cache).remove("skill:import:" + TOKEN); + } + + /** + * 导入会话索引写入失败属于服务端持久化故障,应返回 5xx。 + */ + @Test + public void createPersistenceFailureUsesServerErrorStatus() { + Fixture fixture = fixture(); + when(fixture.mapper.insert(any(SkillImportStage.class))).thenReturn(0); + + BusinessException exception; + try (MockedStatic login = login()) { + exception = assertThrows(BusinessException.class, + () -> fixture.store.create("skill-imports/demo.zip", "demo.zip", SkillImportFormat.STANDARD)); + } + + assertEquals(500, exception.getHttpStatus()); + verify(fixture.cache, never()).put(anyString(), any(), anyLong(), any(TimeUnit.class)); + } + + private Fixture fixture() { + @SuppressWarnings("unchecked") + Cache cache = mock(Cache.class); + AutoReleaseLock lock = mock(AutoReleaseLock.class); + when(cache.tryLock(anyString(), anyLong(), eq(TimeUnit.SECONDS))).thenReturn(lock); + SkillImportStageMapper mapper = mock(SkillImportStageMapper.class); + FileStorageService fileStorage = mock(FileStorageService.class); + return new Fixture(cache, mapper, fileStorage, + new SkillImportStageStore(cache, mapper, fileStorage)); + } + + private SkillImportStage pendingStage() { + SkillImportStage stage = new SkillImportStage(); + stage.setImportToken(TOKEN); + stage.setTenantId(TENANT_ID); + stage.setAccountId(ACCOUNT_ID); + stage.setFilePath("skill-imports/demo.zip"); + stage.setFormat(SkillImportFormat.STANDARD.name()); + stage.setStatus("PENDING"); + stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000)); + return stage; + } + + private MockedStatic login() { + LoginAccount account = new LoginAccount(); + account.setId(ACCOUNT_ID); + account.setTenantId(TENANT_ID); + MockedStatic login = mockStatic(SaTokenUtil.class); + login.when(SaTokenUtil::getLoginAccount).thenReturn(account); + return login; + } + + private record Fixture(Cache cache, + SkillImportStageMapper mapper, + FileStorageService fileStorage, + SkillImportStageStore store) { + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillCategoryMapperLockContractTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillCategoryMapperLockContractTest.java new file mode 100644 index 00000000..b400b21d --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillCategoryMapperLockContractTest.java @@ -0,0 +1,57 @@ +package tech.easyflow.skill.mapper; + +import org.apache.ibatis.annotations.Select; +import org.junit.Test; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import tech.easyflow.skill.service.impl.SkillCategoryServiceImpl; + +import java.lang.reflect.Method; +import java.math.BigInteger; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; + +/** + * {@link SkillCategoryMapper} 分类树并发锁 SQL 契约测试。 + */ +public class SkillCategoryMapperLockContractTest { + + /** + * 验证租户分类树按稳定主键顺序执行排他锁定。 + * + * @throws Exception 反射读取 Mapper 方法失败 + */ + @Test + public void tenantTreeMutationShouldLockRowsInStableOrder() throws Exception { + Method method = SkillCategoryMapper.class.getMethod("selectTenantTreeForUpdate", BigInteger.class); + String sql = String.join(" ", method.getAnnotation(Select.class).value()) + .replaceAll("\\s+", " ") + .toUpperCase(); + + assertTrue(sql.contains("WHERE TENANT_ID=#{TENANTID}")); + assertTrue(sql.contains("ORDER BY ID FOR UPDATE")); + assertTrue(sql.contains("TENANT_ID AS TENANTID")); + assertTrue(sql.contains("PARENT_ID AS PARENTID")); + assertTrue(sql.contains("CATEGORY_NAME AS CATEGORYNAME")); + assertTrue(sql.contains("LEVEL_NO AS LEVELNO")); + assertTrue(sql.contains("SORT_NO AS SORTNO")); + assertTrue(sql.contains("CREATED_BY AS CREATEDBY")); + assertTrue(sql.contains("MODIFIED_BY AS MODIFIEDBY")); + } + + /** + * Skill 归类锁必须加入调用方写事务,避免锁在 category_id 写入前提前释放。 + * + * @throws Exception 反射读取服务方法失败 + */ + @Test + public void skillAssignmentLockShouldRequireExistingTransaction() throws Exception { + Method method = SkillCategoryServiceImpl.class.getMethod( + "lockAndValidateUsableCategory", BigInteger.class); + Transactional transactional = method.getAnnotation(Transactional.class); + + assertTrue(transactional != null); + assertEquals(Propagation.MANDATORY, transactional.propagation()); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillContentMapperSqlTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillContentMapperSqlTest.java new file mode 100644 index 00000000..f7735da5 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillContentMapperSqlTest.java @@ -0,0 +1,178 @@ +package tech.easyflow.skill.mapper; + +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * {@link SkillContentMapper} 原子状态转换 SQL 契约测试。 + */ +public class SkillContentMapperSqlTest { + + /** + * 验证注解 SQL 方法名可以作为唯一的 MyBatis statement id,避免应用启动时重复注册。 + */ + @Test + public void annotatedStatementsHaveUniqueMethodNames() { + Set statementIds = new HashSet<>(); + + for (Method method : SkillContentMapper.class.getDeclaredMethods()) { + boolean annotated = method.isAnnotationPresent(Select.class) + || method.isAnnotationPresent(Insert.class) + || method.isAnnotationPresent(Update.class) + || method.isAnnotationPresent(Delete.class); + if (annotated) { + assertTrue("Mapper 注解 SQL 方法不允许重载: " + method.getName(), + statementIds.add(method.getName())); + } + } + } + + /** + * 验证占位记录以零引用写入,并在物理路径完成后原子激活首个引用。 + * + * @throws Exception 反射读取方法失败 + */ + @Test + public void reservationStartsInvisibleAndFinishesWithFirstReference() throws Exception { + Method reserve = SkillContentMapper.class.getMethod( + "reserve", String.class, String.class, String.class, String.class, long.class); + Method finish = SkillContentMapper.class.getMethod("finishReservation", String.class, String.class); + + String reserveSql = String.join(" ", reserve.getAnnotation(Insert.class).value()); + String finishSql = String.join(" ", finish.getAnnotation(Update.class).value()); + + assertTrue(reserveSql.contains("#{size},0,CURRENT_TIMESTAMP")); + assertTrue(finishSql.contains("ref_count=1")); + assertTrue(finishSql.contains("ref_count=0")); + assertTrue(finishSql.contains("file_path LIKE '__PENDING__:%'")); + } + + /** + * 验证引用增加仅作用于已完成且仍可见的内容。 + * + * @throws Exception 反射读取方法失败 + */ + @Test + public void retainExcludesPendingAndZeroReferenceRows() throws Exception { + Method retain = SkillContentMapper.class.getMethod("retain", String.class); + String sql = String.join(" ", retain.getAnnotation(Update.class).value()); + + assertTrue(sql.contains("ref_count > 0")); + assertTrue(sql.contains("file_path NOT LIKE '__PENDING__:%'")); + } + + /** + * 验证按内容大小复用时同时检查哈希一致性、正式路径与 locator 兼容状态。 + * + * @throws Exception 反射读取方法失败 + */ + @Test + public void retainMatchingRequiresSizeHashAndActiveLocation() throws Exception { + Method retain = SkillContentMapper.class.getMethod("retainMatching", String.class, long.class); + String sql = String.join(" ", retain.getAnnotation(Update.class).value()); + + assertTrue(sql.contains("size=#{size}")); + assertTrue(sql.contains("CONCAT('sha256:',content_hash)=#{contentRef}")); + assertTrue(sql.contains("file_path IS NOT NULL")); + assertTrue(sql.contains("file_path<>''")); + assertTrue(sql.contains("file_path NOT LIKE '__PENDING__:%'")); + assertTrue(sql.contains("storage_locator IS NULL OR storage_locator<>''")); + } + + /** + * 验证引用状态转换使用锁定当前读,并且旧版恢复严格限制为已校验的零引用无 locator 行。 + * + * @throws Exception 反射读取方法失败 + */ + @Test + public void currentReadAndLegacyResurrectionAreStateSafe() throws Exception { + Method current = SkillContentMapper.class.getMethod("selectForUpdate", String.class); + Method resurrect = SkillContentMapper.class.getMethod( + "resurrectVerifiedLegacy", String.class, String.class, String.class, long.class); + + String currentSql = String.join(" ", current.getAnnotation(Select.class).value()); + String resurrectSql = String.join(" ", resurrect.getAnnotation(Update.class).value()); + + assertTrue(currentSql.endsWith("FOR UPDATE")); + assertTrue(resurrectSql.contains("ref_count=1")); + assertTrue(resurrectSql.contains("ref_count=0")); + assertTrue(resurrectSql.contains("storage_locator IS NULL")); + assertTrue(resurrectSql.contains("content_hash=#{contentHash}")); + assertTrue(resurrectSql.contains("file_path=#{filePath}")); + assertTrue(resurrectSql.contains("size=#{size}")); + } + + /** + * 验证新内容直接以首个正式引用写入,并拒绝空 locator 或哈希不一致参数。 + * + * @throws Exception 反射读取方法失败 + */ + @Test + public void insertActiveRequiresStableLocatorAndMatchingHash() throws Exception { + Method insert = SkillContentMapper.class.getMethod("insertActive", String.class, String.class, + String.class, String.class, String.class, long.class); + String sql = String.join(" ", insert.getAnnotation(Insert.class).value()); + + assertTrue(sql.contains("INSERT INTO tb_skill_content")); + assertFalse(sql.contains("INSERT IGNORE")); + assertTrue(sql.contains("storage_locator")); + assertTrue(sql.contains("1,CURRENT_TIMESTAMP")); + assertTrue(sql.contains("#{storageLocator} IS NOT NULL")); + assertTrue(sql.contains("#{storageLocator}<>''")); + assertTrue(sql.contains("CONCAT('sha256:',#{contentHash})=#{contentRef}")); + } + + /** + * 验证新释放流程在状态转换与索引删除时均精确匹配 locator。 + * + * @throws Exception 反射读取方法失败 + */ + @Test + public void releaseMutationsMatchStorageLocatorExactly() throws Exception { + Method mark = SkillContentMapper.class.getMethod( + "markReleased", String.class, String.class, String.class); + Method delete = SkillContentMapper.class.getMethod( + "deleteReleased", String.class, String.class, String.class); + + String markSql = String.join(" ", mark.getAnnotation(Update.class).value()); + String deleteSql = String.join(" ", delete.getAnnotation(Delete.class).value()); + + assertTrue(markSql.contains("storage_locator<=>#{storageLocator}")); + assertTrue(deleteSql.contains("storage_locator<=>#{storageLocator}")); + assertTrue(markSql.contains("file_path=#{filePath}")); + assertTrue(deleteSql.contains("file_path=#{filePath}")); + } + + /** + * 验证所有返回内容实体的显式查询都读取 storage_locator。 + * + * @throws Exception 反射读取方法失败 + */ + @Test + public void contentEntityQueriesSelectStorageLocator() throws Exception { + Method pending = SkillContentMapper.class.getMethod("findStalePending", java.util.Date.class, int.class); + Method released = SkillContentMapper.class.getMethod("findReleasedBefore", java.util.Date.class, int.class); + + String pendingSql = String.join(" ", pending.getAnnotation(Select.class).value()); + assertTrue(pendingSql.contains("content_ref AS contentRef")); + assertTrue(pendingSql.contains("content_hash AS contentHash")); + assertTrue(pendingSql.contains("file_path AS filePath")); + assertTrue(pendingSql.contains("storage_locator AS storageLocator")); + assertTrue(pendingSql.contains("media_type AS mediaType")); + assertTrue(pendingSql.contains("ref_count AS refCount")); + String releasedSql = String.join(" ", released.getAnnotation(Select.class).value()); + assertTrue(releasedSql.contains("storage_locator AS storageLocator")); + assertTrue(releasedSql.contains("storage_locator IS NOT NULL")); + assertTrue(releasedSql.contains("storage_locator<>''")); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillContentWriteIntentMapperSqlTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillContentWriteIntentMapperSqlTest.java new file mode 100644 index 00000000..a7956b9f --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillContentWriteIntentMapperSqlTest.java @@ -0,0 +1,148 @@ +package tech.easyflow.skill.mapper; + +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.junit.Test; + +import java.lang.reflect.Method; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * {@link SkillContentWriteIntentMapper} 原子状态转换 SQL 契约测试。 + */ +public class SkillContentWriteIntentMapperSqlTest { + + private static final String ALL_COLUMNS = + "content_ref AS contentRef,reservation_token AS reservationToken,content_hash AS contentHash," + + "storage_locator AS storageLocator,media_type AS mediaType,size,state,created,modified"; + + /** + * 验证预留通过普通 INSERT 竞争主键,避免静默吞掉主键之外的数据库错误。 + * + * @throws Exception 反射读取方法失败 + */ + @Test + public void reserveCreatesValidatedPendingIntent() throws Exception { + Method reserve = SkillContentWriteIntentMapper.class.getMethod("reserve", String.class, String.class, + String.class, String.class, String.class, long.class); + String sql = sql(reserve, Insert.class); + + assertTrue(sql.contains("INSERT INTO tb_skill_content_write_intent")); + assertFalse(sql.contains("INSERT IGNORE")); + assertTrue(sql.contains("'PENDING'")); + assertTrue(sql.contains("#{storageLocator} IS NOT NULL")); + assertTrue(sql.contains("#{storageLocator}<>''")); + assertTrue(sql.contains("CONCAT('sha256:',#{contentHash})=#{contentRef}")); + } + + /** + * 验证写入声明仅允许同一令牌从 PENDING 原子进入 WRITING。 + * + * @throws Exception 反射读取方法失败 + */ + @Test + public void claimForWriteMatchesTokenAndPendingState() throws Exception { + Method claim = SkillContentWriteIntentMapper.class.getMethod( + "claimForWrite", String.class, String.class); + String sql = sql(claim, Update.class); + + assertTrue(sql.contains("SET state='WRITING'")); + assertTrue(sql.contains("reservation_token=#{reservationToken}")); + assertTrue(sql.contains("state='PENDING'")); + } + + /** + * 验证过期扫描覆盖全部未完成状态并返回完整实体字段。 + * + * @throws Exception 反射读取方法失败 + */ + @Test + public void staleScanCoversAllStatesAndColumns() throws Exception { + Method find = SkillContentWriteIntentMapper.class.getMethod("findStale", java.util.Date.class, int.class); + String sql = sql(find, Select.class); + + assertTrue(sql.contains("SELECT " + ALL_COLUMNS)); + assertTrue(sql.contains("state IN ('PENDING','WRITING','CLEANING')")); + assertTrue(sql.contains("modified<#{cutoff}")); + assertTrue(sql.contains("ORDER BY modified ASC LIMIT #{limit}")); + } + + /** + * 验证清理声明使用 token、观察状态与截止时间做 CAS,且活动内容存在时禁止清理。 + * + * @throws Exception 反射读取方法失败 + */ + @Test + public void cleanupClaimIsConditionalAndProtectsActiveContent() throws Exception { + Method claim = SkillContentWriteIntentMapper.class.getMethod( + "claimForCleanup", String.class, String.class, String.class, java.util.Date.class); + String sql = sql(claim, Update.class); + + assertTrue(sql.contains("SET state='CLEANING'")); + assertTrue(sql.contains("reservation_token=#{reservationToken}")); + assertTrue(sql.contains("state=#{expectedState}")); + assertTrue(sql.contains("state IN ('PENDING','WRITING','CLEANING')")); + assertTrue(sql.contains("modified<#{cutoff}")); + assertTrue(sql.contains("NOT EXISTS")); + assertTrue(sql.contains("active_content.ref_count>0")); + } + + /** + * 验证清理完成只删除同一 token 的 CLEANING 意图,活动内容则仅清除残留意图。 + * + * @throws Exception 反射读取方法失败 + */ + @Test + public void intentDeletesAreTokenScopedAndStateSafe() throws Exception { + Method deleteClaimed = SkillContentWriteIntentMapper.class.getMethod( + "deleteClaimed", String.class, String.class); + Method deleteActive = SkillContentWriteIntentMapper.class.getMethod( + "deleteIfActiveExists", String.class, String.class); + Method deletePending = SkillContentWriteIntentMapper.class.getMethod( + "deletePending", String.class, String.class); + + String claimedSql = sql(deleteClaimed, Delete.class); + String activeSql = sql(deleteActive, Delete.class); + String pendingSql = sql(deletePending, Delete.class); + + assertTrue(claimedSql.contains("reservation_token=#{reservationToken}")); + assertTrue(claimedSql.contains("state='CLEANING'")); + assertTrue(activeSql.contains("reservation_token=#{reservationToken}")); + assertTrue(activeSql.contains("EXISTS")); + assertTrue(activeSql.contains("active_content.ref_count>0")); + assertTrue(pendingSql.contains("reservation_token=#{reservationToken}")); + assertTrue(pendingSql.contains("state='PENDING'")); + } + + /** + * 验证单条意图查询读取完整字段。 + * + * @throws Exception 反射读取方法失败 + */ + @Test + public void getIntentSelectsEveryMappedColumn() throws Exception { + Method get = SkillContentWriteIntentMapper.class.getMethod("getIntent", String.class); + String sql = sql(get, Select.class); + + assertTrue(sql.contains("SELECT " + ALL_COLUMNS)); + assertTrue(sql.contains("content_ref=#{contentRef}")); + } + + /** + * 读取方法上的单个 SQL 注解值。 + * + * @param method Mapper 方法 + * @param annotationType SQL 注解类型 + * @return SQL 文本 + * @throws ReflectiveOperationException 注解 value 方法不可访问 + */ + private String sql(Method method, Class annotationType) throws ReflectiveOperationException { + Object annotation = method.getAnnotation(annotationType.asSubclass(java.lang.annotation.Annotation.class)); + String[] values = (String[]) annotationType.getMethod("value").invoke(annotation); + return String.join(" ", values); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillContentWriteIntentMigrationContractTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillContentWriteIntentMigrationContractTest.java new file mode 100644 index 00000000..c3a3c358 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillContentWriteIntentMigrationContractTest.java @@ -0,0 +1,74 @@ +package tech.easyflow.skill.mapper; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * V31 Skill 内容写入意图迁移契约测试。 + */ +public class SkillContentWriteIntentMigrationContractTest { + + /** + * 验证 storage_locator 通过 information_schema 守卫幂等添加。 + * + * @throws Exception 迁移文件不可读 + */ + @Test + public void storageLocatorAlterIsIdempotent() throws Exception { + String sql = migrationSql(); + + assertTrue(sql.contains("FROM information_schema.columns")); + assertTrue(sql.contains("table_name = 'tb_skill_content'")); + assertTrue(sql.contains("column_name = 'storage_locator'")); + assertTrue(sql.contains("ADD COLUMN `storage_locator` VARCHAR(2048) NULL")); + assertTrue(sql.contains("PREPARE skill_content_storage_locator_stmt")); + assertFalse(sql.contains("ADD COLUMN IF NOT EXISTS")); + } + + /** + * 验证写入意图表与正式内容表分离,并具备状态清理索引和完整审计时间。 + * + * @throws Exception 迁移文件不可读 + */ + @Test + public void writeIntentTableHasRequiredRecoveryColumns() throws Exception { + String sql = migrationSql(); + + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS `tb_skill_content_write_intent`")); + assertTrue(sql.contains("`content_ref` VARCHAR(128) NOT NULL")); + assertTrue(sql.contains("`reservation_token` VARCHAR(128) NOT NULL")); + assertTrue(sql.contains("`content_hash` VARCHAR(128) NOT NULL")); + assertTrue(sql.contains("`storage_locator` VARCHAR(2048) NOT NULL")); + assertTrue(sql.contains("`state` VARCHAR(16) NOT NULL COMMENT 'PENDING/WRITING/CLEANING'")); + assertTrue(sql.contains("PRIMARY KEY (`content_ref`)")); + assertTrue(sql.contains("`idx_skill_content_write_intent_state_modified` (`state`, `modified`)")); + assertTrue(sql.contains("`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP")); + assertTrue(sql.contains("`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")); + } + + /** + * 读取工作区中的 V31 MySQL 迁移。 + * + * @return 迁移 SQL + * @throws Exception 迁移文件不存在或不可读 + */ + private String migrationSql() throws Exception { + Path root = Path.of(System.getProperty("maven.multiModuleProjectDirectory", + Path.of(System.getProperty("user.dir")).toAbsolutePath().toString())); + while (root != null) { + Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/" + + "db/migration/mysql/V31__mysql_skill_content_write_intent.sql"); + if (Files.isRegularFile(migration)) { + return Files.readString(migration, StandardCharsets.UTF_8); + } + root = root.getParent(); + } + throw new IllegalStateException("找不到 V31 Skill 内容写入意图迁移"); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillMigrationGuardContractTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillMigrationGuardContractTest.java new file mode 100644 index 00000000..da9d718d --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillMigrationGuardContractTest.java @@ -0,0 +1,143 @@ +package tech.easyflow.skill.mapper; + +import com.easyagents.skill.util.SkillHashes; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * V27 旧 Skill 数据迁移的失败前置与摘要初值契约测试。 + */ +public class SkillMigrationGuardContractTest { + + /** + * 验证分类重复和旧资源冲突检查均位于业务表 DDL 之前。 + * + * @throws Exception 读取迁移文件失败 + */ + @Test + public void dataGuardsShouldRunBeforePersistentDdl() throws Exception { + String sql = migrationSql(); + int firstPersistentDdl = sql.indexOf("ALTER TABLE `tb_skill`"); + + assertTrue(firstPersistentDdl > 0); + assertTrue(sql.indexOf("tmp_skill_category_migration_guard") < firstPersistentDdl); + assertTrue(sql.indexOf("HAVING COUNT(1) > 1") < firstPersistentDdl); + assertTrue(sql.indexOf("tmp_skill_resource_owner_guard") < firstPersistentDdl); + assertTrue(sql.indexOf("tmp_skill_content_migration_guard") < firstPersistentDdl); + assertTrue(sql.indexOf("tmp_skill_resource_migration_source") < firstPersistentDdl); + assertTrue(sql.indexOf("UNION ALL") < firstPersistentDdl); + } + + /** + * 验证旧 Skill 的空能力配置 hash 与运行时算法一致,且迁移不改业务审计列。 + * + * @throws Exception 读取迁移文件失败 + */ + @Test + public void emptyCapabilityHashShouldMatchRuntimeCanonicalValue() throws Exception { + String sql = migrationSql(); + String emptyHash = SkillHashes.sha256Hex("[]".getBytes(StandardCharsets.UTF_8)); + + assertTrue(sql.contains("`capability_hash` = '" + emptyHash + "'")); + assertTrue(sql.contains("`modified` = `modified`")); + assertTrue(sql.contains("`modified_by` = `modified_by`")); + } + + /** + * 验证旧资源迁移不会使用 INSERT IGNORE 静默吞掉冲突数据。 + * + * @throws Exception 读取迁移文件失败 + */ + @Test + public void resourceMigrationShouldNeverSilentlyIgnoreConflicts() throws Exception { + String sql = migrationSql(); + + assertFalse(sql.contains("INSERT IGNORE INTO `tb_skill_resource`")); + assertFalse(sql.contains("INSERT IGNORE INTO `tb_skill_content`")); + assertTrue(sql.contains("tmp_skill_target_migration_guard")); + assertTrue(sql.contains("WHERE NOT EXISTS (")); + assertTrue(sql.contains("information_schema.statistics")); + assertFalse(sql.contains("ADD COLUMN IF NOT EXISTS")); + } + + /** + * 验证旧文本 hash、二进制大小与引用数都从可验证真相源修复。 + * + * @throws Exception 读取迁移文件失败 + */ + @Test + public void legacyContentSummaryShouldBeRepairedFromCanonicalSources() throws Exception { + String sql = migrationSql(); + + assertTrue(sql.contains("LOWER(SHA2(COALESCE(reference.`content`, ''), 256))")); + assertTrue(sql.contains("LOWER(SHA2(COALESCE(script.`content`, ''), 256))")); + assertTrue(sql.contains("content.`content_hash`,")); + assertTrue(sql.contains("SELECT MAX(asset.`size`)")); + assertTrue(sql.contains("SELECT COUNT(1) FROM `tb_skill_asset` asset")); + assertTrue(sql.contains("tmp_skill_snapshot_content_ref")); + assertTrue(sql.contains("+ COALESCE((SELECT snapshot_ref.`ref_count`")); + assertFalse(sql.contains("GREATEST(COALESCE(content.`ref_count`, 0)")); + } + + /** + * 验证新旧快照字段同时存在时优先读取 resources,避免兼容字段重复计数。 + * + * @throws Exception 读取迁移文件失败 + */ + @Test + public void snapshotResourcesShouldTakePrecedenceOverLegacyAssets() throws Exception { + String sql = migrationSql(); + + assertTrue(sql.contains("JSON_EXTRACT(skill.`published_snapshot_json`, '$.resources')")); + assertTrue(sql.contains("JSON_EXTRACT(approval.`snapshot_json`, '$.resourceSnapshot.resources')")); + assertTrue(sql.contains("approval.`status` IN ('PENDING', 'PROCESSING')")); + assertTrue(sql.contains("<> 'ARRAY'")); + assertTrue(sql.contains("= 'ARRAY'")); + } + + /** + * 验证迁移后所有资源可被租户查询,历史计数与根分类语义也同步归一。 + * + * @throws Exception 读取迁移文件失败 + */ + @Test + public void tenantCountsAndRootCategoryShouldBeNormalizedWithoutAuditPollution() throws Exception { + String sql = migrationSql(); + + assertTrue(sql.contains("COALESCE(reference.`tenant_id`, skill.`tenant_id`)")); + assertTrue(sql.contains("COALESCE(script.`tenant_id`, skill.`tenant_id`)")); + assertTrue(sql.contains("COALESCE(asset.`tenant_id`, skill.`tenant_id`)")); + assertTrue(sql.contains("`tenant_id` BIGINT NOT NULL COMMENT '租户ID'")); + assertTrue(sql.contains("resource.`tenant_id` <> skill.`tenant_id`")); + assertTrue(sql.contains("SET `parent_id` = NULL, `modified` = `modified`, `modified_by` = `modified_by`")); + assertTrue(sql.contains("`reference_count` = (SELECT COUNT(1)")); + assertTrue(sql.contains("`script_count` = (SELECT COUNT(1)")); + assertTrue(sql.contains("`asset_count` = (SELECT COUNT(1)")); + } + + /** + * 读取工作区中的 V27 MySQL 迁移。 + * + * @return 迁移 SQL + * @throws Exception 迁移文件不存在或不可读 + */ + private String migrationSql() throws Exception { + Path root = Path.of(System.getProperty("maven.multiModuleProjectDirectory", + Path.of(System.getProperty("user.dir")).toAbsolutePath().toString())); + while (root != null) { + Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/" + + "db/migration/mysql/V27__mysql_skill_resource_capability.sql"); + if (Files.isRegularFile(migration)) { + return Files.readString(migration, StandardCharsets.UTF_8); + } + root = root.getParent(); + } + throw new IllegalStateException("未找到 V27 Skill MySQL 迁移文件"); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillPermissionMigrationContractTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillPermissionMigrationContractTest.java new file mode 100644 index 00000000..d9be9117 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillPermissionMigrationContractTest.java @@ -0,0 +1,94 @@ +package tech.easyflow.skill.mapper; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Skill 操作权限迁移的最小授权与升级兼容契约测试。 + */ +public class SkillPermissionMigrationContractTest { + + /** + * 验证 V28 只向超级管理员角色授予新增权限,不扩大其他角色权限。 + * + * @throws Exception 读取迁移文件失败 + */ + @Test + public void operationPermissionsShouldDefaultToSuperAdminOnly() throws Exception { + String sql = migrationSql("V28__mysql_skill_operation_permissions.sql"); + int roleGrantStart = sql.indexOf("INSERT INTO `tb_sys_role_menu`"); + String roleGrants = sql.substring(roleGrantStart); + + assertTrue(roleGrantStart > 0); + assertEquals(5, occurrences(roleGrants, "`role_id` = 1")); + assertFalse(roleGrants.contains("FROM `tb_sys_role`")); + assertFalse(roleGrants.contains("SELECT `id` FROM `tb_sys_role`")); + } + + /** + * 验证 V29 先保留历史角色授权,再删除无真实入口的旧菜单。 + * + * @throws Exception 读取迁移文件失败 + */ + @Test + public void deletePermissionCleanupShouldPreserveExplicitRoleGrants() throws Exception { + String sql = migrationSql("V29__mysql_skill_delete_permission_cleanup.sql"); + int duplicateCleanup = sql.indexOf("DELETE legacy_mapping"); + int grantMigration = sql.indexOf("UPDATE `tb_sys_role_menu`"); + int deadMenuCleanup = sql.indexOf("DELETE FROM `tb_sys_menu`"); + + assertTrue(duplicateCleanup >= 0); + assertTrue(grantMigration > duplicateCleanup); + assertTrue(deadMenuCleanup > grantMigration); + assertTrue(sql.contains("SET `menu_id` = 367400000000000018")); + assertTrue(sql.contains("WHERE `menu_id` = 367400000000000015")); + assertTrue(sql.contains("`permission_tag` = '/api/v1/skill/remove'")); + assertTrue(sql.contains("`permission_tag` = '/api/v1/skill/submitDeleteApproval'")); + assertFalse(sql.contains("INSERT INTO `tb_sys_role_menu`")); + } + + /** + * 统计文本片段出现次数。 + * + * @param source 原始文本 + * @param target 目标片段 + * @return 出现次数 + */ + private int occurrences(String source, String target) { + int count = 0; + int index = 0; + while ((index = source.indexOf(target, index)) >= 0) { + count++; + index += target.length(); + } + return count; + } + + /** + * 读取指定 MySQL 迁移。 + * + * @param fileName 迁移文件名 + * @return 迁移 SQL + * @throws Exception 迁移文件不存在或不可读 + */ + private String migrationSql(String fileName) throws Exception { + Path root = Path.of(System.getProperty("maven.multiModuleProjectDirectory", + Path.of(System.getProperty("user.dir")).toAbsolutePath().toString())); + while (root != null) { + Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/" + + "db/migration/mysql/" + fileName); + if (Files.isRegularFile(migration)) { + return Files.readString(migration, StandardCharsets.UTF_8); + } + root = root.getParent(); + } + throw new IllegalStateException("未找到 Skill MySQL 迁移文件: " + fileName); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillSummaryBackfillSqlTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillSummaryBackfillSqlTest.java new file mode 100644 index 00000000..1158c89a --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillSummaryBackfillSqlTest.java @@ -0,0 +1,62 @@ +package tech.easyflow.skill.mapper; + +import org.apache.ibatis.annotations.Update; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.math.BigInteger; + +import static org.junit.Assert.assertTrue; + +/** + * {@link SkillMapper} 迁移摘要回填的并发与审计 SQL 契约测试。 + */ +public class SkillSummaryBackfillSqlTest { + + /** + * 验证 package hash 只回填空值旧记录,并显式保持业务修改审计列。 + * + * @throws Exception 反射读取 Mapper 方法失败 + */ + @Test + public void packageBackfillShouldBeConditionalAndAuditNeutral() throws Exception { + Method method = SkillMapper.class.getMethod( + "backfillPackageSummary", BigInteger.class, BigInteger.class, String.class, + Integer.class, Integer.class, Integer.class, Integer.class); + + String sql = sql(method); + + assertTrue(sql.contains("tenant_id=#{tenantId}")); + assertTrue(sql.contains("package_hash IS NULL")); + assertTrue(sql.contains("modified=modified")); + assertTrue(sql.contains("modified_by=modified_by")); + } + + /** + * 验证 capability hash 首次回填只作用于空值旧记录,并保持业务修改审计列。 + * + * @throws Exception 反射读取 Mapper 方法失败 + */ + @Test + public void capabilityBackfillShouldBeConditionalAndAuditNeutral() throws Exception { + Method method = SkillMapper.class.getMethod( + "backfillCapabilityHash", BigInteger.class, BigInteger.class, String.class); + + String sql = sql(method); + + assertTrue(sql.contains("tenant_id=#{tenantId}")); + assertTrue(sql.contains("capability_hash IS NULL")); + assertTrue(sql.contains("modified=modified")); + assertTrue(sql.contains("modified_by=modified_by")); + } + + /** + * 读取 Mapper 方法声明的更新 SQL。 + * + * @param method Mapper 方法 + * @return 合并后的 SQL + */ + private String sql(Method method) { + return String.join(" ", method.getAnnotation(Update.class).value()); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandlerContentReferenceTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandlerContentReferenceTest.java new file mode 100644 index 00000000..fba4683a --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandlerContentReferenceTest.java @@ -0,0 +1,230 @@ +package tech.easyflow.skill.publish; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.ArgumentCaptor; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.approval.entity.ApprovalInstance; +import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest; +import tech.easyflow.approval.enums.ApprovalActionType; +import tech.easyflow.approval.service.ApprovalInstanceService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.mapper.SkillMapper; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link SkillApprovalSubjectHandler} 发布候选与已发布快照引用所有权测试。 + */ +public class SkillApprovalSubjectHandlerContentReferenceTest { + + private static final BigInteger SKILL_ID = BigInteger.valueOf(101); + private static final BigInteger OPERATOR_ID = BigInteger.valueOf(7); + + private ApprovalInstanceService approvalInstanceService; + private SkillService skillService; + private SkillMapper skillMapper; + private SkillApprovalSubjectHandler handler; + private MockedStatic saToken; + + /** + * 初始化审批处理器。 + */ + @Before + public void setUp() { + approvalInstanceService = mock(ApprovalInstanceService.class); + skillService = mock(SkillService.class); + skillMapper = mock(SkillMapper.class); + LoginAccount account = new LoginAccount(); + account.setId(OPERATOR_ID); + account.setTenantId(BigInteger.ONE); + saToken = mockStatic(SaTokenUtil.class); + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + when(skillMapper.updateApprovalState(any(), any(), any(), any())).thenReturn(1); + when(skillMapper.publish(any(), any(), any(), any(), any(), any())).thenReturn(1); + handler = new SkillApprovalSubjectHandler( + approvalInstanceService, + new ObjectMapper(), + skillService, + skillMapper, + mock(ResourceAccessService.class)); + } + + /** + * 释放静态登录态 Mock。 + */ + @After + public void tearDown() { + saToken.close(); + } + + /** + * 验证发布候选在提交审批请求时立即持有自己的内容引用。 + */ + @Test + public void publishCandidateRetainsSnapshotContentsOnSubmit() { + Skill draft = skill(PublishStatus.DRAFT, Map.of()); + Map candidate = snapshot("sha256:candidate"); + when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(draft); + when(skillService.buildPublishSnapshot(draft)).thenReturn(candidate); + + ApprovalSubmitRequest request = handler.buildSubmitRequest( + SKILL_ID, ApprovalActionType.PUBLISH.getCode(), OPERATOR_ID); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + verify(skillMapper).selectOneByQuery(queryCaptor.capture()); + assertTrue(queryCaptor.getValue().toSQL().toLowerCase().contains("for update")); + verify(skillService).retainSnapshotContents(candidate); + assertSame(candidate, request.getSnapshotJson().get("resourceSnapshot")); + assertEquals(PublishStatus.DRAFT.getCode(), request.getSnapshotJson().get("previousPublishStatus")); + } + + /** + * 验证重新发布时候选引用转为已发布持有,只释放被替换的旧快照。 + */ + @Test + public void approvedRepublishReleasesOnlyPreviousPublishedSnapshot() { + Map previous = snapshot("sha256:previous"); + Map candidate = snapshot("sha256:candidate"); + Skill published = skill(PublishStatus.PUBLISHED, previous); + when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(published); + + handler.applyApprovedAction( + ApprovalActionType.PUBLISH.getCode(), SKILL_ID, candidate, OPERATOR_ID); + + verify(skillMapper).publish( + eq(SKILL_ID), eq(BigInteger.ONE), same(candidate), any(Date.class), + eq(OPERATOR_ID), isNull()); + verify(skillService).releaseSnapshotContents(previous); + verify(skillService, never()).releaseSnapshotContents(candidate); + } + + /** + * 验证发布审批驳回或撤回会释放候选快照,且不会释放当前线上快照。 + */ + @Test + public void rejectedPublishReleasesCandidateButKeepsPublishedSnapshot() { + BigInteger instanceId = BigInteger.valueOf(99); + Map publishedSnapshot = snapshot("sha256:published"); + Map candidate = snapshot("sha256:candidate"); + Skill published = skill(PublishStatus.PUBLISH_PENDING, publishedSnapshot); + published.setCurrentApprovalInstanceId(instanceId); + ApprovalInstance instance = new ApprovalInstance(); + instance.setActionType(ApprovalActionType.PUBLISH.getCode()); + instance.setSnapshotJson(Map.of("resourceSnapshot", candidate)); + when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(published); + when(approvalInstanceService.getById(instanceId)).thenReturn(instance); + + handler.restoreState(SKILL_ID, PublishStatus.PUBLISHED); + + verify(skillMapper).updateApprovalState( + SKILL_ID, BigInteger.ONE, PublishStatus.PUBLISHED.getCode(), null); + verify(skillService).releaseSnapshotContents(candidate); + verify(skillService, never()).releaseSnapshotContents(publishedSnapshot); + } + + /** + * 验证删除草稿不触发发布级校验,并使用不含凭据的治理快照。 + */ + @Test + public void deleteDraftUsesGovernanceSnapshotWithoutPublishValidation() { + Skill draft = skill(PublishStatus.DRAFT, Map.of()); + Map governance = Map.of( + "id", SKILL_ID, + "name", "demo-skill", + "capabilityCount", 1); + when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(draft); + when(skillService.buildGovernanceSnapshot(draft)).thenReturn(governance); + + ApprovalSubmitRequest request = handler.buildSubmitRequest( + SKILL_ID, ApprovalActionType.DELETE.getCode(), OPERATOR_ID); + + assertSame(governance, request.getSnapshotJson().get("resourceSnapshot")); + verify(skillService).buildGovernanceSnapshot(draft); + verify(skillService, never()).buildPublishSnapshot(any()); + verify(skillService, never()).retainSnapshotContents(any()); + } + + /** + * 验证已发布 Skill 仍须先下线,且不会构建任何删除快照。 + */ + @Test + public void deletePublishedSkillRequiresOfflineFirst() { + Skill published = skill(PublishStatus.PUBLISHED, snapshot("sha256:published")); + when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(published); + + assertThrows(BusinessException.class, () -> handler.buildSubmitRequest( + SKILL_ID, ApprovalActionType.DELETE.getCode(), OPERATOR_ID)); + + verify(skillService, never()).buildGovernanceSnapshot(any()); + verify(skillService, never()).buildPublishSnapshot(any()); + } + + /** + * 删除审批通过或无审批直通时必须使用生命周期专用聚合删除入口。 + */ + @Test + public void approvedDeleteUsesLifecycleAggregateRemoval() { + handler.applyApprovedAction( + ApprovalActionType.DELETE.getCode(), SKILL_ID, Map.of(), OPERATOR_ID); + + verify(skillService).removeLifecycleAggregate(SKILL_ID); + verify(skillService, never()).removeAggregate(SKILL_ID); + } + + /** + * 创建指定生命周期状态的 Skill。 + * + * @param status 发布状态 + * @param publishedSnapshot 已发布快照 + * @return Skill + */ + private Skill skill(PublishStatus status, Map publishedSnapshot) { + Skill skill = new Skill(); + skill.setId(SKILL_ID); + skill.setTenantId(BigInteger.ONE); + skill.setName("demo-skill"); + skill.setDisplayName("Demo Skill"); + skill.setPublishStatus(status.getCode()); + skill.setPublishedSnapshotJson(publishedSnapshot); + return skill; + } + + /** + * 创建单二进制资源快照。 + * + * @param contentRef 内容引用 + * @return 快照 + */ + private Map snapshot(String contentRef) { + return Map.of("resources", List.of(Map.of( + "path", "assets/file.bin", + "contentRef", contentRef))); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/repository/DBSkillRepositoryContentOwnershipTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/repository/DBSkillRepositoryContentOwnershipTest.java new file mode 100644 index 00000000..51e18e6d --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/repository/DBSkillRepositoryContentOwnershipTest.java @@ -0,0 +1,218 @@ +package tech.easyflow.skill.repository; + +import com.easyagents.skill.factory.SkillFactory; +import com.easyagents.skill.model.SkillResourceKind; +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillResource; +import tech.easyflow.skill.security.SkillVisibilityQueryHelper; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.store.DBSkillContentStore; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link DBSkillRepository} 二进制内容引用所有权转移测试。 + */ +public class DBSkillRepositoryContentOwnershipTest { + + private static final BigInteger SKILL_ID = BigInteger.valueOf(101); + private static final String REF_A = "sha256:" + "a".repeat(64); + private static final String REF_B = "sha256:" + "b".repeat(64); + + private SkillService skillService; + private DBSkillContentStore contentStore; + private DBSkillRepository repository; + private MockedStatic saToken; + + /** + * 初始化仓储及登录态。 + */ + @Before + public void setUp() { + skillService = mock(SkillService.class); + contentStore = mock(DBSkillContentStore.class); + repository = new DBSkillRepository( + skillService, contentStore, mock(SkillVisibilityQueryHelper.class)); + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(7)); + account.setTenantId(BigInteger.ONE); + saToken = mockStatic(SaTokenUtil.class); + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + } + + /** + * 释放静态登录态 Mock。 + */ + @After + public void tearDown() { + saToken.close(); + } + + /** + * 验证新增 Skill 直接接管调用方已取得的所有二进制引用。 + */ + @Test + public void newSkillTransfersIncomingReferencesWithoutRetain() { + com.easyagents.skill.model.Skill incoming = incoming(null, REF_A, REF_A); + + repository.save(incoming); + + verify(skillService).saveDraft(any(Skill.class)); + verify(contentStore, never()).retain(anyString()); + } + + /** + * 验证更新时仅出现在新聚合中的引用直接转移,不额外 retain。 + */ + @Test + public void updateTransfersNewOnlyReferenceWithoutRetain() { + prepareExisting(List.of(resource("assets/old.bin", REF_A))); + + repository.save(incoming(SKILL_ID.toString(), REF_B)); + + verify(skillService).updateDraft(any(Skill.class)); + verify(contentStore, never()).retain(anyString()); + } + + /** + * 验证旧、新聚合重叠的引用会 retain 一次,以抵消旧聚合替换时的 release。 + */ + @Test + public void updateRetainsOverlappingReference() { + prepareExisting(List.of(resource("assets/old.bin", REF_A))); + + repository.save(incoming(SKILL_ID.toString(), REF_A)); + + verify(contentStore).retain(REF_A); + verify(contentStore, never()).retain(REF_B); + } + + /** + * 验证仅存在于旧聚合的引用不 retain,由资源替换流程负责释放。 + */ + @Test + public void updateDoesNotRetainRemovedReference() { + prepareExisting(List.of(resource("assets/old.bin", REF_A))); + + repository.save(incoming(SKILL_ID.toString())); + + verify(skillService).updateDraft(any(Skill.class)); + verify(contentStore, never()).retain(anyString()); + } + + /** + * 验证共享 contentRef 按资源出现次数计算交集,不因 hash 去重而少持有或多持有。 + */ + @Test + public void updateRetainsSharedReferenceByMultisetIntersection() { + prepareExisting(List.of( + resource("assets/old-a.bin", REF_A), + resource("assets/old-b.bin", REF_A), + resource("assets/old-c.bin", REF_B))); + + repository.save(incoming(SKILL_ID.toString(), REF_A, REF_A, REF_A, REF_B, REF_B)); + + verify(contentStore, times(2)).retain(REF_A); + verify(contentStore).retain(REF_B); + } + + /** + * 验证缺失或不可读 Skill 按仓储契约返回 empty,不把详情服务的 404 泄漏给调用方。 + */ + @Test + public void getReturnsEmptyWhenSkillIsNotReadable() { + when(skillService.getOne(any(QueryWrapper.class))).thenReturn(null); + + assertTrue(repository.get(SKILL_ID.toString()).isEmpty()); + + verify(skillService, never()).getDetail(SKILL_ID); + } + + /** + * 仓储删除必须走带生命周期状态约束的普通聚合删除入口。 + */ + @Test + public void deleteUsesGuardedAggregateRemoval() { + repository.delete(SKILL_ID.toString()); + + verify(skillService).removeAggregate(SKILL_ID); + verify(skillService, never()).removeLifecycleAggregate(SKILL_ID); + } + + /** + * 准备一个可更新的已存在 Skill。 + * + * @param resources 已持久化资源 + */ + private void prepareExisting(List resources) { + Skill header = new Skill(); + header.setId(SKILL_ID); + header.setTenantId(BigInteger.ONE); + Skill detail = new Skill(); + detail.setId(SKILL_ID); + detail.setTenantId(BigInteger.ONE); + detail.setResources(resources); + when(skillService.getOne(any(QueryWrapper.class))).thenReturn(header); + when(skillService.getDetail(SKILL_ID)).thenReturn(detail); + } + + /** + * 创建 M18 Skill 聚合。 + * + * @param id 仓储 ID,可为空 + * @param refs 二进制引用多重集 + * @return M18 Skill + */ + private com.easyagents.skill.model.Skill incoming(String id, String... refs) { + List resources = new ArrayList<>(); + for (int index = 0; index < refs.length; index++) { + com.easyagents.skill.model.SkillResource resource = new com.easyagents.skill.model.SkillResource(); + resource.setPath("assets/incoming-" + index + ".bin"); + resource.setKind(SkillResourceKind.ASSET); + resource.setMediaType("application/octet-stream"); + resource.setContentRef(refs[index]); + resource.setContentHash(refs[index].substring("sha256:".length())); + resource.setSize(1); + resources.add(resource); + } + return SkillFactory.createWithResources(id, + "---\nname: demo-skill\ndescription: Demo skill\n---\n# Demo\n", resources); + } + + /** + * 创建已持久化二进制资源。 + * + * @param path 包内路径 + * @param contentRef 内容引用 + * @return 资源实体 + */ + private SkillResource resource(String path, String contentRef) { + SkillResource resource = new SkillResource(); + resource.setPath(path); + resource.setNormalizedPath(path); + resource.setIsText(false); + resource.setContentRef(contentRef); + resource.setContentHash(contentRef.substring("sha256:".length())); + resource.setSize(1L); + return resource; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillCredentialValueGuardTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillCredentialValueGuardTest.java new file mode 100644 index 00000000..cb8deef6 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillCredentialValueGuardTest.java @@ -0,0 +1,89 @@ +package tech.easyflow.skill.security; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * {@link SkillCredentialValueGuard} 结构化凭据检测测试。 + */ +public class SkillCredentialValueGuardTest { + + /** + * 验证认证头、赋值、URI userinfo、私钥和常见 Token 前缀被识别。 + */ + @Test + public void detectsHighConfidenceCredentialStructures() { + List credentials = List.of( + "Authorization: Bearer actual-secret-value", + "{\"token\":\"actual-secret-value\"}", + "clientSecret=actual-secret-value", + "https://operator:actual-password@example.test/service", + "-----BEGIN RSA PRIVATE KEY-----", + "key=sk-proj-abcdefghijklmnopqrstuvwxyz123456", + "password=actual-secret-value", + "token%253Dactual-secret-value", + "token=${TOKEN}actual-secret", + "token=${TOKEN} actual-secret", + "Authorization: Bearer ${TOKEN} actual-secret", + "https://example.test/service?token=actual-secret-value", + "https://example.test/service?mode=read&client_secret=actual-secret-value", + "https://example.test/callback#access_token=actual-secret-value", + "token%2525253Dactual-secret-value", + "token%3Dactual-secret-value%ZZ", + "token%252525252525253Dactual-secret-value", + "to\u200Bken=actual-secret-value", + "to\u0000ken=actual-secret-value", + "spring.datasource.password=actual-secret-value", + "headers[Authorization]=Bearer actual-secret-value", + "OPENAI_API_KEY=actual-secret-value", + "AWS_SECRET_ACCESS_KEY=actual-secret-value", + "Cookie=session-value-actual-secret", + "X-Auth-Token=actual-secret-value", + "session=actual-secret-value", + "Bearer actual-secret-value", + "Basic dXNlcjphY3R1YWwtc2VjcmV0", + "glpat-abcdefghijklmnopqrstuvwxyz123456", + "hf_abcdefghijklmnopqrstuvwxyz123456", + "sk_live_abcdefghijklmnopqrstuvwxyz123456", + "AIzaSyabcdefghijklmnopqrstuvwxyz1234567890", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMyJ9.signature-value-123456"); + + for (String credential : credentials) { + assertTrue(credential, SkillCredentialValueGuard.containsCredential(credential)); + } + } + + /** + * 验证明确占位符和普通展示文案不会被当作真实凭据。 + */ + @Test + public void allowsPlaceholdersAndOrdinaryDisplayCopy() { + List safeValues = List.of( + "是否继续执行当前工作流?", + "请确认操作,运行时会从安全配置读取认证信息", + "token=${TOKEN}", + "Authorization: Bearer {{ token }}", + "apiKey=", + "password=[REDACTED]", + "secret=***", + "https://operator:${PASSWORD}@example.test/service", + "token=none", + "client_secret=not-set", + "Bearer ${TOKEN}", + "Basic {{ basic_auth }}", + "Basic information", + "Bearer authentication", + "Authorization: Bearer", + "请将 Authorization: Bearer 写入请求头", + "sk-project-management-service", + "https://operator@example.test/service"); + + for (String safeValue : safeValues) { + assertFalse(safeValue, SkillCredentialValueGuard.containsCredential(safeValue)); + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillSensitiveConfigSanitizerTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillSensitiveConfigSanitizerTest.java new file mode 100644 index 00000000..899f8c1c --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillSensitiveConfigSanitizerTest.java @@ -0,0 +1,75 @@ +package tech.easyflow.skill.security; + +import org.junit.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +/** + * {@link SkillSensitiveConfigSanitizer} 字段与值类型白名单测试。 + */ +public class SkillSensitiveConfigSanitizerTest { + + /** + * 验证执行选项仅保留允许的 JSON 标量,并移除常见凭据和复杂值。 + */ + @Test + public void optionsKeepAllowedScalarsAndDropCredentialsOrComplexValues() { + Map source = new LinkedHashMap<>(); + source.put("timeoutMs", 5_000); + source.put("retryCount", 3); + source.put("async", true); + source.put("readOnly", List.of("complex")); + source.put("token", "secret-token"); + source.put("apiKey", "secret-key"); + source.put("headers", Map.of("Authorization", "Bearer secret")); + + Map sanitized = SkillSensitiveConfigSanitizer.sanitizeOptions(source); + + assertEquals(Map.of("timeoutMs", 5_000, "retryCount", 3, "async", true), sanitized); + assertFalse(sanitized.containsKey("token")); + assertFalse(sanitized.containsKey("apiKey")); + assertFalse(sanitized.containsKey("headers")); + assertEquals("secret-token", source.get("token")); + } + + /** + * 验证 HITL 只保留展示字段,认证信息和复杂值不会穿透。 + */ + @Test + public void hitlKeepsDisplayScalarsOnly() { + Map source = new LinkedHashMap<>(); + source.put("prompt", "是否继续"); + source.put("title", "人工确认"); + source.put("confirmLabel", "继续"); + source.put("cancelLabel", "取消"); + source.put("description", Map.of("token", "nested-secret")); + source.put("authorization", "Bearer secret"); + + Map sanitized = SkillSensitiveConfigSanitizer.sanitizeHitl(source); + + assertEquals(Map.of( + "prompt", "是否继续", + "title", "人工确认", + "confirmLabel", "继续", + "cancelLabel", "取消"), sanitized); + assertFalse(sanitized.containsKey("authorization")); + assertFalse(sanitized.containsKey("description")); + } + + /** + * 验证空输入返回可安全修改的独立映射。 + */ + @Test + public void nullInputReturnsMutableEmptyMap() { + Map sanitized = SkillSensitiveConfigSanitizer.sanitizeOptions(null); + + sanitized.put("timeoutMs", 1_000); + + assertEquals(1_000, sanitized.get("timeoutMs")); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillVisibilityQueryHelperTenantTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillVisibilityQueryHelperTenantTest.java new file mode 100644 index 00000000..b030217f --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillVisibilityQueryHelperTenantTest.java @@ -0,0 +1,84 @@ +package tech.easyflow.skill.security; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Test; +import org.mockito.MockedStatic; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.SysDeptService; + +import java.math.BigInteger; +import java.util.Locale; +import java.util.Set; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * {@link SkillVisibilityQueryHelper} 的租户边界回归测试。 + */ +public class SkillVisibilityQueryHelperTenantTest { + + /** + * 验证超级管理员的列表查询仍然限定在当前租户内。 + */ + @Test + public void superAdminQueryShouldStillContainCurrentTenantCondition() { + CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class); + SysDeptService sysDeptService = mock(SysDeptService.class); + SkillVisibilityQueryHelper helper = new SkillVisibilityQueryHelper( + categoryPermissionService, sysDeptService); + LoginAccount account = account(7, 42); + when(categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode())) + .thenReturn(new RoleCategoryAccessSnapshot( + CategoryResourceType.SKILL.getCode(), account.getId(), true, true, Set.of())); + QueryWrapper query = QueryWrapper.create().from(Skill.class); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + helper.applyReadableAccess(query); + } + + assertTrue("超级管理员查询缺少 tenant_id 条件: " + query.toSQL(), + query.toSQL().toLowerCase(Locale.ROOT).contains("tenant_id")); + } + + /** + * 验证分类 ALL 范围的列表查询显式包含未分类 Skill。 + */ + @Test + public void allCategoryScopeQueryShouldIncludeUnclassifiedSkills() { + CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class); + SysDeptService sysDeptService = mock(SysDeptService.class); + SkillVisibilityQueryHelper helper = new SkillVisibilityQueryHelper( + categoryPermissionService, sysDeptService); + LoginAccount account = account(7, 42); + when(categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode())) + .thenReturn(new RoleCategoryAccessSnapshot( + CategoryResourceType.SKILL.getCode(), account.getId(), false, true, Set.of())); + QueryWrapper query = QueryWrapper.create().from(Skill.class); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + helper.applyReadableAccess(query); + } + + String sql = query.toSQL().toLowerCase(Locale.ROOT); + assertTrue("ALL 分类查询缺少未分类分支: " + sql, + sql.contains("category_id") && sql.contains("is null")); + } + + private LoginAccount account(long accountId, long tenantId) { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(accountId)); + account.setTenantId(BigInteger.valueOf(tenantId)); + account.setDeptId(BigInteger.valueOf(9)); + return account; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillCategoryServiceImplTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillCategoryServiceImplTest.java new file mode 100644 index 00000000..0158e545 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillCategoryServiceImplTest.java @@ -0,0 +1,132 @@ +package tech.easyflow.skill.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.SkillCategory; + +import java.math.BigInteger; +import java.util.List; + +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; + +/** + * {@link SkillCategoryServiceImpl} 分类循环、深度和删除约束测试。 + */ +public class SkillCategoryServiceImplTest { + + private SkillCategoryServiceImpl service; + private MockedStatic saToken; + + /** + * 初始化可隔离父级查询的分类服务。 + */ + @Before + public void setUp() { + service = spy(new SkillCategoryServiceImpl()); + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.ONE); + account.setTenantId(BigInteger.ONE); + saToken = mockStatic(SaTokenUtil.class); + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + } + + /** + * 释放静态登录态 Mock。 + */ + @After + public void tearDown() { + saToken.close(); + } + + /** + * 验证分类不能将自身设置为父级。 + */ + @Test + public void categoryCannotBeItsOwnParent() { + SkillCategory category = category(1, 1, 1, ""); + doReturn(category).when(service).getById(BigInteger.ONE); + + assertThrows(BusinessException.class, () -> service.updateById(category)); + } + + /** + * 验证创建或移动到三级父级下会因形成第四级而被拒绝。 + */ + @Test + public void categoryCannotMoveBelowLevelThreeParent() { + SkillCategory category = category(10, 3, 1, ""); + SkillCategory parent = category(3, null, 3, "1,2"); + doReturn(parent).when(service).getById(BigInteger.valueOf(3)); + + assertThrows(BusinessException.class, () -> service.updateById(category)); + } + + /** + * 验证分类不能移动到自己的直接或间接后代下。 + */ + @Test + public void categoryCannotMoveUnderDescendant() { + SkillCategory category = category(1, 2, 1, ""); + SkillCategory descendant = category(2, 1, 2, "1"); + doReturn(descendant).when(service).getById(BigInteger.valueOf(2)); + + assertThrows(BusinessException.class, () -> service.updateById(category)); + } + + /** + * 验证移动带子树分类时需要按整个子树的新深度执行三级限制。 + */ + @Test + public void movingSubtreeCannotPushDescendantBeyondLevelThree() { + SkillCategory category = category(1, 9, 1, ""); + SkillCategory newParent = category(9, null, 1, ""); + SkillCategory child = category(2, 1, 2, "1"); + SkillCategory grandchild = category(3, 2, 3, "1,2"); + doReturn(newParent).when(service).getById(BigInteger.valueOf(9)); + doReturn(List.of(child, grandchild)).when(service).list(any(QueryWrapper.class)); + + assertThrows(BusinessException.class, () -> service.updateById(category)); + } + + /** + * 验证存在子分类时删除约束由服务层统一执行。 + */ + @Test + public void categoryWithChildrenCannotBeDeletedAtServiceLayer() { + doReturn(category(1, null, 1, "")).when(service).getById(BigInteger.ONE); + doReturn(true).when(service).hasChildren(BigInteger.ONE); + + assertThrows(BusinessException.class, () -> service.removeById(BigInteger.ONE)); + } + + /** + * 创建分类测试数据。 + * + * @param id 分类 ID + * @param parentId 父级 ID + * @param level 层级 + * @param ancestors 祖先路径 + * @return 分类 + */ + private SkillCategory category(long id, Integer parentId, int level, String ancestors) { + SkillCategory category = new SkillCategory(); + category.setId(BigInteger.valueOf(id)); + category.setParentId(parentId == null ? null : BigInteger.valueOf(parentId)); + category.setCategoryName("category-" + id); + category.setLevelNo(level); + category.setAncestors(ancestors); + category.setStatus(1); + category.setTenantId(BigInteger.ONE); + return category; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillCategoryTenantConstraintTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillCategoryTenantConstraintTest.java new file mode 100644 index 00000000..17e229c1 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillCategoryTenantConstraintTest.java @@ -0,0 +1,145 @@ +package tech.easyflow.skill.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Test; +import org.mockito.MockedStatic; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.SkillCategory; +import tech.easyflow.skill.mapper.SkillCategoryMapper; + +import java.math.BigInteger; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.doReturn; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Skill 分类租户边界与循环约束的有效路径测试。 + */ +public class SkillCategoryTenantConstraintTest { + + /** + * 验证同租户且登录态有效时,自身父级循环仍会被业务规则拒绝。 + */ + @Test + public void selfParentShouldBeRejectedWithValidTenantContext() { + SkillCategoryServiceImpl service = spy(new SkillCategoryServiceImpl()); + SkillCategoryMapper mapper = mock(SkillCategoryMapper.class); + doReturn(mapper).when(service).getMapper(); + SkillCategory category = category(1, 1, 1); + when(mapper.selectTenantTreeForUpdate(BigInteger.ONE)).thenReturn(List.of(category)); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1)); + BusinessException exception = assertThrows( + BusinessException.class, () -> service.updateById(category)); + assertEquals("父级分类不能是自身", exception.getMessage()); + } + } + + /** + * 验证等待并发事务锁后使用最新分类树重新检查循环关系。 + */ + @Test + public void concurrentMoveShouldUseLockedLatestTreeAndRejectCycle() { + SkillCategoryServiceImpl service = spy(new SkillCategoryServiceImpl()); + SkillCategoryMapper mapper = mock(SkillCategoryMapper.class); + doReturn(mapper).when(service).getMapper(); + + SkillCategory movedA = category(1, 2, 1); + movedA.setLevelNo(2); + movedA.setAncestors("2"); + SkillCategory rootB = category(2, null, 1); + rootB.setAncestors(""); + when(mapper.selectTenantTreeForUpdate(BigInteger.ONE)).thenReturn(List.of(movedA, rootB)); + + SkillCategory moveBUnderA = category(2, 1, 1); + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1)); + BusinessException exception = assertThrows( + BusinessException.class, () -> service.updateById(moveBUnderA)); + assertEquals("父级分类不能是当前分类的后代", exception.getMessage()); + } + } + + /** + * 验证其他租户的分类不能被当前租户用作 Skill 分类。 + */ + @Test + public void categoryFromAnotherTenantShouldBeTreatedAsMissing() { + SkillCategoryServiceImpl service = spy(new SkillCategoryServiceImpl()); + doReturn(mock(SkillCategoryMapper.class)).when(service).getMapper(); + doReturn(null).when(service).getOne(any(QueryWrapper.class)); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1)); + BusinessException exception = assertThrows(BusinessException.class, + () -> service.validateUsableCategory(BigInteger.valueOf(9))); + assertEquals("Skill 分类不存在", exception.getMessage()); + } + } + + /** + * Skill 归类写入必须复用分类结构变更的完整租户树排他锁。 + */ + @Test + public void skillCategoryAssignmentShouldLockTenantTree() { + SkillCategoryServiceImpl service = spy(new SkillCategoryServiceImpl()); + SkillCategoryMapper mapper = mock(SkillCategoryMapper.class); + doReturn(mapper).when(service).getMapper(); + SkillCategory target = category(9, null, 1); + when(mapper.selectTenantTreeForUpdate(BigInteger.ONE)).thenReturn(List.of(target)); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1)); + service.lockAndValidateUsableCategory(target.getId()); + } + + verify(mapper).selectTenantTreeForUpdate(BigInteger.ONE); + } + + /** + * 移出分类同样必须锁树,确保分类删除在移动提交后重新检查占用。 + */ + @Test + public void movingSkillToUncategorizedShouldStillLockTenantTree() { + SkillCategoryServiceImpl service = spy(new SkillCategoryServiceImpl()); + SkillCategoryMapper mapper = mock(SkillCategoryMapper.class); + doReturn(mapper).when(service).getMapper(); + when(mapper.selectTenantTreeForUpdate(BigInteger.ONE)).thenReturn(List.of()); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1)); + service.lockAndValidateUsableCategory(null); + } + + verify(mapper).selectTenantTreeForUpdate(BigInteger.ONE); + } + + private SkillCategory category(long id, Integer parentId, long tenantId) { + SkillCategory category = new SkillCategory(); + category.setId(BigInteger.valueOf(id)); + category.setTenantId(BigInteger.valueOf(tenantId)); + category.setParentId(parentId == null ? null : BigInteger.valueOf(parentId)); + category.setCategoryName("category-" + id); + category.setLevelNo(1); + category.setStatus(1); + return category; + } + + private LoginAccount account(long accountId, long tenantId) { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(accountId)); + account.setTenantId(BigInteger.valueOf(tenantId)); + return account; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillLegacySummaryBackfillTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillLegacySummaryBackfillTest.java new file mode 100644 index 00000000..ecf7aa0c --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillLegacySummaryBackfillTest.java @@ -0,0 +1,163 @@ +package tech.easyflow.skill.service.impl; + +import com.easyagents.skill.util.SkillHashes; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Test; +import org.mockito.MockedStatic; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.skill.capability.SkillCapabilityBindingService; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.entity.SkillResource; +import tech.easyflow.skill.mapper.SkillMapper; +import tech.easyflow.skill.service.SkillCategoryService; +import tech.easyflow.skill.service.SkillResourceService; +import tech.easyflow.skill.store.DBSkillContentStore; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * V27 旧 Skill 在只读详情路径中的摘要回填测试。 + */ +public class SkillLegacySummaryBackfillTest { + + /** + * 验证 READ 权限即可获得完整 hash,并通过专用 Mapper 条件回填且不改内存审计值。 + */ + @Test + public void readDetailShouldBackfillMissingHashesWithoutManagePermission() { + BigInteger skillId = BigInteger.valueOf(101); + BigInteger tenantId = BigInteger.valueOf(42); + BigInteger accountId = BigInteger.valueOf(7); + Date originalModified = new Date(1_700_000_000_000L); + String content = "---\nname: demo-skill\ndescription: Demo\n---\n# Demo\n"; + String referenceText = "# Reference\n"; + String referenceHash = SkillHashes.sha256Hex(referenceText.getBytes(StandardCharsets.UTF_8)); + String expectedCapabilityHash = SkillHashes.sha256Hex("[]".getBytes(StandardCharsets.UTF_8)); + + Skill skill = new Skill(); + skill.setId(skillId); + skill.setTenantId(tenantId); + skill.setCreatedBy(accountId); + skill.setSkillContent(content); + skill.setModified(originalModified); + skill.setModifiedBy(accountId); + SkillResource resource = new SkillResource(); + resource.setPath("references/guide.md"); + resource.setNormalizedPath("references/guide.md"); + resource.setKind("REFERENCE"); + resource.setIsText(true); + resource.setTextContent(referenceText); + resource.setContentHash(referenceHash); + resource.setSize((long) referenceText.getBytes(StandardCharsets.UTF_8).length); + + SkillMapper mapper = mock(SkillMapper.class); + SkillResourceService resourceService = mock(SkillResourceService.class); + SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class); + ResourceAccessService accessService = mock(ResourceAccessService.class); + SkillServiceImpl service = spy(new SkillServiceImpl( + mock(SkillCategoryService.class), resourceService, capabilityService, + mock(DBSkillContentStore.class), accessService, + mock(CategoryPermissionService.class), new ObjectMapper())); + doReturn(mapper).when(service).getMapper(); + doReturn(skill).when(service).getOne(any(QueryWrapper.class)); + when(resourceService.list(any(QueryWrapper.class))).thenReturn(List.of(resource)); + when(capabilityService.listBindings(skillId)).thenReturn(List.of()); + when(capabilityService.calculateStoredHash(skillId)).thenReturn(expectedCapabilityHash); + LoginAccount account = new LoginAccount(); + account.setId(accountId); + account.setTenantId(tenantId); + + Skill detail; + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + detail = service.getDetail(skillId); + } + + String canonical = "SKILL.md\n" + + SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)) + "\n" + + "references/guide.md\n" + referenceHash + "\n"; + String expectedPackageHash = SkillHashes.sha256Hex(canonical.getBytes(StandardCharsets.UTF_8)); + assertEquals(expectedPackageHash, detail.getPackageHash()); + assertEquals(expectedCapabilityHash, detail.getCapabilityHash()); + assertSame(originalModified, detail.getModified()); + assertEquals(accountId, detail.getModifiedBy()); + verify(mapper).backfillPackageSummary( + skillId, tenantId, expectedPackageHash, 1, 1, 0, 0); + verify(mapper).backfillCapabilityHash(skillId, tenantId, expectedCapabilityHash); + verify(accessService).assertAccess( + CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill"); + verify(accessService, never()).assertAccess( + eq(CategoryResourceType.SKILL), any(Skill.class), eq(ResourceAction.MANAGE), anyString()); + } + + /** + * 验证目标权限不足导致绑定脱敏时,不会用脱敏数据回填错误的能力 hash。 + */ + @Test + public void redactedCapabilityShouldNotBackfillMissingHash() { + BigInteger skillId = BigInteger.valueOf(102); + BigInteger tenantId = BigInteger.valueOf(42); + BigInteger accountId = BigInteger.valueOf(7); + Skill skill = new Skill(); + skill.setId(skillId); + skill.setTenantId(tenantId); + skill.setCreatedBy(accountId); + skill.setSkillContent("---\nname: private-skill\ndescription: Private\n---\n# Private\n"); + skill.setPackageHash("existing-package-hash"); + + SkillCapabilityBinding redacted = new SkillCapabilityBinding(); + redacted.setCapabilityType("MCP"); + redacted.setRuntimeName("private_mcp"); + redacted.setTargetStatus("NO_PERMISSION"); + + SkillMapper mapper = mock(SkillMapper.class); + SkillResourceService resourceService = mock(SkillResourceService.class); + SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class); + ResourceAccessService accessService = mock(ResourceAccessService.class); + SkillServiceImpl service = spy(new SkillServiceImpl( + mock(SkillCategoryService.class), resourceService, capabilityService, + mock(DBSkillContentStore.class), accessService, + mock(CategoryPermissionService.class), new ObjectMapper())); + doReturn(mapper).when(service).getMapper(); + doReturn(skill).when(service).getOne(any(QueryWrapper.class)); + when(resourceService.listDescriptors(skillId, tenantId)).thenReturn(List.of()); + when(capabilityService.listBindings(skillId)).thenReturn(List.of(redacted)); + LoginAccount account = new LoginAccount(); + account.setId(accountId); + account.setTenantId(tenantId); + + Skill detail; + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + detail = service.getManagementDetail(skillId); + } + + assertNull(detail.getCapabilityHash()); + verify(capabilityService, never()).calculateStoredHash(any()); + verify(mapper, never()).backfillCapabilityHash(any(), any(), anyString()); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillResourceServiceImplProjectionTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillResourceServiceImplProjectionTest.java new file mode 100644 index 00000000..0f056cda --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillResourceServiceImplProjectionTest.java @@ -0,0 +1,33 @@ +package tech.easyflow.skill.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Test; + +import java.math.BigInteger; +import java.util.Locale; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * {@link SkillResourceServiceImpl} 轻量资源描述查询契约测试。 + */ +public class SkillResourceServiceImplProjectionTest { + + /** + * 文件树和管理详情查询应保留摘要字段,同时排除正文与内部内容引用。 + */ + @Test + public void descriptorQueryExcludesHeavyAndInternalContentColumns() { + SkillResourceServiceImpl service = new SkillResourceServiceImpl(); + + QueryWrapper query = service.descriptorQuery(BigInteger.ONE, BigInteger.TWO); + String sql = query.toSQL().toLowerCase(Locale.ROOT); + + assertTrue(sql.contains("normalized_path")); + assertTrue(sql.contains("content_hash")); + assertTrue(sql.contains("metadata_json")); + assertFalse(sql.contains("text_content")); + assertFalse(sql.contains("content_ref")); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplContentReferenceTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplContentReferenceTest.java new file mode 100644 index 00000000..a3219539 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplContentReferenceTest.java @@ -0,0 +1,155 @@ +package tech.easyflow.skill.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Before; +import org.junit.Test; +import tech.easyflow.skill.capability.SkillCapabilityBindingService; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.service.SkillCategoryService; +import tech.easyflow.skill.service.SkillResourceService; +import tech.easyflow.skill.store.DBSkillContentStore; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.ResourceAccessService; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * {@link SkillServiceImpl} 发布候选和已发布快照内容引用计数测试。 + */ +public class SkillServiceImplContentReferenceTest { + + private DBSkillContentStore contentStore; + private SkillServiceImpl service; + + /** + * 初始化只关注内容引用的 Skill 服务。 + */ + @Before + public void setUp() { + contentStore = mock(DBSkillContentStore.class); + service = new SkillServiceImpl( + mock(SkillCategoryService.class), + mock(SkillResourceService.class), + mock(SkillCapabilityBindingService.class), + contentStore, + mock(ResourceAccessService.class), + mock(CategoryPermissionService.class), + new ObjectMapper()); + } + + /** + * 验证发布候选按资源出现次数 retain;同一 hash 被多个资源引用时必须持有多份引用。 + */ + @Test + public void retainSnapshotContentsPreservesDuplicateResourceReferences() { + Map snapshot = snapshot("sha256:a", "sha256:a", "sha256:b", null, ""); + + service.retainSnapshotContents(snapshot); + + verify(contentStore, times(2)).retain("sha256:a"); + verify(contentStore).retain("sha256:b"); + verify(contentStore, never()).retain(""); + } + + /** + * 验证候选驳回、旧快照替换或聚合删除时按相同出现次数 release。 + */ + @Test + public void releaseSnapshotContentsBalancesEveryHeldReference() { + Map snapshot = snapshot("sha256:a", "sha256:a", "sha256:b", null, ""); + + service.releaseSnapshotContents(snapshot); + + verify(contentStore, times(2)).release("sha256:a"); + verify(contentStore).release("sha256:b"); + verify(contentStore, never()).release(""); + } + + /** + * 验证 V24 assets 快照仍按资源出现次数释放内容引用。 + */ + @Test + public void legacyAssetSnapshotBalancesEveryHeldReference() { + Map snapshot = Map.of( + "assets", List.of( + Map.of("contentRef", "sha256:legacy"), + Map.of("contentRef", "sha256:legacy"), + Map.of("contentRef", "sha256:other"))); + + service.retainSnapshotContents(snapshot); + service.releaseSnapshotContents(snapshot); + + verify(contentStore, times(2)).retain("sha256:legacy"); + verify(contentStore).retain("sha256:other"); + verify(contentStore, times(2)).release("sha256:legacy"); + verify(contentStore).release("sha256:other"); + } + + /** + * 验证空快照和非列表 resources 不触发引用变化。 + */ + @Test + public void malformedOrEmptySnapshotDoesNotChangeReferences() { + service.retainSnapshotContents(null); + service.releaseSnapshotContents(Map.of("resources", "invalid")); + + verify(contentStore, never()).retain(org.mockito.ArgumentMatchers.anyString()); + verify(contentStore, never()).release(org.mockito.ArgumentMatchers.anyString()); + } + + /** + * 验证删除治理快照仅包含审计所需字段,不携带提示词、资源、能力配置或自定义元数据。 + */ + @Test + public void governanceSnapshotExcludesExecutableAndSensitivePayloads() { + Skill skill = new Skill(); + skill.setId(java.math.BigInteger.valueOf(101)); + skill.setTenantId(java.math.BigInteger.ONE); + skill.setName("demo-skill"); + skill.setDisplayName("Demo Skill"); + skill.setSkillContent("secret prompt"); + skill.setMetadataJson(Map.of("apiKey", "secret")); + skill.setPublishStatus("DRAFT"); + skill.setResourceCount(2); + skill.setCapabilityCount(1); + + Map snapshot = service.buildGovernanceSnapshot(skill); + + assertEquals(skill.getId(), snapshot.get("id")); + assertEquals("demo-skill", snapshot.get("name")); + assertEquals(2, snapshot.get("resourceCount")); + assertEquals(1, snapshot.get("capabilityCount")); + assertFalse(snapshot.containsKey("skillContent")); + assertFalse(snapshot.containsKey("metadataJson")); + assertFalse(snapshot.containsKey("resources")); + assertFalse(snapshot.containsKey("capabilities")); + assertFalse(snapshot.toString().contains("secret")); + } + + /** + * 创建包含指定内容引用序列的快照。 + * + * @param refs 内容引用,可含空值 + * @return 发布快照 + */ + private Map snapshot(String... refs) { + List> resources = new ArrayList<>(); + for (String ref : refs) { + Map resource = new LinkedHashMap<>(); + resource.put("path", "assets/" + resources.size()); + resource.put("contentRef", ref); + resources.add(resource); + } + return Map.of("resources", resources); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplDeletionGuardTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplDeletionGuardTest.java new file mode 100644 index 00000000..c9f1133e --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplDeletionGuardTest.java @@ -0,0 +1,154 @@ +package tech.easyflow.skill.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.capability.SkillCapabilityBindingService; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.mapper.SkillMapper; +import tech.easyflow.skill.service.SkillCategoryService; +import tech.easyflow.skill.service.SkillResourceService; +import tech.easyflow.skill.store.DBSkillContentStore; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link SkillServiceImpl} 聚合删除状态与主行锁约束测试。 + */ +public class SkillServiceImplDeletionGuardTest { + + private static final BigInteger SKILL_ID = BigInteger.valueOf(101); + + private SkillMapper mapper; + private SkillServiceImpl service; + private MockedStatic saToken; + + /** + * 初始化 Skill 删除服务与当前租户登录态。 + */ + @Before + public void setUp() { + mapper = mock(SkillMapper.class); + service = spy(new SkillServiceImpl( + mock(SkillCategoryService.class), + mock(SkillResourceService.class), + mock(SkillCapabilityBindingService.class), + mock(DBSkillContentStore.class), + mock(ResourceAccessService.class), + mock(CategoryPermissionService.class), + new ObjectMapper())); + doReturn(mapper).when(service).getMapper(); + + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(7)); + account.setTenantId(BigInteger.ONE); + saToken = mockStatic(SaTokenUtil.class); + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + } + + /** + * 释放静态登录态 Mock。 + */ + @After + public void tearDown() { + saToken.close(); + } + + /** + * 普通仓储删除不得绕过已发布状态约束,且检查状态前必须锁定 Skill 主行。 + */ + @Test + public void ordinaryDeleteRejectsPublishedSkillAfterRowLock() { + doReturn(skill(PublishStatus.PUBLISHED)).when(service).getOne(any(QueryWrapper.class)); + + BusinessException exception = assertThrows(BusinessException.class, + () -> service.removeAggregate(SKILL_ID)); + + assertEquals(409, exception.getHttpStatus()); + assertTrue(exception.getMessage().contains("先下线")); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + verify(service).getOne(queryCaptor.capture()); + assertTrue(queryCaptor.getValue().toSQL().toUpperCase().contains("FOR UPDATE")); + verify(mapper, never()).deleteByQuery(any(QueryWrapper.class)); + } + + /** + * 普通仓储删除不得删除处于删除审批中的 Skill。 + */ + @Test + public void ordinaryDeleteRejectsDeletePendingSkill() { + doReturn(skill(PublishStatus.DELETE_PENDING)).when(service).getOne(any(QueryWrapper.class)); + + BusinessException exception = assertThrows(BusinessException.class, + () -> service.removeAggregate(SKILL_ID)); + + assertEquals(409, exception.getHttpStatus()); + assertTrue(exception.getMessage().contains("进行中的审批")); + verify(mapper, never()).deleteByQuery(any(QueryWrapper.class)); + } + + /** + * 审批通过后的生命周期入口应允许删除 DELETE_PENDING,并仍通过已锁定聚合执行删除。 + */ + @Test + public void lifecycleDeleteAllowsDeletePendingSkill() { + SkillResourceService resourceService = mock(SkillResourceService.class); + SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class); + ResourceAccessService accessService = mock(ResourceAccessService.class); + SkillServiceImpl lifecycleService = spy(new SkillServiceImpl( + mock(SkillCategoryService.class), + resourceService, + capabilityService, + mock(DBSkillContentStore.class), + accessService, + mock(CategoryPermissionService.class), + new ObjectMapper())); + doReturn(mapper).when(lifecycleService).getMapper(); + doReturn(skill(PublishStatus.DELETE_PENDING)).when(lifecycleService).getOne(any(QueryWrapper.class)); + when(resourceService.list(any(QueryWrapper.class))).thenReturn(List.of()); + when(mapper.deleteByQuery(any(QueryWrapper.class))).thenReturn(1); + + lifecycleService.removeLifecycleAggregate(SKILL_ID); + + verify(capabilityService).removeBySkillId(SKILL_ID); + verify(mapper).deleteByQuery(any(QueryWrapper.class)); + } + + /** + * 创建指定发布状态的最小 Skill。 + * + * @param status 发布状态 + * @return Skill 实体 + */ + private Skill skill(PublishStatus status) { + Skill skill = new Skill(); + skill.setId(SKILL_ID); + skill.setTenantId(BigInteger.ONE); + skill.setName("demo-skill"); + skill.setPublishStatus(status.getCode()); + return skill; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplManagementTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplManagementTest.java new file mode 100644 index 00000000..739c9fc1 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplManagementTest.java @@ -0,0 +1,305 @@ +package tech.easyflow.skill.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.MockedStatic; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.capability.SkillCapabilityBindingService; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillCapabilityBinding; +import tech.easyflow.skill.entity.SkillResource; +import tech.easyflow.skill.service.SkillCategoryService; +import tech.easyflow.skill.service.SkillResourceService; +import tech.easyflow.skill.store.DBSkillContentStore; +import tech.easyflow.skill.validation.SkillValidationResult; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +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.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link SkillServiceImpl} 管理操作测试,覆盖复制、更新与发布校验语义。 + */ +public class SkillServiceImplManagementTest { + + /** + * 复制 Skill 时应改写标准名称、保留未知 frontmatter,并为二进制资源建立独立引用。 + */ + @Test + public void copyDraftPreservesPortableContentAndOwnsBinaryReferences() { + BigInteger sourceId = BigInteger.valueOf(101); + BigInteger copiedId = BigInteger.valueOf(202); + DBSkillContentStore contentStore = mock(DBSkillContentStore.class); + SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class); + SkillServiceImpl service = spy(service(contentStore, capabilityService, + mock(SkillCategoryService.class), mock(ResourceAccessService.class), + mock(CategoryPermissionService.class))); + + Skill source = sourceSkill(sourceId); + Skill copied = new Skill(); + copied.setId(copiedId); + copied.setCapabilityHash("empty-capability-hash"); + doAnswer(invocation -> sourceId.equals(invocation.getArgument(0)) ? source : copied) + .when(service).getDetail(any(BigInteger.class)); + ArgumentCaptor draftCaptor = ArgumentCaptor.forClass(Skill.class); + doReturn(copied).when(service).saveDraft(draftCaptor.capture()); + when(capabilityService.replaceBindings(eq(copiedId), any(), eq("empty-capability-hash"))) + .thenReturn(List.of()); + + Skill result = service.copyDraft(sourceId, "demo-skill-copy", "演示副本", BigInteger.valueOf(9)); + + assertEquals(copiedId, result.getId()); + Skill draft = draftCaptor.getValue(); + assertEquals(BigInteger.valueOf(9), draft.getCategoryId()); + assertEquals("演示副本", draft.getDisplayName()); + assertTrue(draft.getSkillContent().contains("name: demo-skill-copy")); + assertTrue(draft.getSkillContent().contains("nested:")); + assertTrue(draft.getSkillContent().contains("keep-me")); + assertEquals(2, draft.getResources().size()); + verify(contentStore).retain("sha256:" + "a".repeat(64)); + + @SuppressWarnings("unchecked") + ArgumentCaptor> bindingsCaptor = ArgumentCaptor.forClass(List.class); + verify(capabilityService).replaceBindings(eq(copiedId), bindingsCaptor.capture(), + eq("empty-capability-hash")); + assertEquals(BigInteger.valueOf(77), bindingsCaptor.getValue().get(0).getTargetId()); + assertEquals("demo_tool", bindingsCaptor.getValue().get(0).getRuntimeName()); + } + + /** + * 更新草稿可能同时移动分类,必须先锁分类树再锁 Skill 行。 + */ + @Test + public void updateDraftLocksCategoryTreeBeforeSkillRow() { + BigInteger tenantId = BigInteger.valueOf(10); + BigInteger categoryId = BigInteger.valueOf(30); + SkillCategoryService categoryService = mock(SkillCategoryService.class); + ResourceAccessService accessService = mock(ResourceAccessService.class); + SkillServiceImpl service = spy(service(mock(DBSkillContentStore.class), + mock(SkillCapabilityBindingService.class), categoryService, accessService, + mock(CategoryPermissionService.class))); + Skill existing = skill(BigInteger.ONE, tenantId, "demo-skill"); + existing.setDescription("Demo skill"); + existing.setSkillContent("---\nname: demo-skill\ndescription: Demo skill\n---\n# Demo\n"); + existing.setPublishStatus(PublishStatus.DRAFT.getCode()); + Skill incoming = skill(existing.getId(), tenantId, existing.getName()); + incoming.setCategoryId(categoryId); + doReturn(existing).when(service).getOne(any(QueryWrapper.class)); + doThrow(new BusinessException("stop after row lock")).when(accessService) + .assertAccess(eq(CategoryResourceType.SKILL), eq(existing), eq(ResourceAction.MANAGE), anyString()); + + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(20)); + account.setTenantId(tenantId); + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + assertThrows(BusinessException.class, () -> service.updateDraft(incoming)); + } + + InOrder lockOrder = inOrder(categoryService, service); + lockOrder.verify(categoryService).lockAndValidateUsableCategory(categoryId); + lockOrder.verify(service).getOne(any(QueryWrapper.class)); + } + + /** + * 覆盖导入必须在取得行锁后重验状态,禁止覆盖并发完成发布的 Skill。 + */ + @Test + public void overwriteImportRechecksDraftStatusAfterLock() { + BigInteger tenantId = BigInteger.valueOf(10); + BigInteger accountId = BigInteger.valueOf(20); + ResourceAccessService accessService = mock(ResourceAccessService.class); + SkillServiceImpl service = spy(service(mock(DBSkillContentStore.class), + mock(SkillCapabilityBindingService.class), mock(SkillCategoryService.class), + accessService, mock(CategoryPermissionService.class))); + Skill published = skill(BigInteger.valueOf(1), tenantId, "published-skill"); + published.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + doReturn(published).when(service).getOne(any(QueryWrapper.class)); + Skill imported = skill(published.getId(), tenantId, published.getName()); + LoginAccount account = new LoginAccount(); + account.setId(accountId); + account.setTenantId(tenantId); + + BusinessException exception; + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + exception = assertThrows(BusinessException.class, + () -> service.overwriteImportedDraft(imported)); + } + + assertEquals(409, exception.getHttpStatus()); + assertTrue(exception.getMessage().contains("仅允许覆盖草稿状态")); + verify(accessService).assertAccess(CategoryResourceType.SKILL, published, + ResourceAction.MANAGE, "无权限管理该 Skill"); + } + + /** + * 发布级校验必须在解析实时能力前重新校验 Skill 管理权限。 + */ + @Test + public void publishValidationRequiresManagePermission() { + BigInteger skillId = BigInteger.valueOf(101); + ResourceAccessService accessService = mock(ResourceAccessService.class); + SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class); + SkillServiceImpl service = spy(service(mock(DBSkillContentStore.class), capabilityService, + mock(SkillCategoryService.class), accessService, mock(CategoryPermissionService.class))); + Skill detail = skill(skillId, BigInteger.TEN, "demo-skill"); + detail.setSkillContent(""" + --- + name: demo-skill + description: Demonstration skill + --- + # Instructions + """); + doReturn(detail).when(service).getDetail(skillId); + SkillValidationResult capabilityResult = new SkillValidationResult(); + capabilityResult.setValid(true); + when(capabilityService.validateBindings(eq(skillId), isNull(), eq(true))) + .thenReturn(capabilityResult); + + service.validateSkill(skillId, true); + + verify(accessService).assertAccess(CategoryResourceType.SKILL, detail, + ResourceAction.MANAGE, "无权限管理该 Skill"); + verify(capabilityService).validateBindings(skillId, null, true); + } + + /** + * 数据库拒绝创建草稿属于服务端持久化故障,不能返回客户端输入错误。 + */ + @Test + public void saveDraftPersistenceFailureUsesServerErrorStatus() { + SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class); + SkillCategoryService categoryService = mock(SkillCategoryService.class); + SkillServiceImpl service = spy(service(mock(DBSkillContentStore.class), capabilityService, + categoryService, mock(ResourceAccessService.class), + mock(CategoryPermissionService.class))); + doReturn(0L).when(service).count(any(QueryWrapper.class)); + doReturn(false).when(service).save(any(Skill.class)); + when(capabilityService.calculateHash(List.of())).thenReturn("4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"); + Skill draft = new Skill(); + draft.setDisplayName("演示 Skill"); + draft.setSkillContent(""" + --- + name: demo-skill + description: Demonstration skill + --- + # Instructions + """); + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(20)); + account.setTenantId(BigInteger.valueOf(10)); + + BusinessException exception; + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + exception = assertThrows(BusinessException.class, () -> service.saveDraft(draft)); + } + + assertEquals(500, exception.getHttpStatus()); + verify(categoryService).lockAndValidateUsableCategory(null); + } + + /** + * 创建仅注入当前测试依赖的服务实例。 + * + * @param contentStore 内容仓库 + * @param capabilityService 能力服务 + * @param categoryService 分类服务 + * @param accessService 资源权限服务 + * @param categoryPermissionService 分类权限服务 + * @return Skill 服务 + */ + private SkillServiceImpl service(DBSkillContentStore contentStore, + SkillCapabilityBindingService capabilityService, + SkillCategoryService categoryService, + ResourceAccessService accessService, + CategoryPermissionService categoryPermissionService) { + return new SkillServiceImpl(categoryService, mock(SkillResourceService.class), capabilityService, + contentStore, accessService, categoryPermissionService, new ObjectMapper()); + } + + /** + * 创建含未知 frontmatter、文本、二进制和能力配置的源 Skill。 + * + * @param id Skill ID + * @return 源 Skill + */ + private Skill sourceSkill(BigInteger id) { + Skill source = skill(id, BigInteger.ONE, "demo-skill"); + source.setSkillContent(""" + --- + name: demo-skill + description: Demonstrates copying + nested: + value: keep-me + --- + # Demo + """); + SkillResource text = new SkillResource(); + text.setPath("references/guide.md"); + text.setIsText(true); + text.setTextContent("guide"); + text.setMetadataJson(Map.of()); + SkillResource binary = new SkillResource(); + binary.setPath("assets/image.png"); + binary.setIsText(false); + binary.setContentRef("sha256:" + "a".repeat(64)); + binary.setContentHash("a".repeat(64)); + binary.setMetadataJson(Map.of()); + source.setResources(List.of(text, binary)); + + SkillCapabilityBinding binding = new SkillCapabilityBinding(); + binding.setCapabilityType("WORKFLOW"); + binding.setTargetId(BigInteger.valueOf(77)); + binding.setTargetLogicalRef("workflow:demo"); + binding.setRuntimeName("demo_tool"); + binding.setEnabled(true); + binding.setOptionsJson(Map.of("timeoutMs", 2_000)); + source.setCapabilityBindings(List.of(binding)); + return source; + } + + /** + * 创建最小 Skill 实体。 + * + * @param id Skill ID + * @param tenantId 租户 ID + * @param name Skill 名称 + * @return Skill 实体 + */ + private Skill skill(BigInteger id, BigInteger tenantId, String name) { + Skill skill = new Skill(); + skill.setId(id); + skill.setTenantId(tenantId); + skill.setName(name); + return skill; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/store/DBSkillContentStoreMySqlConcurrencyTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/store/DBSkillContentStoreMySqlConcurrencyTest.java new file mode 100644 index 00000000..5d9ea702 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/store/DBSkillContentStoreMySqlConcurrencyTest.java @@ -0,0 +1,306 @@ +package tech.easyflow.skill.store; + +import org.junit.Assume; +import org.junit.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * {@link DBSkillContentStore} 在真实 MySQL REPEATABLE READ 下的同 hash 并发锁序集成测试。 + * + *

测试仅在 {@code EASYFLOW_MYSQL_CONCURRENCY_TEST=true} 时运行。每次创建独立随机数据库, + * 并在 finally 中删除,避免接触开发库现有表或数据。

+ */ +public class DBSkillContentStoreMySqlConcurrencyTest { + + private static final String CONTENT_REF = "sha256:" + "7".repeat(64); + private static final String CONTENT_HASH = "7".repeat(64); + + /** + * 验证竞争者先等待独立 intent 预留,前一写者仍能插入 active 索引并提交;竞争者随后 + * 复用 active 内容并原子删除自己的 intent,全程不出现内容 gap lock 等待环。 + * + * @throws Exception JDBC、并发等待或清理失败 + */ + @Test + public void reserveBeforeRetainAvoidsRepeatableReadGapLockCycle() throws Exception { + Assume.assumeTrue("设置 EASYFLOW_MYSQL_CONCURRENCY_TEST=true 后运行真实 MySQL 并发门禁", + Boolean.parseBoolean(System.getenv("EASYFLOW_MYSQL_CONCURRENCY_TEST"))); + + String schema = "easyflow_skill_lock_" + UUID.randomUUID().toString().replace("-", ""); + String rootUrl = environment("EASYFLOW_MYSQL_TEST_ROOT_URL", "jdbc:mysql://127.0.0.1:33306/"); + if (!rootUrl.endsWith("/")) { + rootUrl += "/"; + } + String options = "?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia%2FShanghai"; + String user = environment("EASYFLOW_MYSQL_TEST_USER", "root"); + String password = environment("EASYFLOW_MYSQL_TEST_PASSWORD", "root"); + + try (Connection admin = DriverManager.getConnection(rootUrl + "mysql" + options, user, password)) { + execute(admin, "CREATE DATABASE `" + schema + "` CHARACTER SET utf8mb4"); + try { + runLockOrderScenario(rootUrl + schema + options, user, password); + } finally { + execute(admin, "DROP DATABASE IF EXISTS `" + schema + "`"); + } + } + } + + /** + * 在隔离数据库中运行两个连接的 intent 等待与 active 内容提交场景。 + * + * @param url 隔离数据库 JDBC URL + * @param user 数据库账号 + * @param password 数据库密码 + * @throws Exception JDBC 或并发断言失败 + */ + private void runLockOrderScenario(String url, String user, String password) throws Exception { + try (Connection setup = DriverManager.getConnection(url, user, password); + Connection writer = DriverManager.getConnection(url, user, password); + Connection contender = DriverManager.getConnection(url, user, password); + Connection observer = DriverManager.getConnection(url, user, password)) { + createTables(setup); + assertRepeatableRead(writer); + assertRepeatableRead(contender); + execute(contender, "SET SESSION innodb_lock_wait_timeout=5"); + + String writerToken = "writer-token"; + String contenderToken = "contender-token"; + insertIntent(writer, writerToken); + + writer.setAutoCommit(false); + assertEquals(1, update(writer, + "UPDATE tb_skill_content_write_intent SET state='WRITING' " + + "WHERE content_ref=? AND reservation_token=? AND state='PENDING'", + CONTENT_REF, writerToken)); + + long contenderConnectionId = connectionId(contender); + CountDownLatch reserveStarted = new CountDownLatch(1); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future reserve = executor.submit(() -> { + reserveStarted.countDown(); + return insertIntent(contender, contenderToken); + }); + assertTrue("竞争者 reserve 未启动", reserveStarted.await(2, TimeUnit.SECONDS)); + awaitIntentLockWait(observer, contenderConnectionId); + + assertEquals(1, insertActive(writer)); + assertEquals(1, update(writer, + "DELETE FROM tb_skill_content_write_intent WHERE content_ref=? " + + "AND reservation_token=? AND EXISTS (SELECT 1 FROM tb_skill_content c " + + "WHERE c.content_ref=tb_skill_content_write_intent.content_ref " + + "AND c.ref_count>0)", + CONTENT_REF, writerToken)); + writer.commit(); + + assertEquals("前一写者删除 intent 后竞争者应取得新预留", 1, + (int) reserve.get(5, TimeUnit.SECONDS)); + } finally { + executor.shutdownNow(); + } + + contender.setAutoCommit(false); + assertEquals(1, update(contender, + "UPDATE tb_skill_content SET ref_count=ref_count+1 WHERE content_ref=? " + + "AND size=? AND ref_count>0", + CONTENT_REF, 32L)); + assertEquals(1, update(contender, + "DELETE FROM tb_skill_content_write_intent WHERE content_ref=? " + + "AND reservation_token=? AND EXISTS (SELECT 1 FROM tb_skill_content c " + + "WHERE c.content_ref=tb_skill_content_write_intent.content_ref " + + "AND c.ref_count>0)", + CONTENT_REF, contenderToken)); + contender.commit(); + + assertEquals(2, queryInt(setup, + "SELECT ref_count FROM tb_skill_content WHERE content_ref='" + CONTENT_REF + "'")); + assertEquals(0, queryInt(setup, "SELECT COUNT(*) FROM tb_skill_content_write_intent")); + } + } + + /** + * 创建与生产锁关键字段一致的最小测试表。 + * + * @param connection 测试数据库连接 + * @throws SQLException DDL 失败 + */ + private void createTables(Connection connection) throws SQLException { + execute(connection, "CREATE TABLE tb_skill_content (" + + "content_ref VARCHAR(128) NOT NULL PRIMARY KEY,content_hash VARCHAR(128) NOT NULL," + + "file_path VARCHAR(2048) NOT NULL,storage_locator VARCHAR(2048) NULL," + + "media_type VARCHAR(128) NULL,size BIGINT NOT NULL,ref_count INT NOT NULL," + + "created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP," + + "modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + + ") ENGINE=InnoDB"); + execute(connection, "CREATE TABLE tb_skill_content_write_intent (" + + "content_ref VARCHAR(128) NOT NULL PRIMARY KEY,reservation_token VARCHAR(128) NOT NULL," + + "content_hash VARCHAR(128) NOT NULL,storage_locator VARCHAR(2048) NOT NULL," + + "media_type VARCHAR(128) NULL,size BIGINT NOT NULL,state VARCHAR(16) NOT NULL," + + "created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP," + + "modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + + ") ENGINE=InnoDB"); + } + + /** + * 插入一条 PENDING 写入意图。 + * + * @param connection 执行连接 + * @param token 预留令牌 + * @return 插入行数 + * @throws SQLException 插入失败 + */ + private int insertIntent(Connection connection, String token) throws SQLException { + return update(connection, + "INSERT INTO tb_skill_content_write_intent(content_ref,reservation_token,content_hash," + + "storage_locator,media_type,size,state) VALUES(?,?,?,?,?,?,'PENDING')", + CONTENT_REF, token, CONTENT_HASH, "test-locator", "application/octet-stream", 32L); + } + + /** + * 插入首个活动内容索引。 + * + * @param connection 写者事务连接 + * @return 插入行数 + * @throws SQLException 插入失败 + */ + private int insertActive(Connection connection) throws SQLException { + return update(connection, + "INSERT INTO tb_skill_content(content_ref,content_hash,file_path,storage_locator," + + "media_type,size,ref_count) VALUES(?,?,?,?,?,?,1)", + CONTENT_REF, CONTENT_HASH, "/attachment/test.bin", "test-locator", + "application/octet-stream", 32L); + } + + /** + * 等待 performance_schema 确认竞争连接正在等待 intent 行锁。 + * + * @param observer 观察连接 + * @param connectionId 竞争连接 ID + * @throws Exception 查询失败或两秒内未观察到锁等待 + */ + private void awaitIntentLockWait(Connection observer, long connectionId) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (System.nanoTime() < deadline) { + try (PreparedStatement statement = observer.prepareStatement( + "SELECT COUNT(*) FROM performance_schema.data_lock_waits w " + + "JOIN performance_schema.threads t " + + "ON t.THREAD_ID=w.REQUESTING_THREAD_ID WHERE t.PROCESSLIST_ID=?")) { + statement.setLong(1, connectionId); + try (ResultSet result = statement.executeQuery()) { + if (result.next() && result.getInt(1) > 0) { + return; + } + } + } + Thread.sleep(20L); + } + throw new AssertionError("未观察到竞争者对 intent 主键的锁等待"); + } + + /** + * 断言连接使用 MySQL 默认的 REPEATABLE READ 隔离级别。 + * + * @param connection 数据库连接 + * @throws SQLException 查询失败 + */ + private void assertRepeatableRead(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery("SELECT @@transaction_isolation")) { + assertTrue(result.next()); + assertEquals("REPEATABLE-READ", result.getString(1).toUpperCase(Locale.ROOT)); + } + } + + /** + * 返回当前 JDBC 连接的 MySQL 连接 ID。 + * + * @param connection 数据库连接 + * @return MySQL 连接 ID + * @throws SQLException 查询失败 + */ + private long connectionId(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery("SELECT CONNECTION_ID()")) { + if (!result.next()) { + throw new SQLException("无法读取 MySQL CONNECTION_ID"); + } + return result.getLong(1); + } + } + + /** + * 执行无参数 SQL。 + * + * @param connection 数据库连接 + * @param sql SQL 文本 + * @throws SQLException 执行失败 + */ + private void execute(Connection connection, String sql) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute(sql); + } + } + + /** + * 执行参数化更新。 + * + * @param connection 数据库连接 + * @param sql SQL 文本 + * @param parameters 绑定参数 + * @return 影响行数 + * @throws SQLException 执行失败 + */ + private int update(Connection connection, String sql, Object... parameters) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.length; index++) { + statement.setObject(index + 1, parameters[index]); + } + return statement.executeUpdate(); + } + } + + /** + * 查询单个整数。 + * + * @param connection 数据库连接 + * @param sql SQL 文本 + * @return 第一列整数 + * @throws SQLException 查询失败 + */ + private int queryInt(Connection connection, String sql) throws SQLException { + try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) { + if (!result.next()) { + throw new SQLException("查询未返回结果"); + } + return result.getInt(1); + } + } + + /** + * 读取非空环境变量或返回默认值。 + * + * @param name 环境变量名 + * @param defaultValue 默认值 + * @return 配置值 + */ + private String environment(String name, String defaultValue) { + String value = System.getenv(name); + return value == null || value.isBlank() ? defaultValue : value.trim(); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/store/DBSkillContentStoreTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/store/DBSkillContentStoreTest.java new file mode 100644 index 00000000..88b9c6fd --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/store/DBSkillContentStoreTest.java @@ -0,0 +1,567 @@ +package tech.easyflow.skill.store; + +import com.easyagents.skill.store.SkillContentStage; +import com.easyagents.skill.util.SkillHashes; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InOrder; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.SimpleTransactionStatus; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.filestorage.FileStorageWriteHandle; +import tech.easyflow.common.filestorage.FileStorageWriteResult; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.SkillContent; +import tech.easyflow.skill.entity.SkillContentWriteIntent; +import tech.easyflow.skill.mapper.SkillContentMapper; +import tech.easyflow.skill.mapper.SkillContentWriteIntentMapper; + +import java.io.ByteArrayInputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +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.when; + +/** + * {@link DBSkillContentStore} 事务、恢复意图、引用计数与清理状态机测试。 + */ +public class DBSkillContentStoreTest { + + private SkillContentMapper contentMapper; + private SkillContentWriteIntentMapper writeIntentMapper; + private FileStorageService fileStorageService; + private PlatformTransactionManager transactionManager; + private DBSkillContentStore contentStore; + + /** + * 初始化隔离的存储依赖。 + */ + @Before + public void setUp() { + contentMapper = mock(SkillContentMapper.class); + writeIntentMapper = mock(SkillContentWriteIntentMapper.class); + fileStorageService = mock(FileStorageService.class); + transactionManager = mock(PlatformTransactionManager.class); + when(transactionManager.getTransaction(any(TransactionDefinition.class))) + .thenAnswer(invocation -> new SimpleTransactionStatus()); + when(writeIntentMapper.findStale(any(Date.class), anyInt())).thenReturn(List.of()); + when(contentMapper.findStalePending(any(Date.class), anyInt())).thenReturn(List.of()); + when(contentMapper.findReleasedBefore(any(Date.class), anyInt())).thenReturn(List.of()); + contentStore = new DBSkillContentStore( + contentMapper, writeIntentMapper, fileStorageService, transactionManager); + } + + /** + * 清除测试线程上的事务同步状态。 + */ + @After + public void tearDown() { + TransactionSynchronizationManager.clear(); + } + + /** + * 验证新内容先提交恢复意图,再在业务事务中写文件、激活索引并原子删除意图。 + */ + @Test + public void putInputStreamCommitsRecoverableWriteIntentAndActiveIndex() { + byte[] bytes = "stream-content".getBytes(java.nio.charset.StandardCharsets.UTF_8); + String contentRef = SkillHashes.sha256Ref(bytes); + FileStorageWriteHandle handle = stubNewContentWrite(bytes, "/attachment/stream.bin"); + + String actual = contentStore.put(new ByteArrayInputStream(bytes), bytes.length); + + assertEquals(contentRef, actual); + verify(transactionManager, atLeastOnce()).getTransaction(any(TransactionDefinition.class)); + verify(transactionManager, atLeastOnce()).commit(any(TransactionStatus.class)); + verify(writeIntentMapper).reserve(eq(contentRef), anyString(), eq(contentHash(contentRef)), + eq(handle.encodeLocator()), eq("application/octet-stream"), eq((long) bytes.length)); + verify(writeIntentMapper).claimForWrite(eq(contentRef), anyString()); + verify(fileStorageService).saveRecoverable(any(MultipartFile.class), eq(handle)); + verify(contentMapper).insertActive(eq(contentRef), eq(contentHash(contentRef)), + eq("/attachment/stream.bin"), eq(handle.encodeLocator()), + eq("application/octet-stream"), eq((long) bytes.length)); + verify(writeIntentMapper).deleteIfActiveExists(eq(contentRef), anyString()); + } + + /** + * 验证已完成内容通过 hash 与大小匹配的原子 retain 复用,不重复写物理文件。 + */ + @Test + public void putExistingContentRetainsWithoutDuplicateFile() { + byte[] bytes = "same-content".getBytes(java.nio.charset.StandardCharsets.UTF_8); + String contentRef = SkillHashes.sha256Ref(bytes); + FileStorageWriteHandle handle = handleFor(contentRef); + when(fileStorageService.prepareRecoverableWrite(anyString(), anyString())).thenReturn(handle); + when(writeIntentMapper.reserve( + eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong())) + .thenReturn(1); + when(contentMapper.retainMatching(contentRef, bytes.length)).thenReturn(1); + when(writeIntentMapper.deleteIfActiveExists(eq(contentRef), anyString())).thenReturn(1); + + assertEquals(contentRef, contentStore.put(bytes)); + + InOrder order = inOrder(writeIntentMapper, contentMapper); + order.verify(writeIntentMapper).reserve( + eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong()); + order.verify(contentMapper).retainMatching(contentRef, bytes.length); + order.verify(writeIntentMapper).deleteIfActiveExists(eq(contentRef), anyString()); + verify(contentMapper).retainMatching(contentRef, bytes.length); + verify(fileStorageService, never()).saveRecoverable(any(MultipartFile.class), any()); + } + + /** + * 验证同一内容引用出现不同大小时显式拒绝,不创建恢复意图。 + */ + @Test + public void mismatchedExistingContentIsRejectedBeforePhysicalWrite() { + byte[] bytes = "hash-collision-check".getBytes(java.nio.charset.StandardCharsets.UTF_8); + String contentRef = SkillHashes.sha256Ref(bytes); + FileStorageWriteHandle handle = handleFor(contentRef); + when(fileStorageService.prepareRecoverableWrite(anyString(), anyString())).thenReturn(handle); + when(writeIntentMapper.reserve( + eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong())) + .thenReturn(1); + when(contentMapper.selectForUpdate(contentRef)) + .thenReturn(content(contentRef, "/attachment/existing.bin", null, 1, bytes.length + 1)); + when(writeIntentMapper.deletePending(eq(contentRef), anyString())).thenReturn(1); + + assertThrows(BusinessException.class, () -> contentStore.put(bytes)); + + verify(writeIntentMapper).reserve( + eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong()); + verify(writeIntentMapper).deletePending(eq(contentRef), anyString()); + verify(fileStorageService, never()).saveRecoverable(any(MultipartFile.class), any()); + } + + /** + * 验证另一节点已持有写入意图时返回可重试冲突,且不会重复上传。 + */ + @Test + public void concurrentWriteIntentPreventsDuplicatePhysicalWrite() { + byte[] bytes = "pending-write".getBytes(java.nio.charset.StandardCharsets.UTF_8); + String contentRef = SkillHashes.sha256Ref(bytes); + FileStorageWriteHandle handle = handleFor(contentRef); + when(fileStorageService.prepareRecoverableWrite(anyString(), anyString())).thenReturn(handle); + when(writeIntentMapper.reserve( + eq(contentRef), anyString(), anyString(), anyString(), anyString(), anyLong())) + .thenThrow(new DuplicateKeyException("duplicate intent")); + + assertThrows(BusinessException.class, () -> contentStore.put(bytes)); + + verify(fileStorageService, never()).saveRecoverable(any(MultipartFile.class), any()); + verify(contentMapper).retainMatching(contentRef, bytes.length); + } + + /** + * 验证旧版零引用索引只有在物理大小与完整哈希重新校验通过后才可恢复。 + */ + @Test + public void verifiedLegacyZeroReferenceContentCanBeResurrected() throws Exception { + byte[] bytes = "verified-legacy-content".getBytes(java.nio.charset.StandardCharsets.UTF_8); + String contentRef = SkillHashes.sha256Ref(bytes); + String filePath = "/legacy/verified.bin"; + FileStorageWriteHandle handle = handleFor(contentRef); + SkillContent legacy = content(contentRef, filePath, null, 0, bytes.length); + when(fileStorageService.prepareRecoverableWrite(anyString(), anyString())).thenReturn(handle); + when(writeIntentMapper.reserve( + eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong())) + .thenReturn(1); + when(contentMapper.selectForUpdate(contentRef)).thenReturn(legacy); + when(fileStorageService.getFileSize(filePath)).thenReturn((long) bytes.length); + when(fileStorageService.readStream(filePath)).thenReturn(new ByteArrayInputStream(bytes)); + when(contentMapper.resurrectVerifiedLegacy( + contentRef, contentHash(contentRef), filePath, bytes.length)).thenReturn(1); + when(writeIntentMapper.deleteIfActiveExists(eq(contentRef), anyString())).thenReturn(1); + + assertEquals(contentRef, contentStore.put(bytes)); + + verify(contentMapper).resurrectVerifiedLegacy( + contentRef, contentHash(contentRef), filePath, bytes.length); + verify(writeIntentMapper).deleteIfActiveExists(eq(contentRef), anyString()); + verify(fileStorageService, never()).saveRecoverable(any(MultipartFile.class), any()); + } + + /** + * 验证旧版物理内容哈希不一致时拒绝恢复,并立即删除尚未产生物理写入的 PENDING 意图。 + * + * @throws Exception 模拟旧文件读取失败 + */ + @Test + public void mismatchedLegacyPhysicalContentIsNotResurrected() throws Exception { + byte[] bytes = "expected-legacy-content".getBytes(java.nio.charset.StandardCharsets.UTF_8); + byte[] changed = "tampered-legacy-content".getBytes(java.nio.charset.StandardCharsets.UTF_8); + String contentRef = SkillHashes.sha256Ref(bytes); + String filePath = "/legacy/tampered.bin"; + FileStorageWriteHandle handle = handleFor(contentRef); + SkillContent legacy = content(contentRef, filePath, null, 0, bytes.length); + when(fileStorageService.prepareRecoverableWrite(anyString(), anyString())).thenReturn(handle); + when(writeIntentMapper.reserve( + eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong())) + .thenReturn(1); + when(contentMapper.selectForUpdate(contentRef)).thenReturn(legacy); + when(fileStorageService.getFileSize(filePath)).thenReturn((long) bytes.length); + when(fileStorageService.readStream(filePath)).thenReturn(new ByteArrayInputStream(changed)); + when(writeIntentMapper.deletePending(eq(contentRef), anyString())).thenReturn(1); + + assertThrows(BusinessException.class, () -> contentStore.put(bytes)); + + verify(writeIntentMapper).deletePending(eq(contentRef), anyString()); + verify(contentMapper, never()).resurrectVerifiedLegacy( + anyString(), anyString(), anyString(), anyLong()); + verify(fileStorageService, never()).saveRecoverable(any(MultipartFile.class), any()); + } + + /** + * 验证索引激活失败时不删除恢复意图,事务回滚后可由定时任务精确回收物理对象。 + */ + @Test + public void failedActiveInsertLeavesRecoverableIntentForCleanup() { + byte[] bytes = "rollback-content".getBytes(java.nio.charset.StandardCharsets.UTF_8); + String contentRef = SkillHashes.sha256Ref(bytes); + FileStorageWriteHandle handle = stubNewContentWrite(bytes, "/attachment/rollback.bin"); + when(contentMapper.insertActive(anyString(), anyString(), anyString(), anyString(), anyString(), anyLong())) + .thenReturn(0); + + assertThrows(BusinessException.class, () -> contentStore.put(bytes)); + + verify(transactionManager).rollback(any(TransactionStatus.class)); + verify(writeIntentMapper, never()).deleteIfActiveExists(eq(contentRef), anyString()); + verify(fileStorageService, never()).deleteRecoverable(handle); + + SkillContentWriteIntent stale = intent(contentRef, handle, "reservation", "PENDING", bytes.length); + when(writeIntentMapper.findStale(any(Date.class), anyInt())).thenReturn(List.of(stale)); + when(writeIntentMapper.deleteIfActiveExists(contentRef, "reservation")).thenReturn(0); + when(writeIntentMapper.claimForCleanup( + eq(contentRef), eq("reservation"), eq("PENDING"), any(Date.class))).thenReturn(1); + when(writeIntentMapper.deleteClaimed(contentRef, "reservation")).thenReturn(1); + + contentStore.cleanupStaleContent(new Date(), 100); + + verify(fileStorageService).deleteRecoverable(handle); + verify(fileStorageService).existsRecoverable(handle); + verify(writeIntentMapper).deleteClaimed(contentRef, "reservation"); + } + + /** + * 验证正式内容已存在时只删除残留意图,绝不删除正式物理对象。 + */ + @Test + public void staleIntentWithActiveContentOnlyRemovesIntent() { + String contentRef = "sha256:" + "a".repeat(64); + FileStorageWriteHandle handle = handleFor(contentRef); + SkillContentWriteIntent stale = intent(contentRef, handle, "active-token", "WRITING", 10); + when(writeIntentMapper.findStale(any(Date.class), anyInt())).thenReturn(List.of(stale)); + when(writeIntentMapper.deleteIfActiveExists(contentRef, "active-token")).thenReturn(1); + + contentStore.cleanupStaleContent(new Date(), 100); + + verify(writeIntentMapper, never()).claimForCleanup( + anyString(), anyString(), anyString(), any(Date.class)); + verify(fileStorageService, never()).deleteRecoverable(any()); + } + + /** + * 验证物理删除失败时保留 CLEANING 意图,后续轮次仍可重试。 + */ + @Test + public void failedIntentPhysicalDeleteKeepsClaimedIntent() { + String contentRef = "sha256:" + "b".repeat(64); + FileStorageWriteHandle handle = handleFor(contentRef); + SkillContentWriteIntent stale = intent(contentRef, handle, "retry-token", "CLEANING", 10); + when(writeIntentMapper.findStale(any(Date.class), anyInt())).thenReturn(List.of(stale)); + when(writeIntentMapper.claimForCleanup( + eq(contentRef), eq("retry-token"), eq("CLEANING"), any(Date.class))).thenReturn(1); + doThrow(new RuntimeException("storage unavailable")) + .when(fileStorageService).deleteRecoverable(handle); + + contentStore.cleanupStaleContent(new Date(), 100); + + verify(writeIntentMapper, never()).deleteClaimed(contentRef, "retry-token"); + } + + /** + * 验证 PENDING 与零引用记录不会被读取、判断存在或重新持有。 + */ + @Test + public void pendingAndZeroReferenceContentAreInvisible() throws Exception { + String contentRef = "sha256:" + "c".repeat(64); + when(contentMapper.countVisible(contentRef)).thenReturn(0); + when(contentMapper.selectOneById(contentRef)) + .thenReturn(content(contentRef, "__PENDING__:reservation", null, 0, 12)); + when(contentMapper.retain(contentRef)).thenReturn(0); + + assertFalse(contentStore.exists(contentRef)); + assertThrows(BusinessException.class, () -> contentStore.open(contentRef)); + assertThrows(BusinessException.class, () -> contentStore.retain(contentRef)); + verify(fileStorageService, never()).readStream(anyString()); + } + + /** + * 验证最后一份可恢复内容只在事务提交后删除物理对象与零引用索引。 + */ + @Test + public void lastReleasePurgesRecoverableObjectOnlyAfterCommit() { + String contentRef = "sha256:" + "d".repeat(64); + String filePath = "/attachment/final.bin"; + FileStorageWriteHandle handle = handleFor(contentRef); + SkillContent content = content(contentRef, filePath, handle.encodeLocator(), 1, 10); + when(contentMapper.releaseShared(contentRef)).thenReturn(0); + when(contentMapper.selectForUpdate(contentRef)).thenReturn(content); + when(contentMapper.markReleased(contentRef, filePath, handle.encodeLocator())).thenReturn(1); + TransactionSynchronizationManager.initSynchronization(); + + contentStore.release(contentRef); + List synchronizations = currentSynchronizations(); + + verify(fileStorageService, never()).deleteRecoverable(handle); + synchronizations.forEach(TransactionSynchronization::afterCommit); + verify(fileStorageService).deleteRecoverable(handle); + verify(fileStorageService).existsRecoverable(handle); + verify(contentMapper).deleteReleased(contentRef, filePath, handle.encodeLocator()); + } + + /** + * 验证事务回滚不会触发最后引用的物理删除。 + */ + @Test + public void lastReleaseRollbackNeverPurgesPhysicalObject() { + String contentRef = "sha256:" + "e".repeat(64); + FileStorageWriteHandle handle = handleFor(contentRef); + SkillContent content = content(contentRef, "/attachment/rollback.bin", handle.encodeLocator(), 1, 10); + when(contentMapper.releaseShared(contentRef)).thenReturn(0); + when(contentMapper.selectForUpdate(contentRef)).thenReturn(content); + when(contentMapper.markReleased( + contentRef, content.getFilePath(), handle.encodeLocator())).thenReturn(1); + TransactionSynchronizationManager.initSynchronization(); + + contentStore.release(contentRef); + currentSynchronizations().forEach(synchronization -> + synchronization.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); + + verify(fileStorageService, never()).deleteRecoverable(any()); + verify(contentMapper, never()).deleteReleased(anyString(), anyString(), anyString()); + } + + /** + * 验证缺少 locator 的旧内容释放后保留零引用索引,不执行无法证明正确的 URL 删除。 + */ + @Test + public void legacyReleaseWithoutLocatorKeepsTrackedIndex() { + String contentRef = "sha256:" + "f".repeat(64); + SkillContent content = content(contentRef, "/legacy/random.bin", null, 1, 10); + when(contentMapper.releaseShared(contentRef)).thenReturn(0); + when(contentMapper.selectForUpdate(contentRef)).thenReturn(content); + when(contentMapper.markReleased(contentRef, content.getFilePath(), null)).thenReturn(1); + TransactionSynchronizationManager.initSynchronization(); + + contentStore.release(contentRef); + + assertTrue(TransactionSynchronizationManager.getSynchronizations().isEmpty()); + verify(fileStorageService, never()).delete(anyString()); + verify(fileStorageService, never()).deleteRecoverable(any()); + verify(contentMapper, never()).deleteReleased(anyString(), anyString(), anyString()); + } + + /** + * 验证提交后删除失败会保留零引用索引,并由定时清理再次尝试。 + */ + @Test + public void failedAfterCommitDeleteIsRetriedByCleanup() { + String contentRef = "sha256:" + "1".repeat(64); + FileStorageWriteHandle handle = handleFor(contentRef); + SkillContent content = content( + contentRef, "/attachment/retry.bin", handle.encodeLocator(), 1, 10); + when(contentMapper.releaseShared(contentRef)).thenReturn(0); + when(contentMapper.selectForUpdate(contentRef)).thenReturn(content); + when(contentMapper.markReleased( + contentRef, content.getFilePath(), handle.encodeLocator())).thenReturn(1); + doThrow(new RuntimeException("storage unavailable")) + .doNothing() + .when(fileStorageService).deleteRecoverable(handle); + TransactionSynchronizationManager.initSynchronization(); + + contentStore.release(contentRef); + currentSynchronizations().forEach(TransactionSynchronization::afterCommit); + verify(contentMapper, never()).deleteReleased( + contentRef, content.getFilePath(), handle.encodeLocator()); + + content.setRefCount(0); + when(contentMapper.findReleasedBefore(any(Date.class), anyInt())).thenReturn(List.of(content)); + contentStore.cleanupStaleContent(new Date(), 100); + + verify(fileStorageService, times(2)).deleteRecoverable(handle); + verify(contentMapper).deleteReleased(contentRef, content.getFilePath(), handle.encodeLocator()); + } + + /** + * 验证旧版超时 PENDING 占位仍通过条件删除安全回收。 + */ + @Test + public void staleLegacyPendingReservationIsCleaned() { + String contentRef = "sha256:" + "2".repeat(64); + String pendingPath = "__PENDING__:stale"; + SkillContent pending = content(contentRef, pendingPath, null, 0, 10); + when(contentMapper.findStalePending(any(Date.class), anyInt())).thenReturn(List.of(pending)); + + contentStore.cleanupStaleContent(new Date(), 100); + + verify(contentMapper).deleteStalePending(eq(contentRef), eq(pendingPath), any(Date.class)); + } + + /** + * 验证提交前会重新校验暂存内容,阻止内容被替换后写入错误 hash。 + * + * @throws Exception 文件操作失败 + */ + @Test + public void changedStageIsRejectedBeforeCommit() throws Exception { + byte[] original = "original".getBytes(java.nio.charset.StandardCharsets.UTF_8); + SkillContentStage stage = contentStore.stage(new ByteArrayInputStream(original), original.length); + Path stagePath = Path.of(stage.getStageId()); + Files.writeString(stagePath, "changed!"); + + assertThrows(BusinessException.class, () -> contentStore.commit(stage)); + + assertFalse(Files.exists(stagePath)); + verify(contentMapper, never()).retainMatching(anyString(), anyLong()); + verify(writeIntentMapper, never()).reserve( + anyString(), anyString(), anyString(), anyString(), anyString(), anyLong()); + } + + /** + * 配置一次成功的新内容意图、物理写入与索引激活。 + * + * @param bytes 模拟内容 + * @param fileUrl 模拟读取 URL + * @return 确定性恢复句柄 + */ + private FileStorageWriteHandle stubNewContentWrite(byte[] bytes, String fileUrl) { + String contentRef = SkillHashes.sha256Ref(bytes); + FileStorageWriteHandle handle = handleFor(contentRef); + when(fileStorageService.prepareRecoverableWrite( + "skill-content/" + contentHash(contentRef).substring(0, 2), + contentHash(contentRef) + ".bin")).thenReturn(handle); + when(writeIntentMapper.reserve( + eq(contentRef), anyString(), eq(contentHash(contentRef)), eq(handle.encodeLocator()), + anyString(), eq((long) bytes.length))).thenReturn(1); + when(writeIntentMapper.claimForWrite(eq(contentRef), anyString())).thenReturn(1); + when(fileStorageService.saveRecoverable(any(MultipartFile.class), eq(handle))) + .thenReturn(new FileStorageWriteResult(fileUrl, handle.encodeLocator())); + when(contentMapper.insertActive( + eq(contentRef), eq(contentHash(contentRef)), eq(fileUrl), eq(handle.encodeLocator()), + anyString(), eq((long) bytes.length))).thenReturn(1); + when(writeIntentMapper.deleteIfActiveExists(eq(contentRef), anyString())).thenReturn(1); + return handle; + } + + /** + * 为内容引用创建测试用确定性本地恢复句柄。 + * + * @param contentRef 内容引用 + * @return 恢复句柄 + */ + private FileStorageWriteHandle handleFor(String contentRef) { + String hash = contentHash(contentRef); + return new FileStorageWriteHandle( + "local", "", "/tmp/easyflow-content-test", + "skill-content/" + hash.substring(0, 2), hash + ".bin"); + } + + /** + * 创建指定状态的内容索引测试对象。 + * + * @param contentRef 内容引用 + * @param filePath 文件或占位路径 + * @param locator 稳定恢复定位符 + * @param refCount 引用数 + * @param size 内容大小 + * @return 内容索引 + */ + private SkillContent content(String contentRef, String filePath, String locator, int refCount, long size) { + SkillContent content = new SkillContent(); + content.setContentRef(contentRef); + content.setContentHash(contentHash(contentRef)); + content.setFilePath(filePath); + content.setStorageLocator(locator); + content.setMediaType("application/octet-stream"); + content.setSize(size); + content.setRefCount(refCount); + content.setCreated(new Date()); + content.setModified(new Date()); + return content; + } + + /** + * 创建指定状态的写入意图测试对象。 + * + * @param contentRef 内容引用 + * @param handle 恢复句柄 + * @param token 预留令牌 + * @param state 意图状态 + * @param size 内容大小 + * @return 写入意图 + */ + private SkillContentWriteIntent intent( + String contentRef, FileStorageWriteHandle handle, String token, String state, long size) { + SkillContentWriteIntent intent = new SkillContentWriteIntent(); + intent.setContentRef(contentRef); + intent.setReservationToken(token); + intent.setContentHash(contentHash(contentRef)); + intent.setStorageLocator(handle.encodeLocator()); + intent.setMediaType("application/octet-stream"); + intent.setSize(size); + intent.setState(state); + intent.setCreated(new Date(0)); + intent.setModified(new Date(0)); + return intent; + } + + /** + * 从标准内容引用取得十六进制哈希。 + * + * @param contentRef 内容引用 + * @return 十六进制哈希 + */ + private String contentHash(String contentRef) { + return contentRef.substring("sha256:".length()); + } + + /** + * 获取当前测试事务已注册的同步回调。 + * + * @return 同步回调副本 + */ + private List currentSynchronizations() { + List synchronizations = + new ArrayList<>(TransactionSynchronizationManager.getSynchronizations()); + assertTrue("应注册事务同步回调", !synchronizations.isEmpty()); + return synchronizations; + } +} diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/permission/resource/VisibilityResource.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/permission/resource/VisibilityResource.java index e52f697b..f0787500 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/permission/resource/VisibilityResource.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/permission/resource/VisibilityResource.java @@ -2,13 +2,43 @@ package tech.easyflow.system.permission.resource; import java.math.BigInteger; +/** + * 可由统一资源权限服务判定可见性与动作权限的资源契约。 + */ public interface VisibilityResource { + /** + * 获取资源所属租户。 + * + * @return 租户 ID + */ + BigInteger getTenantId(); + + /** + * 获取资源创建者。 + * + * @return 创建者账号 ID + */ BigInteger getCreatedBy(); + /** + * 获取资源所属部门。 + * + * @return 部门 ID + */ BigInteger getDeptId(); + /** + * 获取资源所属分类。 + * + * @return 分类 ID,未分类时可为空 + */ BigInteger getCategoryId(); + /** + * 获取资源可见范围。 + * + * @return 可见范围编码 + */ String getVisibilityScope(); } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/CategoryPermissionServiceImpl.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/CategoryPermissionServiceImpl.java index 9b303617..46b1430e 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/CategoryPermissionServiceImpl.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/CategoryPermissionServiceImpl.java @@ -119,7 +119,7 @@ public class CategoryPermissionServiceImpl implements CategoryPermissionService @Override public void assertCategoryResourceVisible(String resourceType, BigInteger createdBy, BigInteger categoryId, String message) { if (!canAccessCategory(resourceType, createdBy, categoryId)) { - throw new BusinessException(message == null ? "无权限访问该资源" : message); + throw new BusinessException(403, 403, message == null ? "无权限访问该资源" : message); } } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java index 48400006..55907dfb 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java @@ -53,6 +53,10 @@ public class ResourceAccessServiceImpl implements ResourceAccessService { if (loginAccount == null || loginAccount.getId() == null) { return false; } + if (loginAccount.getTenantId() == null || resource.getTenantId() == null + || !loginAccount.getTenantId().equals(resource.getTenantId())) { + return false; + } BigInteger accountId = loginAccount.getId(); // 分享访问需要先完成密钥校验与审计,即使当前账号同时也是资源创建者或超管。 if (hasExtendedGrant(loginAccount, resourceType, resource, action)) { @@ -67,6 +71,10 @@ public class ResourceAccessServiceImpl implements ResourceAccessService { if (ResourceAction.MANAGE == action) { return false; } + if (CategoryResourceType.SKILL == resourceType && resource.getCategoryId() == null + && categoryPermissionService.getAccess(resourceType.getCode(), loginAccount).isAllAccess()) { + return true; + } if (!categoryPermissionService.canAccessCategory(loginAccount, resourceType.getCode(), resource.getCreatedBy(), resource.getCategoryId())) { return false; } diff --git a/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/ResourceAccessServiceImplTest.java b/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/ResourceAccessServiceImplTest.java new file mode 100644 index 00000000..4a9fb07d --- /dev/null +++ b/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/ResourceAccessServiceImplTest.java @@ -0,0 +1,202 @@ +package tech.easyflow.system.service.impl; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.enums.VisibilityScope; +import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; +import tech.easyflow.system.permission.resource.VisibilityResource; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.SysDeptService; + +import java.lang.reflect.Field; +import java.math.BigInteger; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * {@link ResourceAccessServiceImpl} 资源动作与可见范围回归测试。 + */ +public class ResourceAccessServiceImplTest { + + private CategoryPermissionService categoryPermissionService; + private SysDeptService sysDeptService; + private ResourceAccessServiceImpl service; + + /** + * 初始化被测服务及权限依赖。 + * + * @throws Exception 反射注入失败时抛出 + */ + @Before + public void setUp() throws Exception { + categoryPermissionService = Mockito.mock(CategoryPermissionService.class); + sysDeptService = Mockito.mock(SysDeptService.class); + service = new ResourceAccessServiceImpl(); + inject(service, "categoryPermissionService", categoryPermissionService); + inject(service, "sysDeptService", sysDeptService); + } + + /** + * 验证创建者始终可以管理自己的 Skill。 + */ + @Test + public void ownerShouldManageOwnResource() { + LoginAccount account = account(7, 70); + VisibilityResource resource = resource(7, 99, 700, VisibilityScope.PRIVATE); + + assertTrue(service.canAccess(account, CategoryResourceType.SKILL, resource, ResourceAction.MANAGE)); + } + + /** + * 验证非创建者即使能查看分类,也不能获得 MANAGE。 + */ + @Test + public void nonOwnerShouldNotManageResource() { + LoginAccount account = account(8, 80); + VisibilityResource resource = resource(7, 80, 700, VisibilityScope.PUBLIC); + Mockito.when(categoryPermissionService.canAccessCategory( + account, CategoryResourceType.SKILL.getCode(), BigInteger.valueOf(7), BigInteger.valueOf(700))) + .thenReturn(true); + + assertFalse(service.canAccess(account, CategoryResourceType.SKILL, resource, ResourceAction.MANAGE)); + } + + /** + * 验证 PUBLIC 仍必须先通过分类范围,避免公开标记绕过分类授权。 + */ + @Test + public void publicResourceShouldStillRequireCategoryAccess() { + LoginAccount account = account(8, 80); + VisibilityResource resource = resource(7, 90, 700, VisibilityScope.PUBLIC); + + Mockito.when(categoryPermissionService.canAccessCategory( + account, CategoryResourceType.SKILL.getCode(), BigInteger.valueOf(7), BigInteger.valueOf(700))) + .thenReturn(false); + + assertFalse(service.canAccess(account, CategoryResourceType.SKILL, resource, ResourceAction.READ)); + } + + /** + * 验证部门可见资源使用现有部门树访问判定。 + */ + @Test + public void departmentResourceShouldUseDepartmentAccess() { + LoginAccount account = account(8, 80); + VisibilityResource resource = resource(7, 90, 700, VisibilityScope.DEPT); + Mockito.when(categoryPermissionService.canAccessCategory( + account, CategoryResourceType.SKILL.getCode(), BigInteger.valueOf(7), BigInteger.valueOf(700))) + .thenReturn(true); + Mockito.when(sysDeptService.canUserAccessDeptScopedResource(BigInteger.valueOf(80), BigInteger.valueOf(90))) + .thenReturn(true); + + assertTrue(service.canAccess(account, CategoryResourceType.SKILL, resource, ResourceAction.READ)); + } + + /** + * 验证 PRIVATE 对分类内其他用户仍不可见。 + */ + @Test + public void privateResourceShouldStayPrivateWithinCategory() { + LoginAccount account = account(8, 80); + VisibilityResource resource = resource(7, 80, 700, VisibilityScope.PRIVATE); + Mockito.when(categoryPermissionService.canAccessCategory( + account, CategoryResourceType.SKILL.getCode(), BigInteger.valueOf(7), BigInteger.valueOf(700))) + .thenReturn(true); + + assertFalse(service.canAccess(account, CategoryResourceType.SKILL, resource, ResourceAction.READ)); + } + + /** + * 验证 Skill 分类 ALL 范围可以读取其他创建者的未分类私有草稿。 + */ + @Test + public void allCategoryScopeShouldReadUnclassifiedPrivateSkill() { + LoginAccount account = account(8, 80); + VisibilityResource resource = new TestVisibilityResource( + BigInteger.ONE, BigInteger.valueOf(7), BigInteger.valueOf(90), null, + VisibilityScope.PRIVATE.name()); + Mockito.when(categoryPermissionService.getAccess(CategoryResourceType.SKILL.getCode(), account)) + .thenReturn(new RoleCategoryAccessSnapshot( + CategoryResourceType.SKILL.getCode(), account.getId(), false, true, Set.of())); + + assertTrue(service.canAccess(account, CategoryResourceType.SKILL, resource, ResourceAction.READ)); + assertFalse(service.canAccess(account, CategoryResourceType.SKILL, resource, ResourceAction.MANAGE)); + } + + /** + * 验证资源动作不能跨越租户边界,即使资源是公开状态。 + */ + @Test + public void resourceShouldNeverCrossTenantBoundary() { + LoginAccount account = account(8, 80); + VisibilityResource resource = resource(7, 80, 700, VisibilityScope.PUBLIC, BigInteger.TWO); + + assertFalse(service.canAccess(account, CategoryResourceType.SKILL, resource, ResourceAction.READ)); + Mockito.verifyNoInteractions(categoryPermissionService, sysDeptService); + } + + private LoginAccount account(long id, long deptId) { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(id)); + account.setDeptId(BigInteger.valueOf(deptId)); + account.setTenantId(BigInteger.ONE); + return account; + } + + private VisibilityResource resource(long createdBy, long deptId, long categoryId, VisibilityScope scope) { + return resource(createdBy, deptId, categoryId, scope, BigInteger.ONE); + } + + private VisibilityResource resource(long createdBy, long deptId, long categoryId, + VisibilityScope scope, BigInteger tenantId) { + return new TestVisibilityResource( + tenantId, + BigInteger.valueOf(createdBy), + BigInteger.valueOf(deptId), + BigInteger.valueOf(categoryId), + scope.name()); + } + + private void inject(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private record TestVisibilityResource(BigInteger tenantId, + BigInteger createdBy, + BigInteger deptId, + BigInteger categoryId, + String visibilityScope) implements VisibilityResource { + @Override + public BigInteger getTenantId() { + return tenantId; + } + + @Override + public BigInteger getCreatedBy() { + return createdBy; + } + + @Override + public BigInteger getDeptId() { + return deptId; + } + + @Override + public BigInteger getCategoryId() { + return categoryId; + } + + @Override + public String getVisibilityScope() { + return visibilityScope; + } + } +} diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml b/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml index c66ac1a9..ec2edbfe 100644 --- a/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml @@ -44,7 +44,8 @@ spring: servlet: multipart: max-file-size: 100MB - max-request-size: 100MB + # 为 multipart 边界和请求头预留空间,文件本身仍由 M18 的 100 MiB 硬上限约束。 + max-request-size: 105MB web: resources: # 示例:windows【file: C:\easyflow\attachment】 linux【file: /www/easyflow/attachment】 @@ -276,7 +277,7 @@ jetcache: keyConvertor: fastjson2 broadcastChannel: easyflow-cache valueEncoder: java - valueDecoder: java + valueDecoder: bean:easyFlowJetCacheValueDecoder poolConfig: minIdle: 1 maxIdle: 12 diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V27__mysql_skill_resource_capability.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V27__mysql_skill_resource_capability.sql new file mode 100644 index 00000000..9399dabc --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V27__mysql_skill_resource_capability.sql @@ -0,0 +1,520 @@ +SET NAMES utf8mb4; + +-- 所有不可逆 DDL 前先检查旧分类同租户同父级重名;命中时用固定主键冲突显式终止迁移。 +CREATE TEMPORARY TABLE `tmp_skill_category_migration_guard` ( + `guard_key` TINYINT NOT NULL, + PRIMARY KEY (`guard_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO `tmp_skill_category_migration_guard` (`guard_key`) VALUES (1); + +INSERT INTO `tmp_skill_category_migration_guard` (`guard_key`) +SELECT 1 +FROM `tb_skill_category` +WHERE `tenant_id` IS NULL +LIMIT 1; + +INSERT INTO `tmp_skill_category_migration_guard` (`guard_key`) +SELECT 1 +FROM `tb_skill` +WHERE `tenant_id` IS NULL +LIMIT 1; + +INSERT INTO `tmp_skill_category_migration_guard` (`guard_key`) +SELECT 1 +FROM `tb_skill_category` +GROUP BY `tenant_id`, IFNULL(`parent_id`, 0), `category_name` +HAVING COUNT(1) > 1 +LIMIT 1; + +DROP TEMPORARY TABLE `tmp_skill_category_migration_guard`; + +-- 旧资源必须归属于有效租户 Skill;孤儿记录、空租户或跨租户记录直接终止迁移。 +CREATE TEMPORARY TABLE `tmp_skill_resource_owner_guard` ( + `guard_key` TINYINT NOT NULL, + PRIMARY KEY (`guard_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO `tmp_skill_resource_owner_guard` (`guard_key`) VALUES (1); + +INSERT INTO `tmp_skill_resource_owner_guard` (`guard_key`) +SELECT 1 +FROM ( + SELECT reference.`skill_id`, reference.`tenant_id` + FROM `tb_skill_reference` reference + UNION ALL + SELECT script.`skill_id`, script.`tenant_id` + FROM `tb_skill_script` script + UNION ALL + SELECT asset.`skill_id`, asset.`tenant_id` + FROM `tb_skill_asset` asset +) legacy_resource +LEFT JOIN `tb_skill` skill ON skill.`id` = legacy_resource.`skill_id` +WHERE skill.`id` IS NULL + OR skill.`tenant_id` IS NULL + OR (legacy_resource.`tenant_id` IS NOT NULL AND legacy_resource.`tenant_id` <> skill.`tenant_id`) +LIMIT 1; + +DROP TEMPORARY TABLE `tmp_skill_resource_owner_guard`; + +-- V24 发布快照和迁移前尚未结束的发布审批使用 assets[] 保存二进制引用。 +-- 新结构优先使用 resources[];仅在 resources 不是数组时回退 assets[],与运行时兼容读取规则一致。 +CREATE TEMPORARY TABLE `tmp_skill_snapshot_content_ref` ( + `content_ref` VARCHAR(128) NOT NULL, + `ref_count` INT NOT NULL, + PRIMARY KEY (`content_ref`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO `tmp_skill_snapshot_content_ref` (`content_ref`, `ref_count`) +SELECT snapshot_ref.`content_ref`, COUNT(1) +FROM ( + SELECT legacy_asset.`content_ref` + FROM `tb_skill` skill + JOIN JSON_TABLE( + COALESCE(skill.`published_snapshot_json`, JSON_OBJECT()), + '$.assets[*]' COLUMNS ( + `content_ref` VARCHAR(128) PATH '$.contentRef' NULL ON EMPTY NULL ON ERROR + ) + ) legacy_asset ON TRUE + WHERE COALESCE(JSON_TYPE(JSON_EXTRACT(skill.`published_snapshot_json`, '$.resources')), '') <> 'ARRAY' + UNION ALL + SELECT resource.`content_ref` + FROM `tb_skill` skill + JOIN JSON_TABLE( + COALESCE(skill.`published_snapshot_json`, JSON_OBJECT()), + '$.resources[*]' COLUMNS ( + `content_ref` VARCHAR(128) PATH '$.contentRef' NULL ON EMPTY NULL ON ERROR + ) + ) resource ON TRUE + WHERE JSON_TYPE(JSON_EXTRACT(skill.`published_snapshot_json`, '$.resources')) = 'ARRAY' + UNION ALL + SELECT legacy_asset.`content_ref` + FROM `tb_approval_instance` approval + JOIN JSON_TABLE( + COALESCE(approval.`snapshot_json`, JSON_OBJECT()), + '$.resourceSnapshot.assets[*]' COLUMNS ( + `content_ref` VARCHAR(128) PATH '$.contentRef' NULL ON EMPTY NULL ON ERROR + ) + ) legacy_asset ON TRUE + WHERE approval.`resource_type` = 'SKILL' + AND approval.`action_type` = 'PUBLISH' + AND approval.`status` IN ('PENDING', 'PROCESSING') + AND COALESCE(JSON_TYPE(JSON_EXTRACT(approval.`snapshot_json`, '$.resourceSnapshot.resources')), '') <> 'ARRAY' + UNION ALL + SELECT resource.`content_ref` + FROM `tb_approval_instance` approval + JOIN JSON_TABLE( + COALESCE(approval.`snapshot_json`, JSON_OBJECT()), + '$.resourceSnapshot.resources[*]' COLUMNS ( + `content_ref` VARCHAR(128) PATH '$.contentRef' NULL ON EMPTY NULL ON ERROR + ) + ) resource ON TRUE + WHERE approval.`resource_type` = 'SKILL' + AND approval.`action_type` = 'PUBLISH' + AND approval.`status` IN ('PENDING', 'PROCESSING') + AND JSON_TYPE(JSON_EXTRACT(approval.`snapshot_json`, '$.resourceSnapshot.resources')) = 'ARRAY' +) snapshot_ref +WHERE snapshot_ref.`content_ref` IS NOT NULL + AND snapshot_ref.`content_ref` <> '' +GROUP BY snapshot_ref.`content_ref`; + +-- 二进制资源必须有可验证的 sha256 内容索引,否则无法安全修复物理文件关系。 +CREATE TEMPORARY TABLE `tmp_skill_content_migration_guard` ( + `guard_key` TINYINT NOT NULL, + PRIMARY KEY (`guard_key`) +) ENGINE=InnoDB; + +INSERT INTO `tmp_skill_content_migration_guard` (`guard_key`) VALUES (1); + +INSERT INTO `tmp_skill_content_migration_guard` (`guard_key`) +SELECT 1 +FROM `tb_skill_asset` asset +LEFT JOIN `tb_skill_asset_content` content ON content.`content_ref` = asset.`content_ref` +WHERE content.`content_ref` IS NULL + OR content.`content_ref` NOT REGEXP '^sha256:[0-9a-f]{64}$' + OR content.`content_hash` NOT REGEXP '^[0-9a-fA-F]{64}$' + OR content.`content_ref` <> CONCAT('sha256:', LOWER(content.`content_hash`)) +LIMIT 1; + +INSERT INTO `tmp_skill_content_migration_guard` (`guard_key`) +SELECT 1 +FROM `tb_skill_asset_content` content +WHERE content.`content_ref` NOT REGEXP '^sha256:[0-9a-f]{64}$' + OR content.`content_hash` NOT REGEXP '^[0-9a-fA-F]{64}$' + OR content.`content_ref` <> CONCAT('sha256:', LOWER(content.`content_hash`)) +LIMIT 1; + +INSERT INTO `tmp_skill_content_migration_guard` (`guard_key`) +SELECT 1 +FROM `tmp_skill_snapshot_content_ref` snapshot_ref +LEFT JOIN `tb_skill_asset_content` content ON content.`content_ref` = snapshot_ref.`content_ref` +WHERE content.`content_ref` IS NULL +LIMIT 1; + +DROP TEMPORARY TABLE `tmp_skill_content_migration_guard`; + +-- 在永久 DDL 前构建已修复的内容迁移源;引用数不得低于真实资源数。 +CREATE TEMPORARY TABLE `tmp_skill_content_migration_source` ( + `content_ref` VARCHAR(128) NOT NULL, + `content_hash` VARCHAR(128) NOT NULL, + `file_path` VARCHAR(1024) NOT NULL, + `media_type` VARCHAR(128) NULL, + `size` BIGINT NOT NULL, + `ref_count` INT NOT NULL, + `created` DATETIME NULL, + `modified` DATETIME NULL, + PRIMARY KEY (`content_ref`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO `tmp_skill_content_migration_source` ( + `content_ref`, `content_hash`, `file_path`, `media_type`, `size`, `ref_count`, `created`, `modified` +) +SELECT content.`content_ref`, LOWER(content.`content_hash`), content.`file_path`, content.`media_type`, + GREATEST(COALESCE(content.`size`, 0), + COALESCE((SELECT MAX(asset.`size`) FROM `tb_skill_asset` asset + WHERE asset.`content_ref` = content.`content_ref`), 0), + 0), + (SELECT COUNT(1) FROM `tb_skill_asset` asset WHERE asset.`content_ref` = content.`content_ref`) + + COALESCE((SELECT snapshot_ref.`ref_count` FROM `tmp_skill_snapshot_content_ref` snapshot_ref + WHERE snapshot_ref.`content_ref` = content.`content_ref`), 0), + content.`created`, content.`modified` +FROM `tb_skill_asset_content` content; + +-- 合并旧资源并修复可确定的 hash/size/租户;跨表路径、大小写或 ID 冲突会显式终止。 +CREATE TEMPORARY TABLE `tmp_skill_resource_migration_source` ( + `id` BIGINT NOT NULL, + `tenant_id` BIGINT NOT NULL, + `skill_id` BIGINT NOT NULL, + `path` VARCHAR(512) NOT NULL, + `normalized_path` VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `kind` VARCHAR(32) NOT NULL, + `language` VARCHAR(32) NULL, + `media_type` VARCHAR(128) NULL, + `is_text` TINYINT(1) NOT NULL, + `text_content` MEDIUMTEXT NULL, + `content_ref` VARCHAR(128) NULL, + `content_hash` VARCHAR(128) NOT NULL, + `size` BIGINT NOT NULL, + `metadata_json` JSON NULL, + `sort_no` INT NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tmp_skill_resource_path` (`skill_id`, `normalized_path`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO `tmp_skill_resource_migration_source` ( + `id`, `tenant_id`, `skill_id`, `path`, `normalized_path`, `kind`, `language`, `media_type`, + `is_text`, `text_content`, `content_ref`, `content_hash`, `size`, `metadata_json`, `sort_no` +) +SELECT reference.`id`, COALESCE(reference.`tenant_id`, skill.`tenant_id`), reference.`skill_id`, + reference.`path`, reference.`path`, 'REFERENCE', 'MARKDOWN', 'text/markdown', 1, + COALESCE(reference.`content`, ''), NULL, + LOWER(SHA2(COALESCE(reference.`content`, ''), 256)), + OCTET_LENGTH(COALESCE(reference.`content`, '')), reference.`metadata_json`, 0 +FROM `tb_skill_reference` reference +JOIN `tb_skill` skill ON skill.`id` = reference.`skill_id` +UNION ALL +SELECT script.`id`, COALESCE(script.`tenant_id`, skill.`tenant_id`), script.`skill_id`, + script.`path`, script.`path`, 'SCRIPT', script.`language`, 'text/plain', 1, + COALESCE(script.`content`, ''), NULL, + LOWER(SHA2(COALESCE(script.`content`, ''), 256)), + OCTET_LENGTH(COALESCE(script.`content`, '')), script.`metadata_json`, 0 +FROM `tb_skill_script` script +JOIN `tb_skill` skill ON skill.`id` = script.`skill_id` +UNION ALL +SELECT asset.`id`, COALESCE(asset.`tenant_id`, skill.`tenant_id`), asset.`skill_id`, + asset.`path`, asset.`path`, 'ASSET', NULL, asset.`media_type`, 0, NULL, asset.`content_ref`, + content.`content_hash`, + content.`size`, asset.`metadata_json`, 0 +FROM `tb_skill_asset` asset +JOIN `tb_skill` skill ON skill.`id` = asset.`skill_id` +JOIN `tmp_skill_content_migration_source` content ON content.`content_ref` = asset.`content_ref`; + +SET @skill_summary_columns_ddl = ( + SELECT CASE + WHEN COUNT(1) = 0 THEN 'SELECT 1' + ELSE CONCAT('ALTER TABLE `tb_skill` ', GROUP_CONCAT(stmt ORDER BY ord SEPARATOR ', ')) + END + FROM ( + SELECT 1 AS ord, + 'ADD COLUMN `capability_hash` VARCHAR(128) NULL COMMENT ''能力配置 hash'' AFTER `package_hash`' AS stmt + WHERE NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'tb_skill' AND column_name = 'capability_hash' + ) + UNION ALL + SELECT 2 AS ord, + 'ADD COLUMN `snapshot_hash` VARCHAR(128) NULL COMMENT ''发布快照 hash'' AFTER `capability_hash`' AS stmt + WHERE NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'tb_skill' AND column_name = 'snapshot_hash' + ) + UNION ALL + SELECT 3 AS ord, + 'ADD COLUMN `resource_count` INT NOT NULL DEFAULT 0 COMMENT ''资源数量'' AFTER `snapshot_hash`' AS stmt + WHERE NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'tb_skill' AND column_name = 'resource_count' + ) + UNION ALL + SELECT 4 AS ord, + 'ADD COLUMN `capability_count` INT NOT NULL DEFAULT 0 COMMENT ''能力数量'' AFTER `resource_count`' AS stmt + WHERE NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'tb_skill' AND column_name = 'capability_count' + ) + ) changes +); +PREPARE skill_summary_columns_stmt FROM @skill_summary_columns_ddl; +EXECUTE skill_summary_columns_stmt; +DEALLOCATE PREPARE skill_summary_columns_stmt; + +SET @skill_category_parent_scope_ddl = ( + SELECT IF(COUNT(1) = 0, + 'ALTER TABLE `tb_skill_category` ADD COLUMN `parent_scope_id` BIGINT GENERATED ALWAYS AS (IFNULL(`parent_id`, 0)) STORED COMMENT ''同级唯一约束辅助列''', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tb_skill_category' + AND column_name = 'parent_scope_id' +); +PREPARE skill_category_parent_scope_stmt FROM @skill_category_parent_scope_ddl; +EXECUTE skill_category_parent_scope_stmt; +DEALLOCATE PREPARE skill_category_parent_scope_stmt; + +-- V24 旧数据可能以 0 表示根分类,统一归一为 NULL,与服务层父级语义保持一致。 +UPDATE `tb_skill_category` +SET `parent_id` = NULL, `modified` = `modified`, `modified_by` = `modified_by` +WHERE `parent_id` = 0; + +-- MySQL DDL 会自动提交;修复 Flyway 失败记录后重跑时不得因索引已创建再次失败。 +SET @skill_category_unique_ddl = ( + SELECT IF(COUNT(1) = 0, + 'ALTER TABLE `tb_skill_category` ADD UNIQUE KEY `uk_skill_category_tenant_parent_name` (`tenant_id`, `parent_scope_id`, `category_name`)', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'tb_skill_category' + AND index_name = 'uk_skill_category_tenant_parent_name' +); +PREPARE skill_category_unique_stmt FROM @skill_category_unique_ddl; +EXECUTE skill_category_unique_stmt; +DEALLOCATE PREPARE skill_category_unique_stmt; + +CREATE TABLE IF NOT EXISTS `tb_skill_resource` ( + `id` BIGINT NOT NULL COMMENT 'ID', + `tenant_id` BIGINT NOT NULL COMMENT '租户ID', + `skill_id` BIGINT NOT NULL COMMENT 'Skill ID', + `path` VARCHAR(512) NOT NULL COMMENT '原始逻辑路径', + `normalized_path` VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化逻辑路径', + `kind` VARCHAR(32) NOT NULL COMMENT 'REFERENCE/SCRIPT/ASSET/EXAMPLE/OTHER', + `language` VARCHAR(32) NULL COMMENT '文本或脚本语言', + `media_type` VARCHAR(128) NULL COMMENT '媒体类型', + `is_text` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为UTF-8文本', + `text_content` MEDIUMTEXT NULL COMMENT '文本内容', + `content_ref` VARCHAR(128) NULL COMMENT '二进制内容引用', + `content_hash` VARCHAR(128) NOT NULL COMMENT '内容hash', + `size` BIGINT NOT NULL DEFAULT 0 COMMENT '字节数', + `metadata_json` JSON NULL COMMENT '资源元数据', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序', + `created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `created_by` BIGINT NULL COMMENT '创建人', + `modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + `modified_by` BIGINT NULL COMMENT '修改人', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_skill_resource_path` (`skill_id`, `normalized_path`), + KEY `idx_skill_resource_tenant_skill` (`tenant_id`, `skill_id`, `sort_no`), + KEY `idx_skill_resource_content_ref` (`content_ref`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill 通用资源'; + +CREATE TABLE IF NOT EXISTS `tb_skill_content` ( + `content_ref` VARCHAR(128) NOT NULL COMMENT '内容引用', + `content_hash` VARCHAR(128) NOT NULL COMMENT '内容hash', + `file_path` VARCHAR(1024) NOT NULL COMMENT '存储路径', + `media_type` VARCHAR(128) NULL COMMENT '媒体类型', + `size` BIGINT NOT NULL DEFAULT 0 COMMENT '字节数', + `ref_count` INT NOT NULL DEFAULT 0 COMMENT '引用数', + `created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`content_ref`), + KEY `idx_skill_content_hash` (`content_hash`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill 二进制内容索引'; + +CREATE TABLE IF NOT EXISTS `tb_skill_capability_binding` ( + `id` BIGINT NOT NULL COMMENT 'ID', + `tenant_id` BIGINT NOT NULL COMMENT '租户ID', + `skill_id` BIGINT NOT NULL COMMENT 'Skill ID', + `capability_type` VARCHAR(32) NOT NULL COMMENT 'WORKFLOW/PLUGIN_ITEM/MCP', + `target_id` BIGINT NULL COMMENT '目标资源ID,未解析导入项可为空', + `target_logical_ref` VARCHAR(512) NULL COMMENT '跨环境目标逻辑引用', + `runtime_name` VARCHAR(128) NOT NULL COMMENT '运行时名称或MCP命名空间', + `enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + `selection_mode` VARCHAR(16) NULL COMMENT 'MCP工具选择模式', + `selected_tool_names_json` JSON NULL COMMENT '选择的MCP工具名', + `execution_mode` VARCHAR(16) NULL COMMENT 'SYNC/ASYNC', + `hitl_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用人工确认', + `hitl_config_json` JSON NULL COMMENT '人工确认非敏感配置', + `options_json` JSON NULL COMMENT '非敏感扩展配置', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序', + `created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `created_by` BIGINT NULL COMMENT '创建人', + `modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + `modified_by` BIGINT NULL COMMENT '修改人', + PRIMARY KEY (`id`), + KEY `idx_skill_capability_tenant_skill` (`tenant_id`, `skill_id`, `sort_no`), + KEY `idx_skill_capability_target` (`capability_type`, `target_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill 平台能力绑定'; + +CREATE TABLE IF NOT EXISTS `tb_skill_import_stage` ( + `import_token` VARCHAR(64) NOT NULL COMMENT '单次导入令牌', + `tenant_id` BIGINT NOT NULL COMMENT '租户ID', + `account_id` BIGINT NOT NULL COMMENT '创建用户ID', + `file_path` VARCHAR(1024) NOT NULL COMMENT '受控临时包路径', + `original_name` VARCHAR(255) NULL COMMENT '原始文件名', + `format` VARCHAR(16) NOT NULL COMMENT 'STANDARD/EASYFLOW', + `status` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/PROCESSING', + `expires_at` DATETIME NOT NULL COMMENT '过期时间', + `created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`import_token`), + KEY `idx_skill_import_stage_expire` (`status`, `expires_at`), + KEY `idx_skill_import_stage_owner` (`tenant_id`, `account_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill 导入临时包'; + +-- 兼容 DDL 已部分提交的旧 V27 重跑,强制新表租户边界列不可为空。 +UPDATE `tb_skill_resource` target +JOIN `tmp_skill_resource_migration_source` source + ON source.`id` = target.`id` + AND source.`skill_id` = target.`skill_id` + AND source.`normalized_path` = target.`normalized_path` +SET target.`tenant_id` = source.`tenant_id`, + target.`modified` = target.`modified`, + target.`modified_by` = target.`modified_by` +WHERE target.`tenant_id` IS NULL; + +UPDATE `tb_skill_capability_binding` binding +JOIN `tb_skill` skill ON skill.`id` = binding.`skill_id` +SET binding.`tenant_id` = skill.`tenant_id`, + binding.`modified` = binding.`modified`, + binding.`modified_by` = binding.`modified_by` +WHERE binding.`tenant_id` IS NULL AND skill.`tenant_id` IS NOT NULL; + +CREATE TEMPORARY TABLE `tmp_skill_new_table_tenant_guard` ( + `guard_key` TINYINT NOT NULL, + PRIMARY KEY (`guard_key`) +) ENGINE=InnoDB; +INSERT INTO `tmp_skill_new_table_tenant_guard` (`guard_key`) VALUES (1); +INSERT INTO `tmp_skill_new_table_tenant_guard` (`guard_key`) +SELECT 1 +FROM `tb_skill_resource` resource +LEFT JOIN `tb_skill` skill ON skill.`id` = resource.`skill_id` +WHERE resource.`tenant_id` IS NULL + OR skill.`id` IS NULL + OR skill.`tenant_id` IS NULL + OR resource.`tenant_id` <> skill.`tenant_id` +LIMIT 1; +INSERT INTO `tmp_skill_new_table_tenant_guard` (`guard_key`) +SELECT 1 +FROM `tb_skill_capability_binding` binding +LEFT JOIN `tb_skill` skill ON skill.`id` = binding.`skill_id` +WHERE binding.`tenant_id` IS NULL + OR skill.`id` IS NULL + OR skill.`tenant_id` IS NULL + OR binding.`tenant_id` <> skill.`tenant_id` +LIMIT 1; +DROP TEMPORARY TABLE `tmp_skill_new_table_tenant_guard`; + +ALTER TABLE `tb_skill_resource` MODIFY COLUMN `tenant_id` BIGINT NOT NULL COMMENT '租户ID'; +ALTER TABLE `tb_skill_resource` + MODIFY COLUMN `normalized_path` VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化逻辑路径'; +ALTER TABLE `tb_skill_capability_binding` MODIFY COLUMN `tenant_id` BIGINT NOT NULL COMMENT '租户ID'; + +-- 如果上一次迁移在 DDL 自动提交后失败,只允许复用与迁移源完全一致的已写入行。 +CREATE TEMPORARY TABLE `tmp_skill_target_migration_guard` ( + `guard_key` TINYINT NOT NULL, + PRIMARY KEY (`guard_key`) +) ENGINE=InnoDB; + +INSERT INTO `tmp_skill_target_migration_guard` (`guard_key`) VALUES (1); + +INSERT INTO `tmp_skill_target_migration_guard` (`guard_key`) +SELECT 1 +FROM `tb_skill_content` target +JOIN `tmp_skill_content_migration_source` source ON source.`content_ref` = target.`content_ref` +WHERE NOT ( + target.`content_hash` <=> source.`content_hash` + AND target.`file_path` <=> source.`file_path` + AND target.`media_type` <=> source.`media_type` + AND target.`size` <=> source.`size` + AND target.`ref_count` <=> source.`ref_count` + AND target.`created` <=> source.`created` + AND target.`modified` <=> source.`modified` +) +LIMIT 1; + +INSERT INTO `tmp_skill_target_migration_guard` (`guard_key`) +SELECT 1 +FROM `tb_skill_resource` target +JOIN `tmp_skill_resource_migration_source` source + ON source.`id` = target.`id` + OR (source.`skill_id` = target.`skill_id` AND source.`normalized_path` = target.`normalized_path`) +WHERE NOT ( + target.`id` <=> source.`id` + AND target.`tenant_id` <=> source.`tenant_id` + AND target.`skill_id` <=> source.`skill_id` + AND target.`path` <=> source.`path` + AND target.`normalized_path` <=> source.`normalized_path` + AND target.`kind` <=> source.`kind` + AND target.`language` <=> source.`language` + AND target.`media_type` <=> source.`media_type` + AND target.`is_text` <=> source.`is_text` + AND target.`text_content` <=> source.`text_content` + AND target.`content_ref` <=> source.`content_ref` + AND target.`content_hash` <=> source.`content_hash` + AND target.`size` <=> source.`size` + AND target.`metadata_json` <=> source.`metadata_json` + AND target.`sort_no` <=> source.`sort_no` +) +LIMIT 1; + +DROP TEMPORARY TABLE `tmp_skill_target_migration_guard`; + +INSERT INTO `tb_skill_content` ( + `content_ref`, `content_hash`, `file_path`, `media_type`, `size`, `ref_count`, `created`, `modified` +) +SELECT source.`content_ref`, source.`content_hash`, source.`file_path`, source.`media_type`, + source.`size`, source.`ref_count`, source.`created`, source.`modified` +FROM `tmp_skill_content_migration_source` source +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_skill_content` target WHERE target.`content_ref` = source.`content_ref` +); + +INSERT INTO `tb_skill_resource` ( + `id`, `tenant_id`, `skill_id`, `path`, `normalized_path`, `kind`, `language`, `media_type`, + `is_text`, `text_content`, `content_ref`, `content_hash`, `size`, `metadata_json`, `sort_no` +) +SELECT source.`id`, source.`tenant_id`, source.`skill_id`, source.`path`, source.`normalized_path`, + source.`kind`, source.`language`, source.`media_type`, source.`is_text`, source.`text_content`, + source.`content_ref`, source.`content_hash`, source.`size`, source.`metadata_json`, source.`sort_no` +FROM `tmp_skill_resource_migration_source` source +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_skill_resource` target WHERE target.`id` = source.`id` +); + +DROP TEMPORARY TABLE `tmp_skill_resource_migration_source`; +DROP TEMPORARY TABLE `tmp_skill_content_migration_source`; +DROP TEMPORARY TABLE `tmp_skill_snapshot_content_ref`; + +UPDATE `tb_skill` skill +SET `resource_count` = (SELECT COUNT(1) FROM `tb_skill_resource` resource WHERE resource.`skill_id` = skill.`id`), + `capability_count` = (SELECT COUNT(1) FROM `tb_skill_capability_binding` binding WHERE binding.`skill_id` = skill.`id`), + `reference_count` = (SELECT COUNT(1) FROM `tb_skill_resource` resource + WHERE resource.`skill_id` = skill.`id` AND resource.`kind` = 'REFERENCE'), + `script_count` = (SELECT COUNT(1) FROM `tb_skill_resource` resource + WHERE resource.`skill_id` = skill.`id` AND resource.`kind` = 'SCRIPT'), + `asset_count` = (SELECT COUNT(1) FROM `tb_skill_resource` resource + WHERE resource.`skill_id` = skill.`id` + AND resource.`kind` NOT IN ('REFERENCE', 'SCRIPT') + AND resource.`is_text` = 0), + `package_hash` = NULL, + `capability_hash` = '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', + `modified` = `modified`, + `modified_by` = `modified_by`; diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V28__mysql_skill_operation_permissions.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V28__mysql_skill_operation_permissions.sql new file mode 100644 index 00000000..947dde89 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V28__mysql_skill_operation_permissions.sql @@ -0,0 +1,37 @@ +SET NAMES utf8mb4; + +INSERT INTO `tb_sys_menu` (`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`) +SELECT 367400000000000019, 367400000000000001, 1, '分类管理', '', '', '', 0, '/api/v1/skill/category', 9, 0, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP, 1, 'Skill-分类管理' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000019); + +INSERT INTO `tb_sys_menu` (`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`) +SELECT 367400000000000020, 367400000000000001, 1, '文件管理', '', '', '', 0, '/api/v1/skill/file', 10, 0, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP, 1, 'Skill-文件管理' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000020); + +INSERT INTO `tb_sys_menu` (`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`) +SELECT 367400000000000021, 367400000000000001, 1, '导入', '', '', '', 0, '/api/v1/skill/import', 11, 0, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP, 1, 'Skill-导入' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000021); + +INSERT INTO `tb_sys_menu` (`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`) +SELECT 367400000000000022, 367400000000000001, 1, '导出', '', '', '', 0, '/api/v1/skill/export', 12, 0, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP, 1, 'Skill-导出' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000022); + +INSERT INTO `tb_sys_menu` (`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`) +SELECT 367400000000000024, 367400000000000001, 1, '能力绑定', '', '', '', 0, '/api/v1/skill/capability', 14, 0, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP, 1, 'Skill-能力绑定' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000024); + +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) +SELECT 367400000000000119, 1, 367400000000000019 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000019); +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) +SELECT 367400000000000120, 1, 367400000000000020 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000020); +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) +SELECT 367400000000000121, 1, 367400000000000021 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000021); +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) +SELECT 367400000000000122, 1, 367400000000000022 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000022); +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) +SELECT 367400000000000124, 1, 367400000000000024 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000024); diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V29__mysql_skill_delete_permission_cleanup.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V29__mysql_skill_delete_permission_cleanup.sql new file mode 100644 index 00000000..6f620c7a --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V29__mysql_skill_delete_permission_cleanup.sql @@ -0,0 +1,29 @@ +SET NAMES utf8mb4; + +-- 已同时持有正式删除权限的角色先移除重复旧映射,避免唯一索引冲突。 +DELETE legacy_mapping +FROM `tb_sys_role_menu` legacy_mapping +INNER JOIN `tb_sys_role_menu` canonical_mapping + ON canonical_mapping.`role_id` = legacy_mapping.`role_id` + AND canonical_mapping.`menu_id` = 367400000000000018 +WHERE legacy_mapping.`menu_id` = 367400000000000015; + +-- 保留已部署环境中对旧删除入口的显式角色授权,并迁移到真实审批/删除入口。 +UPDATE `tb_sys_role_menu` +SET `menu_id` = 367400000000000018 +WHERE `menu_id` = 367400000000000015; + +-- 删除没有对应 Controller 的历史权限菜单。 +DELETE FROM `tb_sys_menu` +WHERE `id` = 367400000000000015 + AND `permission_tag` = '/api/v1/skill/remove'; + +-- 真实入口会根据审批配置提交审批或直接删除,统一使用一个操作权限。 +UPDATE `tb_sys_menu` +SET `menu_title` = '删除', + `sort_no` = 5, + `modified` = CURRENT_TIMESTAMP, + `modified_by` = 1, + `remark` = 'Skill-删除(审批或直接执行)' +WHERE `id` = 367400000000000018 + AND `permission_tag` = '/api/v1/skill/submitDeleteApproval'; diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V31__mysql_skill_content_write_intent.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V31__mysql_skill_content_write_intent.sql new file mode 100644 index 00000000..bf4c981a --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V31__mysql_skill_content_write_intent.sql @@ -0,0 +1,31 @@ +SET @skill_content_storage_locator_ddl = ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tb_skill_content' + AND column_name = 'storage_locator' + ), + 'SELECT 1', + 'ALTER TABLE `tb_skill_content` ADD COLUMN `storage_locator` VARCHAR(2048) NULL COMMENT ''稳定存储定位符'' AFTER `file_path`' + ) +); + +PREPARE skill_content_storage_locator_stmt FROM @skill_content_storage_locator_ddl; +EXECUTE skill_content_storage_locator_stmt; +DEALLOCATE PREPARE skill_content_storage_locator_stmt; + +CREATE TABLE IF NOT EXISTS `tb_skill_content_write_intent` ( + `content_ref` VARCHAR(128) NOT NULL COMMENT '内容引用', + `reservation_token` VARCHAR(128) NOT NULL COMMENT '写入预留令牌', + `content_hash` VARCHAR(128) NOT NULL COMMENT '内容hash', + `storage_locator` VARCHAR(2048) NOT NULL COMMENT '稳定存储定位符', + `media_type` VARCHAR(128) NULL COMMENT '媒体类型', + `size` BIGINT NOT NULL DEFAULT 0 COMMENT '字节数', + `state` VARCHAR(16) NOT NULL COMMENT 'PENDING/WRITING/CLEANING', + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`content_ref`), + KEY `idx_skill_content_write_intent_state_modified` (`state`, `modified`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill 内容写入意图'; diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V32__mysql_approval_instance_tenant.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V32__mysql_approval_instance_tenant.sql new file mode 100644 index 00000000..86ff5882 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V32__mysql_approval_instance_tenant.sql @@ -0,0 +1,32 @@ +SET NAMES utf8mb4; + +-- 在任何 DDL 之前阻断无法回填租户的历史实例,避免 MySQL DDL 自动提交留下半迁移结构。 +CREATE TEMPORARY TABLE `tmp_approval_instance_tenant_guard` ( + `guard_key` TINYINT NOT NULL, + PRIMARY KEY (`guard_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO `tmp_approval_instance_tenant_guard` (`guard_key`) VALUES (1); + +INSERT INTO `tmp_approval_instance_tenant_guard` (`guard_key`) +SELECT 1 +FROM `tb_approval_instance` approval +LEFT JOIN `tb_sys_account` applicant ON applicant.`id` = approval.`applicant_id` +WHERE applicant.`id` IS NULL OR applicant.`tenant_id` IS NULL +LIMIT 1; + +DROP TEMPORARY TABLE `tmp_approval_instance_tenant_guard`; + +ALTER TABLE `tb_approval_instance` + ADD COLUMN `tenant_id` BIGINT UNSIGNED NULL COMMENT '租户ID' AFTER `id`; + +-- 账号使用逻辑删除,历史申请人仍保留在账号表中,可无歧义回填审批实例租户。 +UPDATE `tb_approval_instance` approval +JOIN `tb_sys_account` applicant ON applicant.`id` = approval.`applicant_id` +SET approval.`tenant_id` = applicant.`tenant_id`; + +ALTER TABLE `tb_approval_instance` + MODIFY COLUMN `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID'; + +CREATE INDEX `idx_approval_instance_tenant_status` + ON `tb_approval_instance` (`tenant_id`, `status`, `submitted_at`);