From 30a9bbc1e7f13476f542b75caa73a240bac9350d Mon Sep 17 00:00:00 2001 From: yoyuzh Date: Sun, 12 Apr 2026 00:32:21 +0800 Subject: [PATCH] Refactor backend and frontend modules for architecture alignment --- .claude/settings.json | 5 + .devcontainer/devcontainer.json | 38 + AGENTS.md | 13 +- backend/README.md | 20 + .../yoyuzh_portal_dev.mv.db | Bin 0 -> 147456 bytes .../yoyuzh_portal_dev.trace.db | 190 + backend/pom.xml | 8 + .../com/yoyuzh/PortalBackendApplication.java | 4 +- .../yoyuzh/admin/AdminAccessEvaluator.java | 35 +- .../com/yoyuzh/admin/AdminAuditAction.java | 19 + .../com/yoyuzh/admin/AdminAuditLogEntity.java | 126 + .../yoyuzh/admin/AdminAuditLogRepository.java | 25 + .../yoyuzh/admin/AdminAuditLogResponse.java | 17 + .../yoyuzh/admin/AdminAuditQueryService.java | 59 + .../com/yoyuzh/admin/AdminAuditService.java | 73 + .../admin/AdminConfigSnapshotService.java | 121 + .../com/yoyuzh/admin/AdminController.java | 90 +- .../yoyuzh/admin/AdminFilesystemResponse.java | 45 + .../admin/AdminInspectionQueryService.java | 191 + .../admin/AdminMutableSettingsService.java | 66 + .../AdminRegistrationInviteCodeResponse.java | 6 + ...inRegistrationInviteCodeUpdateRequest.java | 11 + .../admin/AdminResourceGovernanceService.java | 60 + .../java/com/yoyuzh/admin/AdminService.java | 611 --- .../yoyuzh/admin/AdminSettingsResponse.java | 73 + .../admin/AdminStorageGovernanceService.java | 199 + .../admin/AdminStoragePolicyQueryService.java | 29 + .../admin/AdminStoragePolicyResponses.java | 30 + .../yoyuzh/admin/AdminTaskQueryService.java | 148 + .../admin/AdminUserGovernanceService.java | 202 + .../UploadSessionRuntimeStateV2Response.java | 13 + .../v2/files/UploadSessionV2Controller.java | 15 + .../api/v2/files/UploadSessionV2Response.java | 1 + .../v2/tasks/BackgroundTaskV2Controller.java | 14 +- .../java/com/yoyuzh/auth/AuthService.java | 43 +- .../com/yoyuzh/auth/AuthSessionPolicy.java | 35 + .../auth/AuthTokenInvalidationService.java | 108 + .../com/yoyuzh/auth/JwtTokenProvider.java | 5 + .../NoOpAuthTokenInvalidationService.java | 37 + .../yoyuzh/auth/RefreshTokenRepository.java | 16 + .../com/yoyuzh/auth/RefreshTokenService.java | 33 +- .../auth/RegistrationInviteService.java | 25 + .../main/java/com/yoyuzh/auth/UserRole.java | 6 +- .../InMemoryLightweightBrokerService.java | 44 + .../broker/LightweightBrokerService.java | 13 + .../broker/RedisLightweightBrokerService.java | 87 + .../common/lock/DistributedLockService.java | 32 + .../lock/NoOpDistributedLockService.java | 17 + .../lock/RedisDistributedLockService.java | 63 + .../yoyuzh/config/AndroidReleaseService.java | 2 + .../com/yoyuzh/config/AppRedisProperties.java | 159 + .../config/JwtAuthenticationFilter.java | 12 + .../com/yoyuzh/config/RedisCacheNames.java | 21 + .../com/yoyuzh/config/RedisConfiguration.java | 73 + .../core/ContentAssetBindingService.java | 78 + .../core/ContentBlobLifecycleService.java | 103 + .../core/ExternalImportRulesService.java | 75 + .../com/yoyuzh/files/core/FileController.java | 77 +- .../core/FileListDirectoryCacheService.java | 45 + .../com/yoyuzh/files/core/FileService.java | 447 +- .../files/core/FileUploadRulesService.java | 55 + .../NoOpFileListDirectoryCacheService.java | 26 + .../RedisFileListDirectoryCacheService.java | 166 + .../files/core/WorkspaceNodeRulesService.java | 147 + .../FileEventCrossInstancePublisher.java | 6 + .../files/events/FileEventDispatcher.java | 141 + .../events/FileEventInstanceIdentity.java | 23 + .../files/events/FileEventPayloadCodec.java | 52 + .../files/events/FileEventPubSubMessage.java | 17 + .../FileEventRedisPubSubConfiguration.java | 23 + .../yoyuzh/files/events/FileEventService.java | 175 +- .../NoOpFileEventCrossInstancePublisher.java | 14 + .../events/RedisFileEventPubSubListener.java | 88 + .../events/RedisFileEventPubSubPublisher.java | 71 + .../tasks/ArchiveBackgroundTaskHandler.java | 55 +- .../yoyuzh/files/tasks/BackgroundTask.java | 6 +- .../tasks/BackgroundTaskCommandService.java | 55 + .../tasks/BackgroundTaskExecutionService.java | 243 + .../tasks/BackgroundTaskRetryPolicy.java | 41 + .../files/tasks/BackgroundTaskService.java | 577 +-- .../tasks/BackgroundTaskStartupRecovery.java | 4 +- .../files/tasks/BackgroundTaskStateKeys.java | 21 + .../tasks/BackgroundTaskStateManager.java | 185 + .../files/tasks/BackgroundTaskWorker.java | 22 +- .../tasks/ExtractBackgroundTaskHandler.java | 56 +- .../MediaMetadataBackgroundTaskHandler.java | 40 +- .../MediaMetadataTaskBrokerConsumer.java | 83 + .../MediaMetadataTaskBrokerPublisher.java | 58 + .../yoyuzh/files/tasks/MediaTaskSupport.java | 43 + ...ePolicyMigrationBackgroundTaskHandler.java | 36 +- .../NoOpUploadSessionRuntimeStateService.java | 41 + ...RedisUploadSessionRuntimeStateService.java | 160 + .../files/upload/UploadPolicyResolver.java | 54 + .../upload/UploadSessionRuntimeState.java | 13 + .../UploadSessionRuntimeStateService.java | 61 + .../files/upload/UploadSessionService.java | 319 +- .../upload/UploadSessionStateMachine.java | 63 + .../transfer/OfflineTransferQuotaService.java | 35 + .../transfer/OfflineTransferService.java | 318 ++ .../transfer/OnlineTransferService.java | 135 + .../transfer/TransferImportService.java | 29 + .../com/yoyuzh/transfer/TransferService.java | 374 +- .../com/yoyuzh/transfer/TransferSession.java | 31 + .../yoyuzh/transfer/TransferSessionState.java | 17 + .../yoyuzh/transfer/TransferSessionStore.java | 174 +- .../src/main/resources/application-dev.yml | 18 +- backend/src/main/resources/application.yml | 27 + backend/src/main/resources/dev-h2-preinit.sql | 25 + .../admin/AdminAuditQueryServiceTest.java | 85 + .../yoyuzh/admin/AdminAuditServiceTest.java | 96 + .../admin/AdminConfigSnapshotServiceTest.java | 170 + .../admin/AdminControllerIntegrationTest.java | 276 +- .../AdminInspectionQueryServiceTest.java | 148 + .../AdminMutableSettingsServiceTest.java | 65 + .../AdminResourceGovernanceServiceTest.java | 111 + .../com/yoyuzh/admin/AdminServiceTest.java | 488 -- .../AdminStorageGovernanceServiceTest.java | 237 + ...inStoragePolicyQueryServiceCacheTest.java} | 155 +- .../admin/AdminTaskQueryServiceTest.java | 111 + .../admin/AdminUserGovernanceServiceTest.java | 233 + .../files/UploadSessionV2ControllerTest.java | 29 + .../java/com/yoyuzh/auth/AuthServiceTest.java | 55 + .../yoyuzh/auth/AuthSessionPolicyTest.java | 36 + .../AuthTokenInvalidationServiceTest.java | 101 + .../RefreshTokenServiceIntegrationTest.java | 2 +- .../auth/RegistrationInviteServiceTest.java | 77 + .../RedisLightweightBrokerServiceTest.java | 44 + .../AndroidReleaseServiceCacheTest.java | 88 + .../yoyuzh/config/DevH2PreinitScriptTest.java | 209 + .../config/JwtAuthenticationFilterTest.java | 46 +- .../yoyuzh/config/RedisConfigurationTest.java | 39 + .../core/ContentAssetBindingServiceTest.java | 89 + .../core/ContentBlobLifecycleServiceTest.java | 172 + .../core/ExternalImportRulesServiceTest.java | 95 + .../core/FileServiceMkdirStorageNameTest.java | 78 + .../yoyuzh/files/core/FileServiceTest.java | 54 + .../FileServiceUploadStorageNameTest.java | 88 + .../FileShareControllerIntegrationTest.java | 83 + .../core/FileUploadRulesServiceTest.java | 97 + ...edisFileListDirectoryCacheServiceTest.java | 76 + .../core/WorkspaceNodeRulesServiceTest.java | 121 + .../files/events/FileEventServiceTest.java | 70 + .../RedisFileEventPubSubListenerTest.java | 107 + .../RedisFileEventPubSubPublisherTest.java | 70 + .../DogeCloudS3SessionProviderTest.java | 47 + .../BackgroundTaskArchiveHandlerTest.java | 2 +- ...ckgroundTaskRepositoryIntegrationTest.java | 38 + .../tasks/BackgroundTaskRetryPolicyTest.java | 39 + .../tasks/BackgroundTaskServiceTest.java | 101 +- .../tasks/BackgroundTaskStateManagerTest.java | 100 + .../files/tasks/BackgroundTaskWorkerTest.java | 36 +- .../ExtractBackgroundTaskHandlerTest.java | 2 +- ...ediaMetadataBackgroundTaskHandlerTest.java | 2 +- .../MediaMetadataTaskBrokerConsumerTest.java | 87 + ...icyMigrationBackgroundTaskHandlerTest.java | 2 +- .../upload/UploadSessionServiceTest.java | 10 +- .../upload/UploadSessionStateMachineTest.java | 52 + .../transfer/OnlineTransferServiceTest.java | 64 + .../yoyuzh/transfer/TransferServiceTest.java | 100 + .../transfer/TransferSessionStoreTest.java | 63 + docs/AGENTS.md | 1 + docs/api-reference.md | 1027 ++-- docs/architecture.md | 1547 ++++-- ...-04-10-cloudreve-gap-next-phase-upgrade.md | 37 +- ...026-04-10-frontend-upgrade-modules-plan.md | 620 +++ .../plans/2026-04-11-backend-refactor-plan.md | 328 ++ ...6-04-11-enterprise-target-refactor-plan.md | 583 +++ front/src/App.tsx | 47 +- front/src/account/pages/LoginPage.tsx | 1 + front/src/admin/AdminLayout.tsx | 72 + front/src/admin/fileblobs.tsx | 1 + front/src/admin/filesystem.tsx | 1 + front/src/admin/oauthapps.tsx | 1 + front/src/admin/settings.tsx | 1 + front/src/admin/shares.tsx | 1 + front/src/admin/tasks.tsx | 1 + front/src/common/pages/TasksPage.tsx | 1 + front/src/components/ThemeProvider.tsx | 26 +- front/src/components/layout/Layout.tsx | 30 +- front/src/components/media/FileThumbnail.tsx | 42 + .../src/components/tasks/TaskSummaryPanel.tsx | 45 + front/src/components/upload/UploadCenter.tsx | 109 + front/src/hooks/use-directory-data.ts | 51 + front/src/hooks/use-session-runtime.ts | 15 + front/src/index.css | 1 + front/src/lib/api.ts | 12 +- front/src/lib/files-cache.ts | 56 + front/src/lib/files.ts | 2 + front/src/lib/realtime-runtime.ts | 88 + front/src/lib/session-runtime.ts | 89 + front/src/lib/task-runtime.ts | 63 + front/src/lib/transfer.ts | 114 +- front/src/lib/upload-runtime.ts | 141 + front/src/mobile-components/MobileLayout.tsx | 28 +- front/src/pages/Login.tsx | 8 +- front/src/pages/Transfer.tsx | 636 +-- front/src/pages/files/FilesPage.tsx | 165 +- front/src/sharing/pages/FileSharePage.tsx | 1 + front/src/sharing/pages/SharesPage.tsx | 1 + front/src/transfer/api/transfer.ts | 113 + front/src/transfer/pages/TransferPage.tsx | 635 +++ front/src/workspace/pages/FilesPage.tsx | 1 + front/src/workspace/pages/OverviewPage.tsx | 1 + front/src/workspace/pages/RecycleBinPage.tsx | 1 + front/vite.config.ts | 1 + front_zip/.env.example | 9 + front_zip/.gitignore | 8 + front_zip/README.md | 20 + front_zip/index.html | 16 + front_zip/metadata.json | 5 + front_zip/package-lock.json | 4383 +++++++++++++++++ front_zip/package.json | 37 + front_zip/src/App.tsx | 58 + front_zip/src/admin/dashboard.tsx | 205 + front_zip/src/admin/files-list.tsx | 181 + front_zip/src/admin/storage-policies-list.tsx | 383 ++ front_zip/src/admin/users-list.tsx | 260 + front_zip/src/components/ThemeProvider.tsx | 73 + front_zip/src/components/ThemeToggle.tsx | 17 + front_zip/src/components/layout/Layout.tsx | 120 + front_zip/src/hooks/useIsMobile.ts | 20 + front_zip/src/index.css | 146 + front_zip/src/lib/admin-storage-policies.ts | 77 + front_zip/src/lib/admin-users.ts | 69 + front_zip/src/lib/admin.ts | 56 + front_zip/src/lib/api.ts | 192 + front_zip/src/lib/auth.ts | 58 + front_zip/src/lib/background-tasks.ts | 46 + front_zip/src/lib/file-events.ts | 16 + front_zip/src/lib/file-search.ts | 3 + front_zip/src/lib/files.ts | 119 + front_zip/src/lib/format.ts | 58 + front_zip/src/lib/session.ts | 47 + front_zip/src/lib/shares-v2.ts | 84 + front_zip/src/lib/transfer.test.ts | 39 + front_zip/src/lib/transfer.ts | 113 + front_zip/src/lib/upload-session.ts | 131 + front_zip/src/lib/utils.ts | 6 + front_zip/src/main.tsx | 13 + .../src/mobile-components/MobileLayout.tsx | 102 + front_zip/src/pages/FileShare.tsx | 228 + front_zip/src/pages/Login.tsx | 249 + front_zip/src/pages/Overview.tsx | 202 + front_zip/src/pages/RecycleBin.tsx | 115 + front_zip/src/pages/Shares.tsx | 142 + front_zip/src/pages/Tasks.tsx | 227 + front_zip/src/pages/Transfer.tsx | 631 +++ front_zip/src/pages/files/FilesPage.tsx | 402 ++ front_zip/tsconfig.json | 26 + front_zip/vite.config.ts | 30 + memory.md | 530 ++ scripts/start-backend-dev.ps1 | 61 +- scripts/start-frontend-dev.ps1 | 8 +- 253 files changed, 25462 insertions(+), 4786 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .devcontainer/devcontainer.json create mode 100644 backend/data-backup-20260409-2030/yoyuzh_portal_dev.mv.db create mode 100644 backend/data-backup-20260409-2030/yoyuzh_portal_dev.trace.db create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminAuditAction.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminAuditLogEntity.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminAuditLogRepository.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminAuditLogResponse.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminAuditQueryService.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminAuditService.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminConfigSnapshotService.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminFilesystemResponse.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminInspectionQueryService.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminMutableSettingsService.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminRegistrationInviteCodeResponse.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminRegistrationInviteCodeUpdateRequest.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminResourceGovernanceService.java delete mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminService.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminSettingsResponse.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminStorageGovernanceService.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyQueryService.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyResponses.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminTaskQueryService.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminUserGovernanceService.java create mode 100644 backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionRuntimeStateV2Response.java create mode 100644 backend/src/main/java/com/yoyuzh/auth/AuthSessionPolicy.java create mode 100644 backend/src/main/java/com/yoyuzh/auth/AuthTokenInvalidationService.java create mode 100644 backend/src/main/java/com/yoyuzh/auth/NoOpAuthTokenInvalidationService.java create mode 100644 backend/src/main/java/com/yoyuzh/common/broker/InMemoryLightweightBrokerService.java create mode 100644 backend/src/main/java/com/yoyuzh/common/broker/LightweightBrokerService.java create mode 100644 backend/src/main/java/com/yoyuzh/common/broker/RedisLightweightBrokerService.java create mode 100644 backend/src/main/java/com/yoyuzh/common/lock/DistributedLockService.java create mode 100644 backend/src/main/java/com/yoyuzh/common/lock/NoOpDistributedLockService.java create mode 100644 backend/src/main/java/com/yoyuzh/common/lock/RedisDistributedLockService.java create mode 100644 backend/src/main/java/com/yoyuzh/config/AppRedisProperties.java create mode 100644 backend/src/main/java/com/yoyuzh/config/RedisCacheNames.java create mode 100644 backend/src/main/java/com/yoyuzh/config/RedisConfiguration.java create mode 100644 backend/src/main/java/com/yoyuzh/files/core/ContentAssetBindingService.java create mode 100644 backend/src/main/java/com/yoyuzh/files/core/ContentBlobLifecycleService.java create mode 100644 backend/src/main/java/com/yoyuzh/files/core/ExternalImportRulesService.java create mode 100644 backend/src/main/java/com/yoyuzh/files/core/FileListDirectoryCacheService.java create mode 100644 backend/src/main/java/com/yoyuzh/files/core/FileUploadRulesService.java create mode 100644 backend/src/main/java/com/yoyuzh/files/core/NoOpFileListDirectoryCacheService.java create mode 100644 backend/src/main/java/com/yoyuzh/files/core/RedisFileListDirectoryCacheService.java create mode 100644 backend/src/main/java/com/yoyuzh/files/core/WorkspaceNodeRulesService.java create mode 100644 backend/src/main/java/com/yoyuzh/files/events/FileEventCrossInstancePublisher.java create mode 100644 backend/src/main/java/com/yoyuzh/files/events/FileEventDispatcher.java create mode 100644 backend/src/main/java/com/yoyuzh/files/events/FileEventInstanceIdentity.java create mode 100644 backend/src/main/java/com/yoyuzh/files/events/FileEventPayloadCodec.java create mode 100644 backend/src/main/java/com/yoyuzh/files/events/FileEventPubSubMessage.java create mode 100644 backend/src/main/java/com/yoyuzh/files/events/FileEventRedisPubSubConfiguration.java create mode 100644 backend/src/main/java/com/yoyuzh/files/events/NoOpFileEventCrossInstancePublisher.java create mode 100644 backend/src/main/java/com/yoyuzh/files/events/RedisFileEventPubSubListener.java create mode 100644 backend/src/main/java/com/yoyuzh/files/events/RedisFileEventPubSubPublisher.java create mode 100644 backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskCommandService.java create mode 100644 backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskExecutionService.java create mode 100644 backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskRetryPolicy.java create mode 100644 backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskStateKeys.java create mode 100644 backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskStateManager.java create mode 100644 backend/src/main/java/com/yoyuzh/files/tasks/MediaMetadataTaskBrokerConsumer.java create mode 100644 backend/src/main/java/com/yoyuzh/files/tasks/MediaMetadataTaskBrokerPublisher.java create mode 100644 backend/src/main/java/com/yoyuzh/files/tasks/MediaTaskSupport.java create mode 100644 backend/src/main/java/com/yoyuzh/files/upload/NoOpUploadSessionRuntimeStateService.java create mode 100644 backend/src/main/java/com/yoyuzh/files/upload/RedisUploadSessionRuntimeStateService.java create mode 100644 backend/src/main/java/com/yoyuzh/files/upload/UploadPolicyResolver.java create mode 100644 backend/src/main/java/com/yoyuzh/files/upload/UploadSessionRuntimeState.java create mode 100644 backend/src/main/java/com/yoyuzh/files/upload/UploadSessionRuntimeStateService.java create mode 100644 backend/src/main/java/com/yoyuzh/files/upload/UploadSessionStateMachine.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/OfflineTransferQuotaService.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/OfflineTransferService.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/OnlineTransferService.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/TransferImportService.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/TransferSessionState.java create mode 100644 backend/src/main/resources/dev-h2-preinit.sql create mode 100644 backend/src/test/java/com/yoyuzh/admin/AdminAuditQueryServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/admin/AdminAuditServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/admin/AdminConfigSnapshotServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/admin/AdminInspectionQueryServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/admin/AdminMutableSettingsServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/admin/AdminResourceGovernanceServiceTest.java delete mode 100644 backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/admin/AdminStorageGovernanceServiceTest.java rename backend/src/test/java/com/yoyuzh/admin/{AdminServiceStoragePolicyCacheTest.java => AdminStoragePolicyQueryServiceCacheTest.java} (57%) create mode 100644 backend/src/test/java/com/yoyuzh/admin/AdminTaskQueryServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/admin/AdminUserGovernanceServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/auth/AuthSessionPolicyTest.java create mode 100644 backend/src/test/java/com/yoyuzh/auth/AuthTokenInvalidationServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/auth/RegistrationInviteServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/common/broker/RedisLightweightBrokerServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/config/AndroidReleaseServiceCacheTest.java create mode 100644 backend/src/test/java/com/yoyuzh/config/DevH2PreinitScriptTest.java create mode 100644 backend/src/test/java/com/yoyuzh/config/RedisConfigurationTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/core/ContentAssetBindingServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/core/ContentBlobLifecycleServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/core/ExternalImportRulesServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/core/FileServiceMkdirStorageNameTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/core/FileServiceUploadStorageNameTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/core/FileUploadRulesServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/core/RedisFileListDirectoryCacheServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/core/WorkspaceNodeRulesServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/events/FileEventServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/events/RedisFileEventPubSubListenerTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/events/RedisFileEventPubSubPublisherTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskRepositoryIntegrationTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskRetryPolicyTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskStateManagerTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/tasks/MediaMetadataTaskBrokerConsumerTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/upload/UploadSessionStateMachineTest.java create mode 100644 backend/src/test/java/com/yoyuzh/transfer/OnlineTransferServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/transfer/TransferServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/transfer/TransferSessionStoreTest.java create mode 100644 docs/superpowers/plans/2026-04-10-frontend-upgrade-modules-plan.md create mode 100644 docs/superpowers/plans/2026-04-11-backend-refactor-plan.md create mode 100644 docs/superpowers/plans/2026-04-11-enterprise-target-refactor-plan.md create mode 100644 front/src/account/pages/LoginPage.tsx create mode 100644 front/src/admin/AdminLayout.tsx create mode 100644 front/src/admin/fileblobs.tsx create mode 100644 front/src/admin/filesystem.tsx create mode 100644 front/src/admin/oauthapps.tsx create mode 100644 front/src/admin/settings.tsx create mode 100644 front/src/admin/shares.tsx create mode 100644 front/src/admin/tasks.tsx create mode 100644 front/src/common/pages/TasksPage.tsx create mode 100644 front/src/components/media/FileThumbnail.tsx create mode 100644 front/src/components/tasks/TaskSummaryPanel.tsx create mode 100644 front/src/components/upload/UploadCenter.tsx create mode 100644 front/src/hooks/use-directory-data.ts create mode 100644 front/src/hooks/use-session-runtime.ts create mode 100644 front/src/lib/files-cache.ts create mode 100644 front/src/lib/realtime-runtime.ts create mode 100644 front/src/lib/session-runtime.ts create mode 100644 front/src/lib/task-runtime.ts create mode 100644 front/src/lib/upload-runtime.ts create mode 100644 front/src/sharing/pages/FileSharePage.tsx create mode 100644 front/src/sharing/pages/SharesPage.tsx create mode 100644 front/src/transfer/api/transfer.ts create mode 100644 front/src/transfer/pages/TransferPage.tsx create mode 100644 front/src/workspace/pages/FilesPage.tsx create mode 100644 front/src/workspace/pages/OverviewPage.tsx create mode 100644 front/src/workspace/pages/RecycleBinPage.tsx create mode 100644 front_zip/.env.example create mode 100644 front_zip/.gitignore create mode 100644 front_zip/README.md create mode 100644 front_zip/index.html create mode 100644 front_zip/metadata.json create mode 100644 front_zip/package-lock.json create mode 100644 front_zip/package.json create mode 100644 front_zip/src/App.tsx create mode 100644 front_zip/src/admin/dashboard.tsx create mode 100644 front_zip/src/admin/files-list.tsx create mode 100644 front_zip/src/admin/storage-policies-list.tsx create mode 100644 front_zip/src/admin/users-list.tsx create mode 100644 front_zip/src/components/ThemeProvider.tsx create mode 100644 front_zip/src/components/ThemeToggle.tsx create mode 100644 front_zip/src/components/layout/Layout.tsx create mode 100644 front_zip/src/hooks/useIsMobile.ts create mode 100644 front_zip/src/index.css create mode 100644 front_zip/src/lib/admin-storage-policies.ts create mode 100644 front_zip/src/lib/admin-users.ts create mode 100644 front_zip/src/lib/admin.ts create mode 100644 front_zip/src/lib/api.ts create mode 100644 front_zip/src/lib/auth.ts create mode 100644 front_zip/src/lib/background-tasks.ts create mode 100644 front_zip/src/lib/file-events.ts create mode 100644 front_zip/src/lib/file-search.ts create mode 100644 front_zip/src/lib/files.ts create mode 100644 front_zip/src/lib/format.ts create mode 100644 front_zip/src/lib/session.ts create mode 100644 front_zip/src/lib/shares-v2.ts create mode 100644 front_zip/src/lib/transfer.test.ts create mode 100644 front_zip/src/lib/transfer.ts create mode 100644 front_zip/src/lib/upload-session.ts create mode 100644 front_zip/src/lib/utils.ts create mode 100644 front_zip/src/main.tsx create mode 100644 front_zip/src/mobile-components/MobileLayout.tsx create mode 100644 front_zip/src/pages/FileShare.tsx create mode 100644 front_zip/src/pages/Login.tsx create mode 100644 front_zip/src/pages/Overview.tsx create mode 100644 front_zip/src/pages/RecycleBin.tsx create mode 100644 front_zip/src/pages/Shares.tsx create mode 100644 front_zip/src/pages/Tasks.tsx create mode 100644 front_zip/src/pages/Transfer.tsx create mode 100644 front_zip/src/pages/files/FilesPage.tsx create mode 100644 front_zip/tsconfig.json create mode 100644 front_zip/vite.config.ts diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..34b0215 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "codex@openai-codex": true + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..8b508e7 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,38 @@ +{ + "name": "my_site backend", + "image": "mcr.microsoft.com/devcontainers/java:1-17-bullseye", + "remoteUser": "vscode", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/backend", + "runArgs": [ + "--init" + ], + "mounts": [ + "source=my-site-backend-maven-cache,target=/home/vscode/.m2,type=volume" + ], + "containerEnv": { + "APP_JWT_SECRET": "devcontainer-local-jwt-secret-please-change-if-needed", + "SPRING_PROFILES_ACTIVE": "dev" + }, + "forwardPorts": [ + 8080 + ], + "portsAttributes": { + "8080": { + "label": "Spring Boot backend", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "vscjava.vscode-java-pack", + "vscjava.vscode-lombok", + "vmware.vscode-spring-boot" + ], + "settings": { + "java.import.maven.enabled": true, + "java.configuration.updateBuildConfiguration": "interactive" + } + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 1e14778..dd89e54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,8 @@ This repository is split across a Java backend, a Vite/React frontend, a small ` - Every new window / new session that starts work in this repository must read `memory.md`, `docs/architecture.md`, and `docs/api-reference.md` first before planning, coding, reviewing, or deploying. - Treat `memory.md` as the current project memory and continuity handoff unless the user explicitly overrides it. -- Treat `docs/architecture.md` as the system-level source of truth for module boundaries and runtime structure. +- Treat `docs/architecture.md` as the project architecture document and source of truth for module boundaries and runtime structure. +- Do not edit `docs/architecture.md` during normal implementation, refactor, review, or handoff work. Only change it when the user explicitly asks to update the architecture document itself. - Treat `docs/api-reference.md` as the quick reference for backend endpoints and auth/public access boundaries. ## Real project structure @@ -117,7 +118,8 @@ Important: ### Project memory upkeep -- Every time a task causes a major project change, update `memory.md` and `docs/architecture.md` in the same turn before handing off. Major changes include architecture shifts, storage/provider migrations, auth or security model changes, deployment topology changes, and meaningful new product capabilities. +- Every time a task causes a major project change, update `memory.md` in the same turn before handing off. +- Do not update `docs/architecture.md` as part of routine implementation follow-up. That file is reserved for explicit architecture-document changes requested by the user. ## Repo-specific guardrails @@ -129,4 +131,11 @@ Important: - For frontend releases, prefer `node scripts/deploy-front-oss.mjs` over ad hoc `ossutil` or manual uploads. - For backend releases, package from `backend/` and deploy the produced jar; do not commit `backend/target/` artifacts to git unless the user explicitly asks for that unusual workflow. +## Debugging Discipline + +- When diagnosing environment or download issues, use short probes first: prefer `curl --max-time`, `mvn -q`, `apt-get update`, `mvn dependency:get`, or similar bounded checks before any full build or long download. +- Do not wait indefinitely on a stalled download or network command. If a command shows no progress within a short probe window, stop and inspect the active proxy, DNS, and mirror path before retrying. +- For WSL-based debugging, prefer the native WSL shell plus the current mirror/proxy settings already in place. If a download path is slow, verify whether the proxy path is actually faster before forcing direct access. +- If a package source is unstable, switch to a domestic mirror only after confirming whether the failure is in DNS, proxy routing, or the upstream mirror itself. + Directory-level `AGENTS.md` files in `backend/`, `front/`, and `docs/` add more specific rules and override this file where they are more specific. diff --git a/backend/README.md b/backend/README.md index 66645c6..4df067f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -13,6 +13,26 @@ - Maven 3.9+ - 生产环境使用 MySQL 8.x 或 openGauss +## Dev Container + +仓库根目录现在提供了后端专用的 `.devcontainer/devcontainer.json`。 + +用途: + +- 使用 JDK 17 + Maven 的开发容器打开后端 +- 默认把工作目录定位到 `backend/` +- 默认转发 `8080` +- 默认注入一个本地开发用 `APP_JWT_SECRET` +- 默认启用 `SPRING_PROFILES_ACTIVE=dev` + +进入容器后,仍按仓库已有命令运行: + +```bash +mvn spring-boot:run -Dspring-boot.run.profiles=dev +``` + +如果你需要读取仓库根目录的 `.env`,它仍然和 `backend/` 一起挂载在同一个 workspace 下,可通过 `../.env` 访问。 + ## 启动 推荐先在仓库根目录准备并加载 `.env`: diff --git a/backend/data-backup-20260409-2030/yoyuzh_portal_dev.mv.db b/backend/data-backup-20260409-2030/yoyuzh_portal_dev.mv.db new file mode 100644 index 0000000000000000000000000000000000000000..8ad4053f099555ac866d84cfc57cbe514320bd36 GIT binary patch literal 147456 zcmeFa378yLc_>;v>XuqF8m*RXd2Nl>u|_ksFM}8LQa#g}UPj$LBaM>KRCiZPvPLuV zXe3z<1ObjQ0TPyAj0waL;2B6>90J5Q3o#JhCCdXYF-r*BO|UJ(M;`Ykc_EPS?thl5 zuBxu;sdi+McQg9FQJqtD{&W8GpMN{&{O9!KI6qRKUu>MVo|RJ%nByGFvLlU?=NC?o zH#kVmo5sR8H_})#jb*br&S{M%&znZGDUP%jm(Cc=XtMpx>c3SAtWsc=0;?2QrNAl$Rw=MbfmI5uQec$=s}xwJz$yiPY805X=Ka=l zb^iZT8;jM3S1GVcfmI5uQec$=s}xwJz$yh+DX>a`RSK+9V3h(d76say{PX4ltO1-g zPMGJ$#gQ|{{o|b2U?FjKoHxXgrNu?qrMo>GH8Y2^|vC(&}UK)%Z&eMw5CMZDU#vzAfhG913|`_{5krbcW@x z;%kG}4PI@S1hm-_q`Fy`+@NAmt966pp=I2vKd2N4sts(dpOEiRUt9)mxHfX&G|ANT`Qtjb~ z76omp3J((61+;^KEzmkJaD%dvg|L$$_zp35K$^{NkloreWr47-)n)=KwDSwi#r)!l zQ7=O@*p|D1mx@qeJFa2zthrEKG8WDmjb#Y;JEu4lrMD}p*r_HBQ-qj6l~*ww5hvbD zcnh$unPyWaJk}6|hGe!{PQZ>4vjNqn$SVdOFgGC4bAp$47NAqhaZ*z zTHGF7Y<{s`FwS<4ZA%mtR@BsCQki+?Y(Ai*q#3foD%@_XzOvcE^_fQ9WO>ChsVZOB z41yapnlYBCKstxXs*7PFuUne4LAYtC%~Twe-ix3@Tb5Zl#x)r4u7az6aLJIvW7PV_!Ai>x*>XG4RbrQ9?vUA z8?0%kz>hVTjnPxhwP-3a%Eie;lB|SmB{VD1q6xW46U~N|u-=$;`o=U#y*$bDEUk`b z75at~t%Uu?pl>va4X@Gac+Fgg@e_FZ6zEf?PlG<|^hwK$9Lb9cK}Y&{gif(olLlII z!U&pcL0mPRY(f6i1#=qDyo45+dhtzA80d8YO~g1=wPs?xH4_t5G!Zk|x??6LX*{UJ z^9$$Cna$*~<*tJ6ZqLpD4KyulHH7-eVxw{Xtg+CzG>&I=_J5+t${)4yZdn216mN<) z=fD~vcmKEy4wZ#+8(lmX1S>};kU|4D!+X(IDBA4_W%q8ha5p5S>Ffm;;RdZaD$Cc! zI^+nQSPiU)ofGOtlecrC*laZHoGdA#(ave2YT7wbZGyO4b;`iNFhGR&H7v-r9C-zo)GDX>a`RSK+9V3h)^6j-IeDg{<4uu6ed3anD#KL-W8 z`|lV0PSJDqmLMo*>*{QyJ8S8`h}KlAm(H5Iv#_AQxO8dDn!dg@{e6)=i%Ta)Px7N@ zE}Vnc=4i7%T3+lZy&09>)E}}mt~+$;ShTONFB)AF7MFFnd@Q;)BsS}D`FJcEVVHr3 z_r7Xv-ySBeHXE=e!f7ZW2&SUqM4fLcC;@v4f{YVdU6W8kVp}Z{C(v3vPNEd-<<0KQUtFMEl6u2az9ybiJF1L6iZZtTFZJ4dNwOay-rpn6= zFx||X7fx6zXvACK{!zMwZ8m3(`SYezPle-_jhnn?WS6HA&}+r>^Ygg= z7lg=v{0hU7YG8wlR6PyGe|#GJC&Vz^u*cW8hyABJ=Hc+YZhISFXR0UM!!B`b7-$R#s|Yt ztpMBMy%Q{4|2A)N_K&2mdM~efg@EUyi5=@=gDAciobnus<7Jq^uib>7o9`m|t_pWkK*@<%_vToVRpWO;R`v*1*GCLD{k5%+) zB3;&#RlSy)$>lQ%o)?D_LxT@S$lJtBMK33E*+j8aP3XsRm1-q1R7nmcD#?5`v5%{> zrq)n7(~vZUm85-%$z)|RvCj}B$<*qCX{vPyD&^`Lr$QiP%j94_At|k<25?#vf<|Fi zS5ZYn=2cUvw`39g@rI)AOC&ReTya-o?;OG~ld7fjrHWq5==tPa;vly>kr;e`l%kkU zrYH4GqMA(Q^~BKhOe&vC4~-5@m&(;-zLqY{lq>qs{(dxZ9Li;e5~=taE@ z9nU0EbBTVZ`sU@pJiE6H(SE|Xv^nQ8RTP~BFtCg~~qq;sd zlmO!B6MC7psuwdg1nPYcRh6s0U#dD9ka7@?v|hwmHOFuu26GUlq9bhmYmr5ZB$et6 z8ZBH;C+(nI|L~r~uEAfRoMMfWgG*l62;$Bsj^?V92`*7aWGz=|R+AGcJzpwLRBF{y z4KwB5#LmGYMPa&}E5Ohurt~=n7W8cm-qwWdPy$#kkdYA&5ARFt8GHqZAaF`|z6U|d zB+{i~rCLrRu5VY{ep!=-5)Qof;}(DoGWsz`c@K{JK?HxeHk1fvbNOo}fLU3A(;RHJ zre;$@O-!poEd^mkHA!c=jFc87Nd!*z@ETB}inypcHytQhmJEgzs9dF%$(8kVwN#!1 zjZ-S+^(3efH;*H_^dimE$?Bw^R7o7)A<>oGap((Gb)I%BfK_rr?@5!aAg-yI^pp-t zrc($xUbUoEuO=r}C{jut2BIN3@GFQqN(aK~!*sbc3mV&3+X!}Pup2sP`VKuvD}#ZW zOXv04M7cCG9jr_|rAuXGNWjE&8y5;QGo3-ECJ2F4zLat(LkeU`9I;1^@BPEWpw(Ux zUaN%@3#S@$Yc+}OP-=I8Qkx2`)MS61Ml=J~HwQqYJ?=WVLFT)3WvtVLc&&ay2 z)C6$IYNEoaH8ss;YLXyjbX8HV*4GkInPI%vsUX%$)HKdrc`(t6+aO#aNZ8(cL+lX!kYxeeav?tvi zN;In1$eqk^DUH>VQcV(*>6$3%shX;#QZ*qhD`GaSD}t8pv}PeqlwL^Y@_t$tX>6~l zng$p3XsMj3A^+YF2>GdAAVjGk`v$(5w>Y@L)mkMz3C0pU>M-t~3&J5iomQyrGJ<=Z zo7JI#N+nk+uBhyjvq^C7K>EA82o5VpAuIzGL;#0lPa#&a5xJ5t;Z@eMi5a~zRV_`| zdhB8$d8{@woi8ObHRLtaQgcup3*$)~5!Bzl-({z>c_Y4!i(k)C7@S$NS;v^?>hN zEqwh9-b6W>(Ze_ixZu$KAd0p1pQC-`J2%dNNFp4d6GYxkbLO!Q`; z&#SHqeQ2X(i1!bXY%pcqy@}oeczm!s9@ymz)@=SV?j9|qWf|D3^-16mMtXXbWK(l(^68E6-lnitOl!#QYur^Bte0-4oQSFsXCwG z_zrilpjXSeG~ir?^#Jt0*X_d09lQ{SJ_q?Q@IxDIFAnVVZ6FW}l#pKSn4OYm3r8}A ziNnHlUf|~@D{Nu*s9u;9VdjgLnrdLf{FG8x<1!q*9k}hw-MNj{5X^5~a2rZwOJzMb zQA8_tZFSwrWnJoM_phvH^)gyRhE;U$LNyh5w7LgF1S_cNsb~TSIv+^TX?9wioXsB- zb4M$)lklkMsdQ;dfb~#J&;Ta{3tzh{ZY8@D$oZx2$oP`D%?7RXK9kUoIwi5Z zvoO)A?w5qN;-XWWw#(%^ee_nhg4%RCYQFV>y4(ZIQ3%Z(SZH%1@Y}Z zsaQ#R9oqsRVh)}Su`K{7!oKJ0i`xglIoY>=_>Kc32S>+Pj%T9#psx?mzIsgqT3*9w zg_vqsy|u~j@QlC>a597A%W!l*v~S72xBcu?2xZfa^nS<;M~7i7p6RYiSMYf9jq zsHRNmXb~@xz)o5=3oeywoy4miO4)2a2a6w2xL5(B53P_*mwc`Od-Gjk%V?t+*1|v; z7GY1te-p=5$l=~mZDzMSQ$brjUE2#;ytT*Q@T#vguXDsk^ zBQ#G#T#TO$AUti6!8`hdxjA6&w}fHtfNDZJc4R$4vKJ{COcWh2)bH!W*Vv>TkdtsblTLCs9kmaczu9S*?6{?)pxvx)V zjV}ZeH8un5`Z}z~@86eB!YnkO*E9P@68qAn!Zh9>N0}_FZjjetg=R>qJX0(}G}68f zrx~#Vjhi6#Cd=tb*k8f5qL_!7X_VBDfjvrBQF1a5ficJ^rb`)Ite|IdNg!-Bi9VP0 zLJ4SM8C&$KItd#HMZl`F`4trlwT8BX=5I@rRfyu6whb-~fbz1{qz+L&DTtK|8dm5p z#Hi`VrXlj9(!CJ)68>8v4$egTG88iywg9AxK-wA%Pe*h8!>~@c_Q(wzVgrmIO0uGA z<1e}ME+#7Urf9=SV0T3;f}))bt!M+*fjSfr(XA5b)`z<5Rxe~Ib(#W$*_%EUhN3d) z)91o@4R}cs#eG~OO`7C0X;DebH6f#bi;x!5HC17inw(0fV0)QM$vo$yNxN@*IyN!G z>QUzweCNbCbSM+Jq)Km$9F73p8CeBJo(|2(0fBsuktZyy0SjO5&cbb&P|TZ!uz=o} zH#-y^2j=}zxS(2$4hc)TM(oQ%utQczay7WGnFsa-7U5CLSV@+h(ZHodDQx7|WU%xUsw^)d=R=(K0O3kSr%O;>_k2x|T8 z*}XNR46r3(Vg{#65M4dzh()t3EDazC8CGFwixi5E#PQf5&@bstpu@35cY(eW1iBJh zpf%Qi4&V|8BGY$)Our_COj98WCDo|;-lQ5JAav{bW1k%aFW~Nb4kc4*h;*NrWTJP2 zu>W>1!afU_UL#qD69T%bYHUqTrxkGe#UyOD$wH0GO4&@3XH%@?fC21Jqlj?WHNt7?qISwUW}5cC8YZB(#LW$rQgyIuTI9!bCok zA#E;ZgP~C332pZ#oynnS3dHbt!|mPRKA>q5EXE|SktBjuRYh3<(Ni;WP2^-)R}wX; zmQ2EF77?OMlW8AG3>knJw8?{n)jRj1)85%!B?qT`z-IVI)Q6o9T4b!vCi63Xvw2Gz zQP5jkMs2J>6wK$zTMD zw!`7BmZo&yWiPGsc-m}EKkC~nj}7(G9Ivtj$nQjt@*8T*yUXt+$nVk6@{8thfws)a z^H_pmvFCk__ih9?FL!tt!yo#Ed3rhq|z zE;NIt0%k)2BS#EP9vE_konwFaP;7}%>doK)M6>{0`L1xQb{)7;`dW+`p4=y!lf9bSem>$5nb?_0ZUPL-uv!N;G2|v=t|lz$-H_a4!q376Xo_ zbmCbCsyW#04OoY>iiTVg z0ZziVkHDew0ygbrhapp%(BT4<83?_AeQLDoSOlK{JwdHhbLq~-MS7n}jnX^F?t{Y% z%PGNH7cT!GVN5;;`yKHPX!h87bkIxLAm`e5y$5ts?wFrz<7gHb7Kc$-mk$IuH%K)6 zBP`mdLY@0L`BFNW$HW!D!76}zkJg+zX&gpB90OZo9kB}&avq9fUADw^`v~88aB&Va zRQSDjCBU?J<(KHCGg?X&WHhh#Xr$1t7QVKqR))Jq`~(MX!|>LOmrn=* z{8mY*@^Vtke{%4%y?IRGsNmxAj)N6H2W%Za9#w(R2EPf3vuhS35Y~Pvu=Wav4Sj&a zu{R*oqpjt@cUpJs+$T1J?^Ju4QExQO)`^p+m}m{i@Lw$7DfE%dfY@vtMNB7w_yAGQ z=TTsJxP92n5q}Sn`L@IDcs(3_S#O-fbE+o7Jr*@pO~Um)EZnq}|b_wrUK~GWzic>ECv9ziP}ED=p>VaG@TA~m}+ShKSaRSff+ve0-2#B z@)U*q)=+FPtsfjjKM7hP{MFf0=Rq^*HT^`wk6ldBfD!sym=WqgnmQ|icV5}_?0~ep z8#qc%e7lTPaLqO$qfHp7cUuGHJ&l4+!+0XraJc78rPdi>=r<%DU^vi?yhIfG>7X(U zKxb`^@550OkbKBOGPG*JCIw$-)Q)AL9S;!>)rN5)I~;8ROZ<9wmatpyE~a*|nzJ$M zsI%jp?-=_*+ue(v0Fu4gB3YPm#Y)j>-@}ckpDu*~bynmW%9DWCTYABZFwBZK_k`uN zK*UY~V%9ZQVd&+J><&jRFO-uu3={jo>prms?CQOz=g%xGp1p7B+%glr7ZB@qodV_q z=dEIXn4m%+ea>RerlY5!k&}cr9ZM%j3*bgvca!<+VA_tWB=+ZAt<6Jgr&ekWORjUc zg8Ee>p{v0>cLtjK4U6Y`Qyc^Q?j0A~af$@bkG^6Sxdv{EwBa}j0%bjy<= zbPH`H*vGT3E%4yqwkQtlKwO;|1uGj*Wi~OQi$LI@6OA_aQ0P!2O3HS-|Klt)Y1^&d z#uTh44qPEjjg|uJp7{C|cvvkUzzOHk?87m1Ktr;o;CMG2y$^=-{|qslcI7J%s8zI$ ze0xkS#4KcjN3ydPn*`A%pzLccY4Ov9gw1hGS}3E@E)T@()n1$fY|NEZ1qqO|x*t4< zB!bF+1c#%`(A4R0O@(7(8sz0H>|_syS$p6O+qZ(gKY!uk{g)p2`Io=q7hcIk&jT7O zs$$mw2CHIxmW2dq`LgYB^gA`(82QD> zLy^};9*(>&!bC4efc*AF`F@OOF)_BkWE8=J@f&@sBvD;~7CVp=UWIV!X0ojY?0xny zSlESKgZgUJ*5`X=JA45GoZ2CdNqec9k~?5))Mt$qEoY$MdQwxTOXzH%^DA|T$s)O{ zTd#=#v#zi(=xmv~%iY*CsLyRKbyf(-f)nXMzdcXDDEo18P%AcNqYt7H>o!+FXrOB% z=;p=i-)@`wCUa7bT$1DYWmU&O+~?f;7vV?sC5f0jm2skKU*jI zDla!!H>>$NBx5T~&OEhsl`nT$*QLIeqtWh!Pk;){*aNGu`wUA&zx2+(y)G7KemU|h zkvB!&9C=IRt&v}iybWx?FC#+R6SuJbbv8YfJj@j`b4mTkF@c4E-TY))<)+~7dA#ou zpbU{20qVMM)pc?HuORxque-s;wREFuAa-rFCuVZ<%|8Vrph&+GQojgPl1nwYYrvef4T*+ji z&`F*yb+kJN20QSoKrH)o0S-Ee_I1g9fp0-Hy%3;zvQzv~Ez7aF^aR9BRc9u};^7HS zk_siY-K?Mfp){p?mNlv|JE;3sMBQiqozx9P(L>!%Wc=tHSX;ZpE7F;2W_taZMd@Gd zPHET$Drct3rDQ?imD#zu8BxzwQ@mt3*`XFQ$v9@<@+Q}^*KqW0h|>RKWlGbb>arn{ zoeIR%LvRN&ev}TZhEf_j+>O#ywP9uk79>mN@QX|2H-RkvW|)nSz3tNW*cRrI$lD{2 zM&1#5Eb`9CuSMP!d3WU3Bkzs;M&x~w_eVYu`C#Njpqn2-RQfL=sAR981Z&0Yl%mcY z%g*J*V{)Y`>eUp>YZ8B0fZt1UU1^C$6L+Iqcd0~qx|>c`(xB?B-bUY!c1IKE$JleBTN|KF3N&Hv$Ht8%RO#lqx$F zSVz0dVaaX@@;eZbtxK&tTRg15ZCW#>BLd47)Z%RQ*u;b|3BTr%ZMPVtGHVXP>>p~M zMRHl}#}Jjjef6juh_8prHadR799T)a)7{AI6lROe??nCleD{8KECpaO>BduG45!3I_K%F%L(j;dIt*aRTx(tR=0i)anJcc5tX=nPKIllkEArd zJUv^SN~Y&j?J$I-5=SrOdL1HY=Z4<95J|t$outrL#|eaPqzpvWLrM!5KfVd9qut$Z z#P{QyccV`JLHABNCu7z;s*3}cVrAt>wF0*^Wu|7PrnGcQu2wkp2rSKXs`9q8Gu+jH zVIAu4D*QU?!vA$uyD(7{N?N5LS-vma zopJ8D)isLx4i_Wljj^1ZDNb2OOarb*N#iozE=lR(w4vort#}QEW9_%w^>m2;$o5a? zxk$fTlO0>p0kPfQU{Qzu^MUX=V$+RMRz%WDm(kIi>CoMC5baKhhLUtO6(!pj))9X5 zy{OK;g5-#HwGI#^%M*IFr?Z?Z#(&eQ30AOxGY$90t#nerQm+BGz82s|N^__lYuy5* z+GjM-h{NfW-$2%>hZDzqz?A`BIVK^p??Za+wcYia2Qt_IIWGUz!Ex;^J~j)8N8gV+ zYcIukJvkH+;YNl8x>}6r=ft_o!^(?2I1U#M7HF+H9B+rCA3*4w3-AnluyHQ!heE~) z;A;7UsO8{A09nhFlybFnI14KQsi|s(&m2t_Cyv3TPI&PtVA>Fh7{k#IA*T4jRbmSG zO@$z2J%-L289%Qiu#R?z!+Is{si|kK2EYBh@k6h_{-J1ui6kKdxwqZ$T05tG=uJ1i zITj&53_bjNzs#(GA9TKh*?j#?H^Uo_b)R{ zWaz^`*zxAr^-P4<$8@d_{?Bs!8hA`_yby)wBs|3!c_FSNPiZ|obDSXZTpYiVq<(n9 zuWoQ6`Wrwm1zy|$&q;XlH$oyykhlpYpS}6+=hv^>fs)T%zwWu6y9W8}7>7VM@`uN` zF-ZK}rNv9G7F+%%SrgN>8M5=LwY zeX$T59f#DF!S&DYzXuT89ot|xx)-NO4SR5!!0d}{up50|Ogwk%=I7MdvxD0aX@}7_ z0&?X1se@-08>i2oMa0;TKF~J5bJs1;Zi`$Q6Q8?rBeZ!(Y|w6V1gA*_2XLCe9E=TG zZ5~-XcMkeuHcq1kM`IgpU>2td@EA@LU@o?CV(gxKex`W}IAQtV`LpwjMzdW7lBxag z|NPCr8r{f5m_B9=6J=t|T4o&+XVx?Q%mA~2fnTRY#hCdIoV(@iQMPNjP0VIy3$vBk z#%yPmn1jqHGsdtC zN87sn*2k_FbsJ|i-@Y@S<=A4dP1OF)9i z{tvwW_ID$!O5CBQd0}*Zv0=<_G<@)De_lI7F>SvZJZj_Ie!2c z8;fT){G9y(^~Cw9|G2YP?YahjmcGaRb3#OqKtJ0BV~`cv*)`Mm8SUJw_p=+;B#N>j~5uG@BDlJcqiY_MEcf5V{6yN*XJ27 zm*F^poBr;B8=oKFmpE)(FcP4ho*mzGW%TYVqdNz_`@}sT_`_!sU-;IhokKe(p1XD9 z6(;hm8s`~?&n0=D6?0JP`OzDmyJh_OyS6<4l3N@QyLRuIcz)x64I$)G0>|=`SMD17 z&P(KHl|9gsqxhb^dnTUUvfnN)hQRHIfAdV@+y6M^#Z3yv?MDFdD0oBBI+0N7$g~(?&N!FLwuYm6yY@4CnKMV zd^*C!3IY9X*Y4Nfj@n~IzaB#+&jX5Y`iS7yT8{ubvQ#M zIgT?_n$|lrd48bHx&>#Zh%=NQo3Zt%9AzexALG~Z>-adop3n0IzQ{w%&>XaZDn_-TDiGYhPyEk4<9%exF!J

SxwCbqx=O z!^dR&K9v5!`%&pF?$Sbsxo8)Sxr>Tj&Dc9${kok`{?bkh9}y{G=^!WAT3GVK>oI`3 z{CBK{S0|L*fnOU0Zxp#G&bOe>YoGHw?B)(FseRa8#++^3xIj*qX`RD zADF?esD17eK--H)4w)s&53a$KM+DqUr$K z+JR~t#>E&8m>t2Wg4+7(J3n{b6YtoBP%Qv<;=z_NKOgJ2FlA#mbzllme+5ua{pk*b zsXx@sZbV%mF@KPkcEyvrGHsF)TMi1-_F!T)KUCX*RN8z1U)F&wH3QHbC94lqbCS&4 zpWAT#a~p3&HsD{r@tfP9{KgnSt#kBo0O81!pWKLhK3_9xnN)&_q^~g6O-vlSEHTj);c0Lywdu|u=yg1~p zZhb&?u@jTu8Q=Y#@vU_F0O)`Io4>jJ2R{K`5RtYP50XB|HX!^t_`MML;l9|(!&mmL zy|QopckWDFx%=+#sN23fgu8CTK7$RvKk%tMQ0&=*H#~dC*6-Z4?Yld*=WiZ`6_6{6 zgmC@>H2?T_x2${hN8`cmzp^WreCFAYZol$%#mgnJ^-eaq9P#6mHmHkF+@PVjqlH8AoW@x!`Uw8YJ-*9KY>CT>rZ9oW6 z5k2Y3ogz6Zd&ti`@jG|iaW@)r{FXN*^Ojs+d6XL>p0jMc&kO$?B z@+KK*fe3(l4PAx)L8qW6fIi@i5JlJ_lmHKC99o1{BIPIl-vdeH27_LMzcug|g})g5 zt%bjJywhW}%n__#`@UZ9|2j`!6Gi6G0fnFs#h?%1mXnq{>H=>);0rkM0bh`BAMgpe z`~jcPOd#MBP;|}A@BGHd1R6L}29!3eTmW%h*){I$W>?lG^A`WtTm4_Zl)mmDO)cod z%Yr^c1%$2TGursX{9mv2f4vUBw!AjGlDPlt_5QE>WnWAN{9kYIe?5p_cVM#7|Me#S z*C1bH2s`MD#4g|ylE#2fAf#=dz@iQda_jRJMA_#pNO$|K!@B{I_k9m~!1p~;4gp9a zoe}T}sgi(CpkdG#%lmLRE}(b)2U%3NNa_|Hg=Kf)KY7C&cK-0A14+-G9>96tNOrFo z#a+ciKYrWJC*HdOwFjGns4tSEy;$5``uRV8Warb5C{S7=r6pJC_3qLSf8~*#pZ#(y zNw!0wc9L+`-|vR_{`Vi*dHL%DNqf5#fb6sb4(!-F{_MMBXy{?d1y4=4ZD)YM4P9mZ)m%yy1Zl>XAob0<*CPZKJmtFhl2NV>1J>2EBk}X zIMLhGwY_ite~<0_>i<1-$bVzlYU^>y|GTrD&BbN&{^hZ=^Tw$K)RQkh|A#w&_`A}f zj$LGZEViZNrQ#TXEe?W$&-BR0KYQI1f160s-EbHJnu@J=0Bv;xJqAFJzbTfao8<_I z>}k4ywz+|R4FNs4K1ny*5fIrFbpdU61HA_3;a5Jh-3Pt?HhRFA7yopDMT~j<4L8Da z$t}0ub~_Wh-d)v0XiES6K_8?YRmE;_1HBf29{n5u5mX&WJArO=13mNj-(2_Pe~)$| z?Hth^ZkVqD%u^rU)rp-G<|a4H6@YpAV{1B*bHd#0C=HkT#iofF?T|O1z}j1n-R1q_s`1vssWZk2a||}* zQEl&s+MfQskwf0d1#1gGcDwhBtG3(RMEV=3?aCKk(m5Dp(V1{j>~=TMvk2&IdpbwM z2?V};?dflEU+UY+Bz6w%+P!D*zWu{@92hw`I>vHLEa9&5d8qQ?-`w9hSk5YAJM);h z5Ga8t@m)lTf8Bm4?~S6c$7)B%Cs%bton!TVsPobH+yZ0ejQ3($SB<;eWA#6w zw#%Qq?vN*z!yc-H=Yy-R-EIo~)?a*h`wzb|>@!kmK1Kh<_PBwb1fa|R17wXTAcrb) z0_}AJeFT86JhRznESy05Jd*QKfcp0PwjIiQ!%OVZxTE6}o(fv1@9!Lqk0VUJyZ+FW zC;G%5jdAZ6SB=B&(Rd1Kd-}C^9&!h{*n_dt{oYm69S#cpKTq8A*YAFH4%RutJg9Ms zsJ1j!YAEtSJ6;2Y_)I{KkRmqf?$JL!_fOlu`d@eZ(2hjwV5r!b8|a?_=2)AlF-X!D_<$RiY$6x$l~HMVh@(h?Jq87xp@iZ(G@ zuwt#$P4=K9b0QG4As^K0Z1Z4K9&Vhjs&cPuK118tRa0HvuW89`~PAP%B5 z75hj@>;uKe;qB#h$+~Cu^o?6J35< ziE_FU{#Ea%o{o6S79LG?_eNoI6#$&pe!-vf?wH?J1~VKm2dMzw^nzUh5NE zXm_s4oA+MO6?XOvdt!DTe*Qt46NS>`@~P$nIr(VL%+ItQXiXaBy4m7W6UE}3!f~T^ z*rv0^SRCn^cYWlm+kWuJyE{S^?FRYf>bb?j>H8M*^1Z3-sj1Vtbg^pQyEHdHdU!$R zN6#0IYW2}Gsr&A0*6%;EP%fTq34A*))NYY%mHgAkK6~AxPu>s|&)IJEK6C2msCrQ@ zaA#QUf#Y+>3XLP@)pM6FoLm?abW@v6&s<0;3#achwBr{u+RRi^Kjn;ZMQeK!-O6E4N6(!ftIuB4j*SV)a_W*5NQ=X2+YN(nQ!Qr!Z>zcT5baXi_=Pu4%D6~#j8>bF)M@{W?NzG>$?mv3&O!LUt%)QIUA2>HL zCKpeN$4*V3I^zy7b~QhUEYQcmy?yxc5x-bpyWzPDf`0CFSszje$#Hnf}eb|_2M?AY4-H6Q|_=P`saD|X$yZIy9*csK( zmyR56oyZ=U8#~;(Gk%9c-_KT}^YPR$o#rS^hpgS3`TnG4N%yL{0&10M;@l6l{GI4f~3y|{GF1~_;A zEQ)eJms^17`~_oPUud2M>$+?gIAh#js^1GIEGnlSfT|=|j#gEObbiIa>ppnTr=PnV z`Rt#qA4(>f*yYQYe-xR^m@NZ3n#iM|{zPSYaS2wY3ZrA^NGi$fbiko;`pVyHe}8Sp ziFxoVOXHEXWT!^C{SmxVqaJ<+KefHr;ph1E$MN&dmw$mg|AFk}-1VU^Y#rRRf5-mq z>&_TwM>)Ako-Oj^IWvadw>B;S#YayqG|l@*IbIvY*=3^+rx17zcCdfznQy%$vXvpg ztV*96eH!E`N%SexC;cw9NM7dYQ=m_gJ}I0`;beurr|rnJ9+|cy*Xj3$hVUG>@EmEt zivO}XYc8FGZGdrAl}ApSm&VnW0UIr>IC8<5AD2{Cs0%I52`LI;AUUZi87ybIAmuQS zveAS!hGs)`V5^1#Y}6a@gUbeKl8%lT1XJP%HvDNh^h-kYS22qAKr%lmoGKwyAPlgH>V4$1x~!sNwWu1ceQv zkh9Gur>dgb5S?u*p(xal4MA=w5`iR~pahSnT5l*^(`P)%s3=MpY~7&HW+O*IsS-2J4Gp(rGoM#JDVn6s@p^Y@4Ej0{p`WobOwBub;%piim^O{z>x`n^S! zQj0317F9wms)Slp3AHF(%cX?WP?Y7(Rznq7DxIX48rS=n5J)Z**TQo1Ci1u3oRj++1_l5IoRo-Q={os|Gh{mw`6qW~&Ry`7Q%(bZ2Tlc(k-8h=bb% zxDnQZ^i5r@H)W{{Xcu=t1wX<=XjiL3H;u!pb>gI1-isTw%itIa{2*HJ;0WCYN0B^C z?P^sFbS6MqaY{F3B?cp`ajYVBL0AlC2>`8gT~wqP%o1u-G+4KugP$sX?E9a;JF=e$ zFw6(2s1cVPj@OdBM64z+(RqVJ%Td>!m#Ik0R8(c+((}}n2U9}oQE9yztyiO}L8EXQ zh0~}?(5T33R3&IsC1_LyXv7Nh8pX$;^}%^=G_MiX;{SQ_pgEc34DzUxhlw8|M;;1> zmhk^F{-3;2aawb6h}2E(2r^7U(7eN!rKK*?U=Xppk85cfO0%I$+)ZjpkC>1gF3tvH>l+qd8O9| zb-LxgeU*b-HAGk~=z?&^G{SBb+^QB_U>V$3*{hY?pbmB8?yKdI1&(Xo;3P}6TA)rh zQ0wSwOCuanhaZ-fv=+wKtT$Sw!7DCEud*@Pkeb3#tLF#VVXSS)Wv;+7MWt7C1c^7(LkZ2=M?;wXb*2! zRIyV{TI&yFX@^K6?tEVmnwVA?TW7NAqh zS<4o#7QLKl0oJv8y-p`K?iNWO!P{~(mkkIL42e!_`~W+|$O3FOBwl8NiPIqoHlPLz z6?8UawYb+Yj6TNAS_IsKi_I_A3&z>bv2BT>!om-g50lEwGiUPwB_$25eJI>+tG=?? z!u6R3#KZ8g#DfG}R8_vN83Z?IGz0e_b+&5N1rhgu^{;#HiR@tTOl7p@TB2oHcb0Q! z1$S0-XC-%5c4rlLR&{4JSC(_P%kl27?shqMyPUgS&fPBOZkMA=($v5gEPB4WsUxbcn%vDu?op83C`#^{WH+qr`RW>No^`g$bFLik$_cKV=*qdM zAh_xjT(tPe@%D?-#Fk*uC)fHcWZbVzRME) zDSd(fU|gT5tl^r#`dS_S%sv6QTx#_R^*#akUj}}cp(+)?H8!F};Cva_ zJM!uu?@30sGlWtEQ}>pVAUiuqhlvyv=@8N!9W;`YT%?d(a1tE~Qc|V^N^`DS6jv>Z zs}@CZBIc@6b%CiaFjaB(RpU0{!Ctm41{pKkTXARkH5{w<2}YlQQlLp#psv&aDnnit z;7^1X#q^Bd86yrdOkviPmc6yv2 znKu{4Id?VKn!8x1=rONwZ;bDF4{c1~2AAn)Ll`tzJZ41LA%phASX52IYTiTHfFq`iVeF?-q2wC#sQ~^c8_?Y4%<-nHv>YR zZLIk^;Azu^hse5iZ}|o^A=+?_x(tfY!pv+6S{t*L#DX2RzO}&dY`4&=o0?qJY4OnPCp($TRyWe)g^VGJdW(cRhYauRMaEYvtqk8BfA9a)=q&HAS8|k{|rqO*p^# zE|TBcd<^HePmuf#lV8L69iJlaZ~h*6-Zl>HLVbzN&<;FzzJcU--Ea)&cfVs6Kll9j z>kRX%%gpe555eLj}*LnbE&|6T@t}%#2M>GR)>f%-FpUwX@}EX6zHc$}lsB z81}87Ax}6^i}L6|Eq=m*TJ(eiweY;m2*3YDhM7IYhy`f>BEyJp1Ug(i#K?CXW|*T- zGxAY*J^;^44>Qcq!24f?e*gSsM*dsKGm!tu>+utXgP=SL2SHC14uYO290Wa4I0$}1 zI0$+|I0!r$=I#M#{Qt+u<4#<||19Itu#84qeK$s0^X4*K0lPHbVvSZyYKV!e{jc9@ zzvCctGC!cduNKn%;D3A}ewKl&Wo@s+ z;eXigU`Juq6It~{z>#`UJ&}*wepAEpo50lzbp2qzuhaMAU9_-cI zg4gi#_fTD~f8weKh3LWm$MB%Q)Et<-bk^LRg>_=M?IIc@#%Iss(uvWN{OFkrC{WZK zU4}BFsY}ada%su9gckU!@Mf2%RI@%>JU>4_Ys{ZFtuiB?GRyam&Jva}o46pV$ZFo( zEL2!tyl7Qrm0AL)(zc)%hkX2CAR(N9vNQo30^|*Z9^nLvP9%vsS0@RxN#3B) z6Z{74Zs7z9yg`YE&{B*rBzqACc|CYF_P?I$zv>8wsW6;^i1jgjOeAdCzP?BgW&3(4 zyQYV-(U4`=_Jzk6l?{t8D%%SVv5@$#>j7U>HY~oVY*>6z*~7T-uWr?;3#r@-)O4 zw2?Q-8UQEQQ<8R@FF1uP<(UniV?dR_QKZWZ18a`xiPjv^6RkO-Ct7nvPqgNUp0MV4 z(~etq?A_z=VZi8H@?jc=i5Q9;a*3GWp5x(HL*yph_p6$5Ue$!AsVZ?R7C$cVb=i~zxCT@*G*gzPxL~$KSrN6k z$fKYnH~~Pn@Z(m*SX^RTO-0a}aVw-CZtdU4C6jA%aPABeh>|J+5fHsd6e8hr2ZdSYG~Vxj^gUwgVK#1-X`G zdUD9ZX`W4+?cQ367onB4>#WMJ7P(#oB;j`!T>rwL`XZpQ1ubEF|NWG3S)KpGTF1YC zg<^I7?_NIgTNnz%;duW4hpY2{y59C8D4*5&KXSxFddx8>8`f(_Wmo6_OP5yX|72zT zKgs<6y4CrAhw1a#!uXFe{|~GA&{{!Q&4(5hAQVDsYE(8XzNlaAZ>Vh8z76((FDe@rUsN`%)kI~(_6?QorElv)js>cBpog*> zLYCbK^@imKR5mPqQQ5FGLuKO;aKV+E-Wf$(aA?t!E>@|qXjF&2ICul=M0ka?#foHs zwS|s5qfhwAG)`~={RF2B$R{`ohc@oi7HpW|<0Z&;e4vSDe4%7&#GDjSw&sBBo8p|W9VhRTMe*;XhUmJX)VUernR&W33RCxeuM6%A_=%th9uyI7?i+QD3AnPh=3CKtTIaAqsJ&w z7Znu+5s=f2D1ndaq6EGkgC_7s7rDv>y@8-@18taTrXtB`HwzN5d4&_E)2Sds+ zD!Z|VvRis6yESCl=o;jLg_VpLDjiz)tVN|`Ve4Mkd+D`d>yD$k*M)U82T3CR7Na?V3V@%eCt-Bv%61MIQm?mNC#+byz);-u$-5Yxc%t)UH9gm z>c%t)UH6ur>c$l8vF@;13R5t2-Iyk!OXFUKE{*Hn6n2u0>keHy76oM-1#K8oZ?41T zW6`kw^Lku99*csnF!1o+*R1W^1HJ;?{YMEwFcmP;kf`%bu+2@_NLK_f$&k?M8W>Kv zT7Ye}z!K9t25>?ZC9uTsrUh}4C?U&Dj>8Fb{Yc!pH8ieql8G)2T?YrP1ff}v8-`ey zTf7lB8k~f#+>BedKtQ6Y@^V9r*Ht*W*$|{S+%(ZJWzLA#TS7~MOF84#9R+dguERLo z>sW_#VrslmHzXC}2IAI5k&vhhh77n{_qN1MR^#P5TaR0J3&gEEG2$&=G7P1z#Nnnt zfoqy<+`7Ub-jYN(b*MDs);&b=R?|==!PLTe2>tdbgeyPLWBCm|mLKe~{Kg*3Z|bo; z?eF@qGPb$L`nU90eru2Ax7praXP^|!mRC^~_ zxc+V4;_M$uU-e#I^$G#cM-w-%iw%-3@@A=xzY?M0W$AC%PK| zJ<;6&=!xzIKu>fx0D7Xk0niiO4S=5LZUFQ|cLSg&+zl`|xM9Np`PU!cvhn79!ri%Q z?cVd3Bd?D<0`r6Z^nsJ{*r5C8u9%&Py~iqgHIXjs$*NvU&E)c#1kZ~@iJ`#D?e9l{+e5j`P$HF^$Q7%J3B9P7q2rlEYA%t{v&k9gQL>WAW%Ob- zS8cy56%)mod_GapkId-Bw6l%MT&1cPYSx#Uq-9dFuB2-z_*L$j2tTh-gWoXB)|8CE zr;?nOVw1`crX{Lzgy59U!_RD$OQph4B0ZTbC(~6R`E0U0mn%-}=Ot;_Du;VgD<%ti zP|1#}@z7>!$?8y|nk(p)YO*lBUmo_B%OvM&rEKk}u1^gmfH?YuUZ$<;#Y_!>df!7; z<*M(Os*VPv9E2mS7co}NF&v1&97L(;2wVSJWYHo?r8dWTD4TeOu09)bFfHJm@elEFtmv& zea?XeeOrUKH6c5c0G116WW>Y6`x1KwUjZTroYI}|L69}We#}wcgX4Y>!5^*-C4$*p{#prOR#sq3g3Z>{Y)Yt!X;rAD;PRoGq_bQ` zN{f;t0w;TT4Jc7XTvVN#4wNiQ215!|u2Re7%6ht5D$jw&DV6ei64Z#B#}Qq6k>=@S zb<$6&B#!Tp=t}N5^o6QAPrDVsDmkI|q)Ao~*VIgUN(Uv=DTHvk#FAFMnw(gnNGWv~ zh=%0AuOR9u9SExr)8*1EXl!3?BiNKv#{13y0EYU}JmJXY$YpbmwiB)8kcrxy&)@N7S$!w2f0& zMQzIrvTKz@Hdn4xN5FluO`GSv!i3+FI)HIi=NrIHo zRYhSl85oV=vF3T9W3b^m{5SI7TMsVe#!Z{IY~6O<_UmuBamP)}C{V3NnZS=|XvpYy z!ux*oqD(-lA;p~C9}CllQ*U%>!&%JPMszb+v$wCKJ?ZXHqEWp@?qr5b!Tx$us!3up zT@xieRa3Q8swSl2_+2)w!wI`|r!@;{qVz&Cm-o}MNMn0V)ik)MM@!{Q4f*$eK*&$^ z0wGET**Eabyv4x{uGT8)Nide+QHOE=To4ZF>9j&^ml53S+^h}_R4TbraYbdHoK1pr z2h!i&MQ~U-3Sk+jAObiXdkV3VjmVXJ39qu2P0Z+(scLDu)?*h7$z!#d>3k`fsUfeS zmYS>T70Y*^rl?R#AyMwJ=IKfJo!VM)rjT06KwH#0GE=H1-5B%_N4NB5?ZSyoumpw^P$aipPImegb=na5bH%pBB?i>d{I-x>+ zyN?rX-zp5It?f7sM|Xh0{8HrKBQd}`E^LNqFUT<9@gDYLI2VxJh3HK{j-?kw4s3hv zihOyN(t=uPCm|p<@Lic!e{2JD^DVdDc6(yy(5~Hk_A=3%fj+OgD)ga^k|EwdM6$t@ zarY*A3*hm=?s#CAFIcnr%eZ^AkeUI3z{2YkSYI@|X^iNt;QM{LkB(z^-w&@!)-vEY z%~`gPjMFt)T}(?!Rff>Zn#^jjx+tYGHBAx}SnH5P_^nT!&v1N)J6O=GD>p+g=>l>Dxdc7APUT+A%vN&lZkk3KNHg>Ab+tO;*^#>`}ci zDZ+Q{68KZN){WIBl29 zclzk9ZUwdJbkuz719iCvn4=JyIkKSJ{q&>dNg~A&-3#K|eNwTK^g6Z$K*St88)91k zP=sK_*B5UX0Ow@i{^2_gj2s*tV>zCQ?t{KQK>O-74QP1{qZMMRVfEG~zr!;EH^9jZ zjxWQ}{m{N8``-4mQz4X1H`4ndGaMa;v3RDtDqX=3%1#BU_#QNAU>$8U(W@rC1G@Ic z?p+H~TETY1!Fiwvd*eK9D0%?Y;8%Ka7qg*rS>NCG=)*(|tJ zu5}WxdMIVH`5Y{MK;dEqj6SqNHeK?$0_@Fqg)O6vW>^aYWmtqg75_~fS0RUcN41&V z?o0)3^>l47XyM836X+_mK7Ks3u1fi zeDE+dof%9*Sb@E{A5bQE_F#3pE3bxOu(95pvv57`?wql}(~ZzP4RJAkHh}Q7MF#Ka z6XxcCx!)3oxdW;R#pQrsZ(J@8MR}w%!)<${lIbb5KZ?RZl9j1z#Ms#^FQ-zvT7$Tx zOidJ$pvp8MRg*KKu4oyN%_`7u4`ZiGE~g zA>Oe$oy`?<6^Ka++U|vMf^7xZgg}<3cDPb1`c0HI zItjDTd|uD&8%gX-mkQH(gB)eDu)0BBgB6+~sq##*2+>IUI-F+23N&tl)SE1)Ct-gD z*NS2uW~Nb6KL++FT}8>sJOsudqnIvbaIu1($t8iX)g<~{)(a(|iDhijtLh|dAQS4w}C$O;#a_YuYxrGyuxWR+BnJ`J^CLE@)VxzYwFQADf2Ak4pDK;7j;# zi8weD?aNTiVAukXDgtS1FgzX2^$){3;o2kHH^c@QL6l@g)y7|P=Uq%x=uOdvlfdqZ zRs=;m8(Pr@tOIo@Afj6((5(-3*R5X2Q0g=V2D3MPDhx$s(5KIZ^BVAyB#QgEMw&Fq zXVRjQmTN*r0T&@Hq-(0eDm6KkPQmsvmy&tTN0WBn_H=AwhSj6aE%?rfap+Jca7mTk z7&#mPx-+s0j65Bhkplwx93xLySOXTm+?|EnFrkiJ#Iu6YHqi{jB7#$Lp zbdA`Tg0i?GiErq@W;;e>#$ z!gb^|Ih|I(=@*l**(M7$E-Pg-NuEuyl8@7$&jE%3@jMQ%%Hq70LgIDQVV7%5A(3d) zcv6%slxC@qi~|O+KaC>7Vb=(!sf*ev>zHZU`)*W@vgJ}COhD;$at<#>^)z{afb=l2 zs#Xw($5{pmy{9(`4L6Y8CG-$T=#kJ83MW(iCh0^#2@4bXOop_%m<@(Pi6^w(n{+0J zqA3u=-wn5SgZqG{Nw64`yhf4;R#g>c0Yp#D$Tg9ZVO>eoq*^iwr&&aZGEJs^Br#+F zV$dcJ5?1fri%xrIbCn#N@&TLSA5kB6K4_7#Hk-`P_|4`mX+%M9Z5g$(0#Qi!CJNyw z*h5Z3m{W7;!n=sW99|zB zwh%JL0s||+{wv#nwO-pRe0<{*7Gr}k!q|%rmXANXwx99E-^in?E4!+CdUnD8|M#r- zMtM|5Mr34UWMpP!c1vqpdq-zicTX=DY=&`tYX;-mQC~)LT|@1H?~Flvf-Nw#4`nvp zxem}!UpuEg_vV3zPx%5?fT;d-a+@k#WQ!Rs`2KHR%fid9dUUJRdq$ks&)seo%xSG4 zpjT!rpfx_LA)lTj6HOZkkgw!&34w1@6>bl_H@V3XCMwYHy>fWuf1ali03cRD~cc4RCX zK$Ms2^5-60in8xxlm$CMNDijgTE10fBqu;0BPLGlE(c&*5_@RXuMKvAw7is=4ug#s zSPC}5MPM}7xjhWtlcQiskl>aV?DIB6v|(ATmWQh&4dSq<^O6F;)74O;dE6Nyi)X(J zu!38jGBry4?2c4{?x`ODCi&|z`rUQRLB|8;u5U7*J1GmnzcxFZR?o!o3i9wE-XPb__IIho~NdE5-h9CZ&Bfru#nHyo*VYm44{B zt20)MKG##yw2JiM0QBMQ>Gh$19U1i}Iz?Yte@4~@2chHc&Fl#P%lnCJAA9uHd!Y(z zWqA5nloWV21f3ZO;AyVQHPyqls#YU6RM*QoI^;`{6LOP1?cI2)b84%*s^L~#b6+!@ z_WJC@Sw%D45&=oVuaCf`@^(D6lM{xz&c=Fp0Hq&XFW{US?K*aVO@KmBD}Bwi?#)H| zp2;xfx8J-E1}|)<_-kEw{DZhLd0gyw_&cE0qxgM+=1O2-wS#$&_MF^2&O|>9!CYcpvCG8eJR^x^ z<`U-h5z&2daT{o;%+KB5h5C(Ydl2GO46cWCA-OTcy-z8ZcIJ8mX+>>S~O!@!1rv}~u0 zuVni8W@9g6x(LJi(yTxhUa~F zcxkIzY*2^mMC9V6t97~zI{@h=$AF&wQF>#_hd*gHz2U~94^Wb+$@_~=HaS@1HlQ=9 zmU{7n2YeKS;i>}{7Uug&Wv5DP)^>!?~}cC+gFytyn4;(*!aZc z)UN57SuS`D5SH#a1uO^7SH-+2L4{!SIg2?_2S)+PPQn}4)(O%A7zy(>nYRw6c$kvd zo@20$0k)C(u;G#G>@L4{4HMT@f0-Kxbf2+gZei+!TBPVPM6@oW_ve07~GXc8gSO&Uuyt3df z=+6I&N8$eBzSg|8`f@O@B_d!>q%!XUFkX-#!AXMsrS@Wo*=*}$HB03<|zyQ z4bX618gX$|=sXDZ5*inPMK8R+VBqG!ErDADw*|Q1fdH`Iekk8d5G^6b^DpT|uwcA;-zrH|m+y@2 zD+$*lTGCC~76SX6JrgSI!c3>$fN(vtP_e@gAi%91VwfbiswumDjz&H9SkZO{a;_&e zxlKYR0{2&H5R*-Erdc-zKv?J57<6K$=5jhQ^=ordN}Uw~v0y}|ke^&9V3NHg*$<1& z+2Bp6$I`785Eq69CuNWA}8$ZbSt9Z_5}}7 z>lIk)i_pQQiGi(uk z=##%VCzQv1H1M&&?SVT2cLweXd_3?8m;*kF7;P`yGPSQ8YFn#Yg!Z~^)%D#&5)Tf$ zZB4bB&XK@UA;C*0`PN~#FnBv+ zo&V!@Qd?Jps7LC>nkEh2RIO`JhWh%OyXxBP8a!nYkK;{i@jh0H_Ad+Gf!OoY^z0#u z!1n~9q1BhLlqZ=i65Q-bQ%ACKFk$;X6^O?^-GGBeqH|sHUf`XGr@MVLPeZFXs5c0F zb8RE|P4)FR$sH|?f}*r{YDu(S`iJtAo>>l4jp^d|0y1 z!SnK&YG(TUnZ@akr{^@B0`=6j_Hgxk9F{P6SM7A z7W@R_^uN!~Y1&n34n!JSeJM?0xPurkPWx6vISma?$7!nCurPxNk}Y%i#U=8aK$d)S zT=C&gT)QH)lsg!>J8&p)PvCIilYx5!p9GSDhZ?pu%R_2!pIqNp!;88iwn*@MNz9X$cxd8gq-!o6Q7O%>t?sP_ zRcEy}csG)Mu~mqON^_gu*fZEkS)TE3jYTtSLmWcv`*{ZJ zbL@1aW1ug&z8p+lQZ=;t){$&-rfjzc@;!*j)}z*Lj4cs(O{>4NTjKe4tz)2XsIgIM zf?xA!NMiI;nZpjoY+9SVi^R;>hY^=wefPNROK%F7ZF0N@vu`EIMyF%5b1++M{v>MW zGwIvu+6utKB%Ms@T3e4$+dS0O)7{r9NcDqC?~pK9+tr-XTE+-WN4krI@4pBvDYgXY|=I$C@?CBclXsxc@rs*xnVzN4R>uv5 zbgc9xHHDQHEnaftTSv0F>6q^&H}|1N{!{u!Iu~QsIx39;SHr83?!I1lrKzs9zqM7b ztx@}W1+5#lX56YgY3!7xoxrdT^)?kgjhgU3-_<5Gc1WFiZ@bji*xChVog&qA4#AK2 zHG(`bO>lDNU7D002!NmEgPFY<$F8mZLSEmQ`G}?ZST^-{v|3k8eV#|D#bwexlCpr? zhL$-s?=uvRz29`t(;@jI$3O0SkzTJRJC34#e7jSfMIHLj1H$8qO*&3l9!c{(Mn_+! zLvPQ4w>xDTiqhRw6iq%@N94`(p*nU4$>Hs4T_CFNX{_&C&|S`X{l96|geus;>4Nv; z=DR6isaKy@U)$kFN}EwV*1iRdYLDJPJr1{1K7(eb1>86meVz|rjF?dtd=v@ATkn!kz;7z}5t}m6Sv})rmH5_?Y;dMlNpfkLvQ~rNelC9W zwv{&r16-gQ5|Fxk)h%{PfAr3C?+69R4?~xI>7yL_;pWZUl9lJ3AIfj6d`&And+DWZ zXD=^4y^=qx1W&JA9iH7g%W;9VkNwB$J3=eDfLLEyFJ!_04T6{rL8Bl_K?s{5ltUy# z&LyFe4`H()$)b>la}=cjLip7UK}LUtC{q&UMG!VYD4q+E213MnD0=Gr%g*HIu13+* zD|1hmt}7B7Dg^{mEVfh%l@Pgd-{iiz{X2)JCZ}hOvEle+Y^G$BSQZ|SFSd$oa~FZn zIZAzH&&>Gbs9BMi9E+RN71EMOeP!RY88atlEASW8_@$wIo2cbDPN-UjV|;3ErebF2;R|2iyy)}_{&Z0|9Gls_4t*ma zJ#k~!sEF+}rZHh_(HD!ba23SP7UiGWbU6^VKD5Y2dNGcZ8aCiKf!P>ZWFtKzmz=&} z$!RTgs%QmbZ5jGTK)UBfD#j;cqjOV;8Jo}tiu1Kg*PL1&I9n;7KDQWfZVnaMILmRI zRImld2~0(($imq@IWq%oF=L|$VK`K51M@gefGcsF01Khw#>&etzal;Yk}z8_H#IhC z#FJH^F}3NTADsX3U@;fqvbbz6$c4BZE|<&W^0@-8kXyvTuT!F8+}Ojr*4!QB)0SJz zE#a1O%edv-3T`F0iaVEE&4CbcYq$%z3%QH960Ve6%dO+qa~rsexsBW=t_%k37OtGD z;KE!b$8!S3b-@LP|3bJ9K0I^bCqu=)tF))*aJE+}vdpwcG5^?<6C%B5saP zF6y=uGb1}D3c6@=a(1UVU1+6dX6NGjvKw|8!Ny&N7=r(#+%_^icNu}ES#xGKuLb>Y zP6G=9n;!oAi|#{IbqZVK=I-#=WXu>VHazG{e$MHqlqR!^25p*?nfX`P1dmT{HTS~U z9W%qR$?-*3+8ywg`9PDPYSFpDV~~=eVr72fkV=7I1;A>|iJ-H!r`9 z6PoJ;K@z%N-*WDms*NQr#%`kow9~1o#b?8poeh^3zJB!bhrjZC$+JIST)MWj@$?17 zXSu*BEl=b)vAJ3ldAS)%oe8fxy{78SrAyDeZ;bami>sLoFKmE>KD~{f;dbMk2YIw#|s zxKO)Kdy}<$wbxa9sKcwpP{|n}@%ArBUM+T463?K`e|YWj6-RCsnKp;=2@l;hn*PoE z6I68TH%pl2`B#+iX&|# zQR}n7a1V+hF-Oc5^Td3yO>7rCM8FK_00*iV)rP7-a2vn&)`qGqvScVfu?)`_75jQ{ z7>+C}Dk>;Wz#u;L)i*A#DoV~A1%-=>xb)M;@n@bdIdU*N_lD=Qow`PZiNj;c_%f7! z>mgKnDO*}{O&7_cA-1TTb{f0qhTBSyeW=u;M@C9msSqSv3oBl99R{ca|HfN%xuH}S zeMt~ZpBp5Smd?ijtf-wwqfaSX={VYQDjsU0_PC8%;(vte! zPkp5H#3Q*Dsa8u>o28*bFH*k;V6Q%nz%Z#EV2fO&eji|tKVD*y>H$-fmei8~|LmVb z7O56jlca)>|L&jGYy=_4KR&4lqxD zv(RdS2h5VRO}Oo``%BNBl~S7kBiu40mU@x;5db@KS1^@S2iP(fsYw_{FdQ(e{YeG2 z_2MUgc+Sy#79&#IfjYS&VT_H13M@+b(0MLO0qUm!b^JT45vATx=d*+|AR%v%tJ2a* zO_?N1$y^Q!)3#tj!(OB&0qJP-0KD2oEu97c9c8NrRJ@6-+fOfAdAj&qGza|t(@!4R zR8_#CtNzy^ALN$Owo3UaMOFTGcYTd;s#F zdF8V!-g*aQK}OnIt{`m=Ekg7Q@OvTf!+oKhEoV38oZXoJ+9f4tFT3nDZTahKano(s z=kdhv4Ls5Y96MF9>eS|CuU)$Q^-}%J`C-@rIU7+B&Cde*Bd;&bz2WgZfBe^_m6C6r zdVIy%mm{fC63Taq$$@~EoFqXRIblJs54a@dX+U}Oke(_jEZob1v}9A;!9_Nf9BQA3 zPI{7^1ZBx)Kwoi_orGfqJmiFf#r&_I{9@Vr)>BiB+6lx|qjNhk)o5Y~wzR1BTZ$7@ zKP_`6u5y}P!_LIjP8%#JA^VUT10kHC6LyX>k&6>_Qq6NF@^OMrodqhYjn0FGI6-H( zML0pHts3VgPSC00I${G-p*av>lGoED6^+%Q0wms4)1F6CRD+(+q~gSs zq-w+@sXq|SkYG|Tvv%c4Hu(yh+!0!Y2%tlBCzBc>DLVF$ow?)ln>JsDx*XZ3OT?w( zGVv;SF7RqGD21dPDObvq@+IJ=x=1Zji`B&{@B%RawHlfV?Sn=^OMrZ!84-%8LnHwm zfE+LamO#(3-)yNyW-#bA_{)aBApC{kF9-f|MW@ARO-HbQ?U|kLo!v&Wi6V38fOfwR z9ey9cEGL*=Euot^KU zU7&hWQs|w%$UD0TXS*mV_Re1HoekrQbfJsCXxRCDLZi{=6AaR%O<-|{hjHtX3q#o> z7e;q7*J0hjkoU|7J>Z#-l!FhENN4zbLaM~)6KELp#j-veh6^YjpD&<~e7+!UDE@G! z;>TIpWZBIh>B7rx;Ucp97QRK6-(qZKK|#T?!j&bU*#7v;Z1~4nj=kFgPG~2)-;>k( z&?NMWN1rP>^6EL&^+J6mUk|^AZ2uG4fM`4*QeCyx+sN}@YN~-Z;A(Y0UqqxErKYss@N{-*eqq=2Mx9ljavW36- z;SZO-{di$@%9$R(rQ|4ftGNhU#m#^GMCs8779o6aIEdP!IPit?*wSbI`C#dZgApjL zkkSfMI-f0l>k9`>{YyKhvY zu7@obyfj_Df&+G~?j3c>x;rQXwqaRtm z)&DG)9`=S_*yLZvN#5eL_+I%xhf81l!P>3f2g4SwBZ~KTC!Qr8v*zB}%BeA9WCFG1 zxieoWefx{bR@W(VePw8=D>LHgfTa$Gg3WaB$*0db`mZI`^fVm0fW|`k4xnW$&|v^N za(k$n9+o2@a;C`uEoXu5ML;*@SJT6G1Vj!+8K4y`(2cMTKl`l}9^@4y$pgXM^PMps z3FgXG=fZZ$nhP$xhzqS`tGXF2>EA5!Algw?XcY@|3jiJZApj9l9Yi~U&Sim~Kl00S zj{PF&CfeDft67+r0Ot5(>)g~iVa{V=&H~JdC$indIbqIsN)B}RUEh0b#i8#NZ*5C; zzOYWRLz`Wnm>ycwjYV!K!?wLS__x(`kTL<*5sSX9!*?Q>G)Gthp7qpLz8#~NO zIFLuRJp{F#_)_`SRL=$L2tRaD>KCTA3t1-p5^6j9?EBoEK{lO<6ooEgfleWyPi%1a zh7$;E`QZ~Mg&$`v<4Q``u3Nw1;*FciHg74f2v_m~7b;<^JOfqU`q@qH&T>{6Ds98e z1xE?QiPsS){>KCTQGPXC4Lv1H+xnwKrs5UV&X3kU!QYA`P@=_%q5v7Et>J>&b604ATJyH=B46{NPBC?jyH6<2N zD{Oxa$h!(E1DE6nO`Cms=hk_CQEjzvWliqmyNHj2}QK@i2M z-5)}6YSibTIJMbxQJfmzc_>az>wFZahI0XmQV?yN*{YOkz9acYL(jc96JtVa3NYB&$Y zsWosuiqlDc4T{sr`2rNDQ|pB&PAA5TP@K+pB`8iOs!|lE6A<2UrxV9Iluy zdjpD7ZG16`Qzg3*#i{DtgyK}?m7zFQSepxzDrXCdQ%zEi;#5IYpg0}&VHBsstP;iP z=;2YE4hjLqsi>n7_s+?0OFu=jl*X6tzwRSPJmf0g70n{N%R>qppELVnr~Gm@coJIv zR_N5cm=lnY9)0-PlD8hLNb%_^x@5g*tQYw8O=C^d{jL1m?ro}`s0)vpLzTN{=JqJl zQtQM-+a!Dv^=k&|+B&7EHYsk?CPv!o_hF|wtmx=}czgUi&;3iuv;T0B({Ij3v%96E zu5uu>v zds|;$&Cb!Dp_nkUe{XXyI0iafA|swX@x+HpkN-ZO^`o=Vv>R=SMEkbwyWppr=O$-6 z8v7efeQs<|Q_ECM-|nG?+R2fLx?R2FYD+_RXV<7wSFQ1#$oJ5RM?rY*gw2K{w+XC+ zo{cBm9_j5K9pBNgWBd4MUC->eQcn{EAx{fQ={Cu%0dn#PWfZ4p5T+isgqjG;WFYd-ab z7ni^F&+A>TiZ()Vwr^&#eRS7kn|e)6!$|9By|Smzyk>gaSh!_E6~lAwgL*VPUbAag zJi51gqNihLLK2gHp*BWxRPx&=o<8T$u~mNloJp*^%+|rMwnuFj#(91J_H9G$vFt9D&C>SHk;Z9Bof_*K=Eb0 zZc*yoKG0%n`+LXQdOIdM#jw%T)4Fd*M?^Vij*)L9ZA@Xng@k{E@ zk(FROuMyX^0Lq;$pZwvRXV(%u$v^;K~Nv&Vb7ra2EsaRluzaxIzK9CE)S| z+>?MS5pYAIG*k#T8{palTw;Lx3UGY^ZYsdN1Gtj_R}A1b0bDMCdjxP}04@i>T>-cj zfCw4;@lBC8vfnFng>mT*T=K%z+MD2b7XTOkqBDVO*Am;jAKyq=TLlg?GiLeP@wu_t zktw*Wk&=(x(am@rYz3hU4E@t%HpGlMz1y7bnx5Rd&-%Q!YIb@KDx2LoHy)iZM#ef| zr*^k#gCwR$%!&AzUB1H@hmQni+T1kt+f>KC7b!>X0rad^oGo+aV0$w|(ok3PGsXz`}in^xqG8&hFHjgv4zLQyb7C_hm= z0UQsHOvKH-VL{Z3aB|j&!Yu?*hZF42J%93jfn^*4<~17XG&D%4C^S@QNWUuyl2%0; zN;H&dNa0ipr$%T##iP=CREkH9((f@H(OG5DSssHO|5%SX+9Ra(M; zgBD&c-))RlDH<n&^a7eW`Wg)C3{MYp~_x=oB@h?(`Cb2=B)sC(gK_X|fiRoj4;IaVVx5k{XLB z1d>QX#J@kaXe=VcJ^GXMN+gmAwQR3YoW8vh)eP0(y<~{s)H31_+fE9Z5r-7hh#7(o zYqsq1Uo~A4D5A>B(s-&#ltw&8L#hc)s!UA!JwcUHf-0i~RYD1>gc4KI#!Xt%hA>J1$sG$Um zD@OQOP+uM)&=}8Z6Baa`_(fnq`(qV+`5V>ZVl-i-0reHYsBZGS8A}72W{k#U_<^f5 zp#B^N6(ULLK>azaMO8gwroozq!%@XZ0foW#)Ek4B1{Tx7mLTQ?nde2G$3soz1sYoU zWd4UKndXzB275C!O@^GvlOYE;xk4@b73X zY6^OkseuJe)6s?+Po!bFn5LtR^g=EA_m&=q;lNG-EWx^;zKLqlxT>T9O=AvdU`M1d zn!zfi(>S~qB}STMy|AEZI>(5>52E>Zj+Cx*B1(!-GgxI`V*-?w5J{)3WPgHnfsZI@ z2$uat0zjie8Wkz~i-Z=J4W89=@KeQy-~7R4flXup!+L-YHDa>E^;(iv$gC+UblsrP za@4dJRXU_qI#g9+(u>rThpB|rqtSYGTCYx3gHGXe3a3+*pwl6*QaM0HbRrQ^_+e>9Phfh@Xe?nG zVuXQARW?QvQjZ99*7JgNP1ZJKJf@nUSQ(tGD5VZK8x+Yr_71IX=7r>h#}X}nF!7$+9S0on(S7S*8T(8+94xROl7q14r17NAqhS=$y2##G6)0Hb;|8l?*x79(jR zSX-=c*?_RXQ0TJ83(z%;EWmh75mnxwIj)gl1M09*L03Z-#)}=@=rOrjn}8c|v9ZZ$ zyD{bN+k`Afc=)06GE$j2KGo(^Qqj@gM?_d})z@PtaDAo${xBkJ@t^@NYnm9<4MH0r zt%G-v+*qx;z~la>e_DTeU^RzVD&b*fi{k}Az~U8HyaJ0?VDSocOPbDgEFO`iLQMI}QX#Tbh%6PN7J%Df5%|;PY_N9W&xF53 zwqQo#&&(DQkgqEcst~FWYVfDSp86hL(?8`i<_C%_+6%flgj*3bWZ z!B#N28D@zrXC=0SCALix+rbjsM2T&pq>}A?$~(5K%vLO?d}X;KYfMcn8inlzg(Xp8 zYf@QQHRUVQ+amA8Dhf@rg_qB!e}F#OP&Z@RV!X#;pB z3J>%`D*>v;j)R)O(@;2d8X}-Xxl{a>MN5RnN+F3t0;xj?0?9|<4@n%Pbr47e@&x!U zK)ztWUmWt_n*fq2B(h<7mo2LBT~**Ok|jw1CS=KxY#|P!uSem}%#uLLl|+^l&5}U= zRgiZTs?q>l=L32`4QK%=03#v}lCOez2X6S|#_GTdj&O=l${s06sTE#hpeE-c2KJ!{Vxq|auL}L3Lf5EJMnP%NHYzFyovNAv=wbtu z4Du!l7Qa61hnN;FhyXwWc9Lt0)I zNLr2%a+DJR%!(>tMR^onZX2x<%g4-#DuFMbf|K5vDmViigA?g4oZ!ph__#_C<6!2X z+;&Kzys0W+Z#klv`Q8dcuCgx_$I8B35N7B`^*1;s+ zm!pT2h@jcYSXCk>Maw5+vAHQ@BDSw8k@Am^eAaG3oVEZ=Dcl6w0>RY)6^>*4$(IrR zy!hWGKaR;)B5?=xybutIfJ5lOT~ZUK7PV8n4tB4dlA=ajv{SMikHzeiszhWXnbKv= zv{SMchXHS65DjO^2rOEY-%Kf)Ld}(R>ljaqEy~Qeq$ep$C_K!= z*0%&0o=FUesHxkv@KJaGEeg&G2RLp^GYK0G;V>)rCpgTWS&75o*@HODQMcnTuNuO@ zR<3YeD+!xPy6C0zaC*t5B)u$t7^hb>lJu&kmvDOZ*Gc~QZ;c%l`H`~5SD(J zq}Qz)!s+$*4B&9XA7AFU>kn{c4{U|-1XuRZ366VkD_8bNJqh7SS(Jt+WjXGH9JhJ@ z*C9N?ZNBMgj=LG2n*B6{4@3IP-{rW!hxDr*9JlBIw`DUtR*OP-j}V7Hd=A1BT=~xL zb6n8@uKZ{nJY@^vleHv-cc@Sry+egUc!vtY6I}R5w{zU$16*ZS6UQyt%2i$iUOP)q zaFt*CILGyG<@me4MMAhxi_+*qEe_#AEehd6ErbU+>C4Y?+`v{&ZU_8(I8Ocu@L|tZ zPTkzXaf2r~br8b+5bnE`;DQY{D~BekESW4iL>}aNg&({RG&;aSt+V-MH*O zPkRscS{(|*!`{Q=)%l6SudnRSx0}4YqbPj(H>Rz^-*3AU(0ixx|kwklQP6;)l64Tun5JrV)m6dXaGCL{t5 z5hMam6*ywTPAG|}LV`r%I*BCUSOMk0GBO$?5pWX15hzO|a3DZ(!1V}6kar@9M1?4c zm~oPWTu*QgI^Dt%8=)!6?QRR6n9a3&RIrXWIDToxC|v}{&Z zU;$;b7Em^O0cC?3md(k^oL*EmQ+iR^h0qYnklx${(2L4uN-rv#DZQxdLg>xOux)t@ zkh470w2bS`oDZnpO!B38F4zF_T2%q9$pm40d6~5lM<*6_1aMaSrnO!Vx{DM{vY|ok|jk5ESvK zN+PDfCsg#w6yzwb8M{uBoEX7nicu>9&I|Yx@^c~)@LIwVO@TdSRF4LaDd30>&nu8f xLXTvobr8ogm2^}#(;h};^D^wU0w|lh{!Uu+iRAKMRZYzh&3A1fom`L?{3qYE<@f*q literal 0 HcmV?d00001 diff --git a/backend/data-backup-20260409-2030/yoyuzh_portal_dev.trace.db b/backend/data-backup-20260409-2030/yoyuzh_portal_dev.trace.db new file mode 100644 index 0000000..fd875da --- /dev/null +++ b/backend/data-backup-20260409-2030/yoyuzh_portal_dev.trace.db @@ -0,0 +1,190 @@ +2026-04-09 00:35:10.239730+08:00 jdbc[4]: exception +org.h2.jdbc.JdbcSQLSyntaxErrorException: Column "SF1_0.IS_RECYCLE_ROOT" not found; SQL statement: +select sf1_0.id,b1_0.id,b1_0.content_type,b1_0.created_at,b1_0.object_key,b1_0.size,sf1_0.content_type,sf1_0.created_at,sf1_0.deleted_at,sf1_0.is_directory,sf1_0.filename,sf1_0.storage_name,sf1_0.path,sf1_0.primary_entity_id,sf1_0.recycle_group_id,sf1_0.recycle_original_path,sf1_0.is_recycle_root,sf1_0.size,sf1_0.updated_at,sf1_0.user_id from portal_file sf1_0 left join portal_file_blob b1_0 on b1_0.id=sf1_0.blob_id where sf1_0.deleted_at is not null and sf1_0.deleted_at(JdbcPreparedStatement.java:93) + at org.h2.jdbc.JdbcConnection.prepareStatement(JdbcConnection.java:316) + at com.zaxxer.hikari.pool.ProxyConnection.prepareStatement(ProxyConnection.java:328) + at com.zaxxer.hikari.pool.HikariProxyConnection.prepareStatement(HikariProxyConnection.java) + at org.hibernate.engine.jdbc.internal.StatementPreparerImpl$5.doPrepare(StatementPreparerImpl.java:153) + at org.hibernate.engine.jdbc.internal.StatementPreparerImpl$StatementPreparationTemplate.prepareStatement(StatementPreparerImpl.java:183) + at org.hibernate.engine.jdbc.internal.StatementPreparerImpl.prepareQueryStatement(StatementPreparerImpl.java:155) + at org.hibernate.sql.exec.spi.JdbcSelectExecutor.lambda$list$0(JdbcSelectExecutor.java:85) + at org.hibernate.sql.results.jdbc.internal.DeferredResultSetAccess.executeQuery(DeferredResultSetAccess.java:231) + at org.hibernate.sql.results.jdbc.internal.DeferredResultSetAccess.getResultSet(DeferredResultSetAccess.java:167) + at org.hibernate.sql.results.jdbc.internal.JdbcValuesResultSetImpl.advanceNext(JdbcValuesResultSetImpl.java:265) + at org.hibernate.sql.results.jdbc.internal.JdbcValuesResultSetImpl.processNext(JdbcValuesResultSetImpl.java:145) + at org.hibernate.sql.results.jdbc.internal.AbstractJdbcValues.next(AbstractJdbcValues.java:19) + at org.hibernate.sql.results.internal.RowProcessingStateStandardImpl.next(RowProcessingStateStandardImpl.java:67) + at org.hibernate.sql.results.spi.ListResultsConsumer.consume(ListResultsConsumer.java:204) + at org.hibernate.sql.results.spi.ListResultsConsumer.consume(ListResultsConsumer.java:33) + at org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.doExecuteQuery(JdbcSelectExecutorStandardImpl.java:211) + at org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.executeQuery(JdbcSelectExecutorStandardImpl.java:83) + at org.hibernate.sql.exec.spi.JdbcSelectExecutor.list(JdbcSelectExecutor.java:76) + at org.hibernate.sql.exec.spi.JdbcSelectExecutor.list(JdbcSelectExecutor.java:65) + at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.lambda$new$2(ConcreteSqmSelectQueryPlan.java:139) + at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.withCacheableSqmInterpretation(ConcreteSqmSelectQueryPlan.java:382) + at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.performList(ConcreteSqmSelectQueryPlan.java:302) + at org.hibernate.query.sqm.internal.QuerySqmImpl.doList(QuerySqmImpl.java:526) + at org.hibernate.query.spi.AbstractSelectionQuery.list(AbstractSelectionQuery.java:423) + at org.hibernate.query.Query.getResultList(Query.java:120) + at org.springframework.data.jpa.repository.query.JpaQueryExecution$CollectionExecution.doExecute(JpaQueryExecution.java:130) + at org.springframework.data.jpa.repository.query.JpaQueryExecution.execute(JpaQueryExecution.java:93) + at org.springframework.data.jpa.repository.query.AbstractJpaQuery.doExecute(AbstractJpaQuery.java:152) + at org.springframework.data.jpa.repository.query.AbstractJpaQuery.execute(AbstractJpaQuery.java:140) + at org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:170) + at org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:158) + at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.doInvoke(QueryExecutorMethodInterceptor.java:169) + at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.invoke(QueryExecutorMethodInterceptor.java:148) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.data.projection.DefaultMethodInvokingMethodInterceptor.invoke(DefaultMethodInvokingMethodInterceptor.java:70) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:379) + at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:138) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:136) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223) + at jdk.proxy2/jdk.proxy2.$Proxy162.findByDeletedAtBefore(Unknown Source) + at com.yoyuzh.files.FileService.pruneExpiredRecycleBinItems(FileService.java:292) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:355) + at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:379) + at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) + at com.yoyuzh.files.FileService$$SpringCGLIB$$0.pruneExpiredRecycleBinItems() + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at org.springframework.scheduling.support.ScheduledMethodRunnable.runInternal(ScheduledMethodRunnable.java:130) + at org.springframework.scheduling.support.ScheduledMethodRunnable.lambda$run$2(ScheduledMethodRunnable.java:124) + at io.micrometer.observation.Observation.observe(Observation.java:499) + at org.springframework.scheduling.support.ScheduledMethodRunnable.run(ScheduledMethodRunnable.java:124) + at org.springframework.scheduling.support.DelegatingErrorHandlingRunnable.run(DelegatingErrorHandlingRunnable.java:54) + at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572) + at java.base/java.util.concurrent.FutureTask.runAndReset(FutureTask.java:358) + at java.base/java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:305) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) + at java.base/java.lang.Thread.run(Thread.java:1570) +2026-04-09 00:35:10.299570+08:00 jdbc[3]: exception +org.h2.jdbc.JdbcSQLSyntaxErrorException: Column "SF1_0.IS_RECYCLE_ROOT" not found; SQL statement: +select sf1_0.id,sf1_0.blob_id,sf1_0.content_type,sf1_0.created_at,sf1_0.deleted_at,sf1_0.is_directory,sf1_0.filename,sf1_0.storage_name,sf1_0.path,sf1_0.primary_entity_id,sf1_0.recycle_group_id,sf1_0.recycle_original_path,sf1_0.is_recycle_root,sf1_0.size,sf1_0.updated_at,sf1_0.user_id from portal_file sf1_0 where not(sf1_0.is_directory) and sf1_0.blob_id is null [42122-224] + at org.h2.message.DbException.getJdbcSQLException(DbException.java:514) + at org.h2.message.DbException.getJdbcSQLException(DbException.java:489) + at org.h2.message.DbException.get(DbException.java:223) + at org.h2.message.DbException.get(DbException.java:199) + at org.h2.expression.ExpressionColumn.getColumnException(ExpressionColumn.java:244) + at org.h2.expression.ExpressionColumn.optimizeOther(ExpressionColumn.java:226) + at org.h2.expression.ExpressionColumn.optimize(ExpressionColumn.java:213) + at org.h2.command.query.Select.optimizeExpressionsAndPreserveAliases(Select.java:1285) + at org.h2.command.query.Select.prepareExpressions(Select.java:1167) + at org.h2.command.query.Query.prepare(Query.java:218) + at org.h2.command.Parser.prepareCommand(Parser.java:489) + at org.h2.engine.SessionLocal.prepareLocal(SessionLocal.java:639) + at org.h2.engine.SessionLocal.prepareCommand(SessionLocal.java:559) + at org.h2.jdbc.JdbcConnection.prepareCommand(JdbcConnection.java:1166) + at org.h2.jdbc.JdbcPreparedStatement.(JdbcPreparedStatement.java:93) + at org.h2.jdbc.JdbcConnection.prepareStatement(JdbcConnection.java:316) + at com.zaxxer.hikari.pool.ProxyConnection.prepareStatement(ProxyConnection.java:328) + at com.zaxxer.hikari.pool.HikariProxyConnection.prepareStatement(HikariProxyConnection.java) + at org.hibernate.engine.jdbc.internal.StatementPreparerImpl$5.doPrepare(StatementPreparerImpl.java:153) + at org.hibernate.engine.jdbc.internal.StatementPreparerImpl$StatementPreparationTemplate.prepareStatement(StatementPreparerImpl.java:183) + at org.hibernate.engine.jdbc.internal.StatementPreparerImpl.prepareQueryStatement(StatementPreparerImpl.java:155) + at org.hibernate.sql.exec.spi.JdbcSelectExecutor.lambda$list$0(JdbcSelectExecutor.java:85) + at org.hibernate.sql.results.jdbc.internal.DeferredResultSetAccess.executeQuery(DeferredResultSetAccess.java:231) + at org.hibernate.sql.results.jdbc.internal.DeferredResultSetAccess.getResultSet(DeferredResultSetAccess.java:167) + at org.hibernate.sql.results.jdbc.internal.JdbcValuesResultSetImpl.advanceNext(JdbcValuesResultSetImpl.java:265) + at org.hibernate.sql.results.jdbc.internal.JdbcValuesResultSetImpl.processNext(JdbcValuesResultSetImpl.java:145) + at org.hibernate.sql.results.jdbc.internal.AbstractJdbcValues.next(AbstractJdbcValues.java:19) + at org.hibernate.sql.results.internal.RowProcessingStateStandardImpl.next(RowProcessingStateStandardImpl.java:67) + at org.hibernate.sql.results.spi.ListResultsConsumer.consume(ListResultsConsumer.java:204) + at org.hibernate.sql.results.spi.ListResultsConsumer.consume(ListResultsConsumer.java:33) + at org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.doExecuteQuery(JdbcSelectExecutorStandardImpl.java:211) + at org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.executeQuery(JdbcSelectExecutorStandardImpl.java:83) + at org.hibernate.sql.exec.spi.JdbcSelectExecutor.list(JdbcSelectExecutor.java:76) + at org.hibernate.sql.exec.spi.JdbcSelectExecutor.list(JdbcSelectExecutor.java:65) + at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.lambda$new$2(ConcreteSqmSelectQueryPlan.java:139) + at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.withCacheableSqmInterpretation(ConcreteSqmSelectQueryPlan.java:382) + at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.performList(ConcreteSqmSelectQueryPlan.java:302) + at org.hibernate.query.sqm.internal.QuerySqmImpl.doList(QuerySqmImpl.java:526) + at org.hibernate.query.spi.AbstractSelectionQuery.list(AbstractSelectionQuery.java:423) + at org.hibernate.query.Query.getResultList(Query.java:120) + at org.springframework.data.jpa.repository.query.JpaQueryExecution$CollectionExecution.doExecute(JpaQueryExecution.java:130) + at org.springframework.data.jpa.repository.query.JpaQueryExecution.execute(JpaQueryExecution.java:93) + at org.springframework.data.jpa.repository.query.AbstractJpaQuery.doExecute(AbstractJpaQuery.java:152) + at org.springframework.data.jpa.repository.query.AbstractJpaQuery.execute(AbstractJpaQuery.java:140) + at org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:170) + at org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:158) + at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.doInvoke(QueryExecutorMethodInterceptor.java:169) + at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.invoke(QueryExecutorMethodInterceptor.java:148) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.data.projection.DefaultMethodInvokingMethodInterceptor.invoke(DefaultMethodInvokingMethodInterceptor.java:70) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:379) + at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:138) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:136) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223) + at jdk.proxy2/jdk.proxy2.$Proxy162.findAllByDirectoryFalseAndBlobIsNull(Unknown Source) + at com.yoyuzh.files.FileBlobBackfillService.backfillMissingBlobs(FileBlobBackfillService.java:28) + at com.yoyuzh.files.FileBlobBackfillService.run(FileBlobBackfillService.java:23) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:355) + at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:379) + at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) + at com.yoyuzh.files.FileBlobBackfillService$$SpringCGLIB$$0.run() + at org.springframework.boot.SpringApplication.lambda$callRunner$5(SpringApplication.java:790) + at org.springframework.util.function.ThrowingConsumer$1.acceptWithException(ThrowingConsumer.java:82) + at org.springframework.util.function.ThrowingConsumer.accept(ThrowingConsumer.java:60) + at org.springframework.util.function.ThrowingConsumer$1.accept(ThrowingConsumer.java:86) + at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:798) + at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:789) + at org.springframework.boot.SpringApplication.lambda$callRunners$3(SpringApplication.java:774) + at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:184) + at java.base/java.util.stream.SortedOps$SizedRefSortingSink.end(SortedOps.java:357) + at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:557) + at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:546) + at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151) + at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174) + at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:265) + at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:611) + at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:774) + at org.springframework.boot.SpringApplication.run(SpringApplication.java:342) + at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363) + at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352) + at com.yoyuzh.PortalBackendApplication.main(PortalBackendApplication.java:25) diff --git a/backend/pom.xml b/backend/pom.xml index f30da72..cee996e 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -38,6 +38,14 @@ org.springframework.boot spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-starter-cache + + + org.springframework.boot + spring-boot-starter-data-redis + org.springdoc springdoc-openapi-starter-webmvc-ui diff --git a/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java b/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java index 0ca8e53..0663ff2 100644 --- a/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java +++ b/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java @@ -1,6 +1,7 @@ package com.yoyuzh; import com.yoyuzh.config.AdminProperties; +import com.yoyuzh.config.AppRedisProperties; import com.yoyuzh.config.AndroidReleaseProperties; import com.yoyuzh.config.CorsProperties; import com.yoyuzh.config.FileStorageProperties; @@ -17,7 +18,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties CorsProperties.class, AdminProperties.class, RegistrationProperties.class, - AndroidReleaseProperties.class + AndroidReleaseProperties.class, + AppRedisProperties.class }) public class PortalBackendApplication { diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminAccessEvaluator.java b/backend/src/main/java/com/yoyuzh/admin/AdminAccessEvaluator.java index 8be317b..79febb5 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminAccessEvaluator.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminAccessEvaluator.java @@ -1,27 +1,34 @@ package com.yoyuzh.admin; -import com.yoyuzh.config.AdminProperties; +import com.yoyuzh.auth.UserRole; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.stereotype.Component; -import java.util.Set; -import java.util.stream.Collectors; +import java.util.Objects; @Component public class AdminAccessEvaluator { - private final Set adminUsernames; - - public AdminAccessEvaluator(AdminProperties adminProperties) { - this.adminUsernames = adminProperties.getUsernames().stream() - .map(username -> username == null ? "" : username.trim()) - .filter(username -> !username.isEmpty()) - .collect(Collectors.toUnmodifiableSet()); + public boolean isAdmin(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + return false; + } + return authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .map(this::toUserRole) + .filter(Objects::nonNull) + .anyMatch(UserRole::canAccessAdmin); } - public boolean isAdmin(Authentication authentication) { - return authentication != null - && authentication.isAuthenticated() - && adminUsernames.contains(authentication.getName()); + private UserRole toUserRole(String authority) { + if (authority == null || !authority.startsWith("ROLE_")) { + return null; + } + try { + return UserRole.valueOf(authority.substring("ROLE_".length())); + } catch (IllegalArgumentException ex) { + return null; + } } } diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminAuditAction.java b/backend/src/main/java/com/yoyuzh/admin/AdminAuditAction.java new file mode 100644 index 0000000..08bb833 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminAuditAction.java @@ -0,0 +1,19 @@ +package com.yoyuzh.admin; + +public enum AdminAuditAction { + UPDATE_REGISTRATION_INVITE_CODE, + ROTATE_REGISTRATION_INVITE_CODE, + UPDATE_OFFLINE_TRANSFER_STORAGE_LIMIT, + UPDATE_USER_ROLE, + UPDATE_USER_BANNED, + UPDATE_USER_PASSWORD, + RESET_USER_PASSWORD, + UPDATE_USER_STORAGE_QUOTA, + UPDATE_USER_MAX_UPLOAD_SIZE, + DELETE_SHARE, + DELETE_FILE, + CREATE_STORAGE_POLICY, + UPDATE_STORAGE_POLICY, + UPDATE_STORAGE_POLICY_STATUS, + CREATE_STORAGE_POLICY_MIGRATION_TASK +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminAuditLogEntity.java b/backend/src/main/java/com/yoyuzh/admin/AdminAuditLogEntity.java new file mode 100644 index 0000000..96e70c8 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminAuditLogEntity.java @@ -0,0 +1,126 @@ +package com.yoyuzh.admin; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "portal_admin_audit_log") +public class AdminAuditLogEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "actor_user_id") + private Long actorUserId; + + @Column(name = "actor_username", nullable = false, length = 100) + private String actorUsername; + + @Column(name = "actor_authorities", nullable = false, length = 255) + private String actorAuthorities; + + @Column(name = "action_type", nullable = false, length = 100) + private String actionType; + + @Column(name = "target_type", nullable = false, length = 100) + private String targetType; + + @Column(name = "target_id") + private Long targetId; + + @Column(name = "summary", nullable = false, length = 255) + private String summary; + + @Column(name = "details_json", nullable = false, columnDefinition = "TEXT") + private String detailsJson; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + public void prePersist() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } + + public Long getId() { + return id; + } + + public Long getActorUserId() { + return actorUserId; + } + + public void setActorUserId(Long actorUserId) { + this.actorUserId = actorUserId; + } + + public String getActorUsername() { + return actorUsername; + } + + public void setActorUsername(String actorUsername) { + this.actorUsername = actorUsername; + } + + public String getActorAuthorities() { + return actorAuthorities; + } + + public void setActorAuthorities(String actorAuthorities) { + this.actorAuthorities = actorAuthorities; + } + + public String getActionType() { + return actionType; + } + + public void setActionType(String actionType) { + this.actionType = actionType; + } + + public String getTargetType() { + return targetType; + } + + public void setTargetType(String targetType) { + this.targetType = targetType; + } + + public Long getTargetId() { + return targetId; + } + + public void setTargetId(Long targetId) { + this.targetId = targetId; + } + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } + + public String getDetailsJson() { + return detailsJson; + } + + public void setDetailsJson(String detailsJson) { + this.detailsJson = detailsJson; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminAuditLogRepository.java b/backend/src/main/java/com/yoyuzh/admin/AdminAuditLogRepository.java new file mode 100644 index 0000000..99bfed5 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminAuditLogRepository.java @@ -0,0 +1,25 @@ +package com.yoyuzh.admin; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface AdminAuditLogRepository extends JpaRepository { + + @Query(""" + select l from AdminAuditLogEntity l + where (:actorQuery = '' or lower(l.actorUsername) like lower(concat('%', :actorQuery, '%'))) + and (:actionType = '' or l.actionType = :actionType) + and (:targetType = '' or l.targetType = :targetType) + and (:targetId is null or l.targetId = :targetId) + """) + Page search( + @Param("actorQuery") String actorQuery, + @Param("actionType") String actionType, + @Param("targetType") String targetType, + @Param("targetId") Long targetId, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminAuditLogResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminAuditLogResponse.java new file mode 100644 index 0000000..d2e0fe9 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminAuditLogResponse.java @@ -0,0 +1,17 @@ +package com.yoyuzh.admin; + +import java.time.LocalDateTime; + +public record AdminAuditLogResponse( + Long id, + Long actorUserId, + String actorUsername, + String actorAuthorities, + String actionType, + String targetType, + Long targetId, + String summary, + String detailsJson, + LocalDateTime createdAt +) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminAuditQueryService.java b/backend/src/main/java/com/yoyuzh/admin/AdminAuditQueryService.java new file mode 100644 index 0000000..30b4758 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminAuditQueryService.java @@ -0,0 +1,59 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.common.PageResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminAuditQueryService { + + private final AdminAuditLogRepository adminAuditLogRepository; + + public PageResponse listAuditLogs(int page, + int size, + String actorQuery, + String actionType, + String targetType, + Long targetId) { + Page result = adminAuditLogRepository.search( + normalizeQuery(actorQuery), + normalizeQuery(actionType), + normalizeQuery(targetType), + targetId, + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt") + .and(Sort.by(Sort.Direction.DESC, "id"))) + ); + return new PageResponse<>( + result.getContent().stream().map(this::toResponse).toList(), + result.getTotalElements(), + page, + size + ); + } + + private AdminAuditLogResponse toResponse(AdminAuditLogEntity entity) { + return new AdminAuditLogResponse( + entity.getId(), + entity.getActorUserId(), + entity.getActorUsername(), + entity.getActorAuthorities(), + entity.getActionType(), + entity.getTargetType(), + entity.getTargetId(), + entity.getSummary(), + entity.getDetailsJson(), + entity.getCreatedAt() + ); + } + + private String normalizeQuery(String query) { + if (query == null) { + return ""; + } + return query.trim(); + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminAuditService.java b/backend/src/main/java/com/yoyuzh/admin/AdminAuditService.java new file mode 100644 index 0000000..fe9f10d --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminAuditService.java @@ -0,0 +1,73 @@ +package com.yoyuzh.admin; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.auth.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AdminAuditService { + + private final AdminAuditLogRepository adminAuditLogRepository; + private final UserRepository userRepository; + private final ObjectMapper objectMapper; + + public void record(AdminAuditAction action, + String targetType, + Long targetId, + String summary, + Map details) { + ActorSnapshot actor = resolveActorSnapshot(); + AdminAuditLogEntity entity = new AdminAuditLogEntity(); + entity.setActorUserId(actor.userId()); + entity.setActorUsername(actor.username()); + entity.setActorAuthorities(actor.authorities()); + entity.setActionType(action.name()); + entity.setTargetType(targetType); + entity.setTargetId(targetId); + entity.setSummary(summary); + entity.setDetailsJson(serializeDetails(details)); + adminAuditLogRepository.save(entity); + } + + private ActorSnapshot resolveActorSnapshot() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return new ActorSnapshot(null, "system", ""); + } + String username = authentication.getName(); + Long userId = StringUtils.hasText(username) + ? userRepository.findByUsername(username).map(user -> user.getId()).orElse(null) + : null; + String authorities = authentication.getAuthorities() == null + ? "" + : authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .sorted() + .collect(Collectors.joining(",")); + if (!StringUtils.hasText(username)) { + return new ActorSnapshot(userId, "system", authorities); + } + return new ActorSnapshot(userId, username, authorities); + } + + private String serializeDetails(Map details) { + try { + return objectMapper.writeValueAsString(details == null ? Map.of() : details); + } catch (JsonProcessingException ex) { + return "{}"; + } + } + + private record ActorSnapshot(Long userId, String username, String authorities) { + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminConfigSnapshotService.java b/backend/src/main/java/com/yoyuzh/admin/AdminConfigSnapshotService.java new file mode 100644 index 0000000..a6f061b --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminConfigSnapshotService.java @@ -0,0 +1,121 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.auth.RegistrationInviteService; +import com.yoyuzh.auth.UserRole; +import com.yoyuzh.config.AppRedisProperties; +import com.yoyuzh.config.FileStorageProperties; +import com.yoyuzh.config.JwtProperties; +import com.yoyuzh.files.core.FileBlobRepository; +import com.yoyuzh.files.core.FileEntityRepository; +import com.yoyuzh.files.core.StoredFileRepository; +import com.yoyuzh.files.policy.StoragePolicy; +import com.yoyuzh.files.policy.StoragePolicyCapabilities; +import com.yoyuzh.files.policy.StoragePolicyService; +import lombok.RequiredArgsConstructor; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminConfigSnapshotService { + + private final RegistrationInviteService registrationInviteService; + private final AdminMetricsService adminMetricsService; + private final AppRedisProperties redisProperties; + private final FileStorageProperties fileStorageProperties; + private final JwtProperties jwtProperties; + private final Environment environment; + private final StoragePolicyService storagePolicyService; + private final StoredFileRepository storedFileRepository; + private final FileBlobRepository fileBlobRepository; + private final FileEntityRepository fileEntityRepository; + + public AdminSettingsResponse getSettings() { + return new AdminSettingsResponse( + new AdminSettingsResponse.SiteSection(false, false), + new AdminSettingsResponse.RegistrationSection( + true, + registrationInviteService.getCurrentInviteCode(), + List.of(UserRole.MODERATOR.name(), UserRole.ADMIN.name()), + true + ), + new AdminSettingsResponse.UserSessionSection( + jwtProperties.getAccessExpirationSeconds(), + jwtProperties.getRefreshExpirationSeconds(), + redisProperties.isEnabled(), + redisProperties.getTtlBufferSeconds(), + false + ), + new AdminSettingsResponse.TransferSection( + adminMetricsService.getOfflineTransferStorageLimitBytes(), + true + ), + new AdminSettingsResponse.MediaProcessingSection(true, false, false, false), + new AdminSettingsResponse.QueueSection( + redisProperties.isEnabled() ? "redis" : "in-memory", + readLongProperty("app.redis.broker.media-meta.fixed-delay-ms", 3000L), + readLongProperty("app.redis.broker.media-meta.initial-delay-ms", 15000L), + false + ), + new AdminSettingsResponse.AppearanceSection(false, false), + new AdminSettingsResponse.ServerSection( + normalizeStorageProvider(fileStorageProperties.getProvider()), + redisProperties.isEnabled(), + false + ) + ); + } + + public AdminFilesystemResponse getFilesystem() { + StoragePolicy defaultPolicy = storagePolicyService.ensureDefaultPolicy(); + StoragePolicyCapabilities capabilities = storagePolicyService.readCapabilities(defaultPolicy); + boolean directUpload = capabilities.directUpload(); + return new AdminFilesystemResponse( + new AdminFilesystemResponse.OverviewSection( + normalizeStorageProvider(fileStorageProperties.getProvider()), + storedFileRepository.count(), + fileBlobRepository.count(), + fileEntityRepository.count() + ), + AdminStoragePolicyResponses.from(storagePolicyService, defaultPolicy), + new AdminFilesystemResponse.UploadSection( + !directUpload, + directUpload && !capabilities.multipartUpload(), + directUpload && capabilities.multipartUpload(), + resolveEffectiveMaxFileSize(defaultPolicy, capabilities) + ), + new AdminFilesystemResponse.MediaProcessingSection(true, capabilities.thumbnailNative()), + new AdminFilesystemResponse.CacheSection( + redisProperties.isEnabled() ? "redis" : "disabled", + redisProperties.getCache().getFilesListTtlSeconds(), + redisProperties.getCache().getDirectoryVersionTtlSeconds() + ), + new AdminFilesystemResponse.WebdavSection(false) + ); + } + + private String normalizeStorageProvider(String provider) { + if (!StringUtils.hasText(provider)) { + return "local"; + } + return provider.trim().toLowerCase(); + } + + private long resolveEffectiveMaxFileSize(StoragePolicy policy, StoragePolicyCapabilities capabilities) { + long effectiveMaxFileSize = fileStorageProperties.getMaxFileSize(); + if (policy.getMaxSizeBytes() > 0) { + effectiveMaxFileSize = Math.min(effectiveMaxFileSize, policy.getMaxSizeBytes()); + } + if (capabilities.maxObjectSize() > 0) { + effectiveMaxFileSize = Math.min(effectiveMaxFileSize, capabilities.maxObjectSize()); + } + return effectiveMaxFileSize; + } + + private long readLongProperty(String key, long defaultValue) { + return environment.getProperty(key, Long.class, defaultValue); + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminController.java b/backend/src/main/java/com/yoyuzh/admin/AdminController.java index 333e35a..cde9c6c 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminController.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminController.java @@ -34,18 +34,47 @@ import java.util.List; @PreAuthorize("@adminAccessEvaluator.isAdmin(authentication)") public class AdminController { - private final AdminService adminService; + private final AdminInspectionQueryService adminInspectionQueryService; + private final AdminTaskQueryService adminTaskQueryService; + private final AdminStoragePolicyQueryService adminStoragePolicyQueryService; + private final AdminAuditQueryService adminAuditQueryService; + private final AdminResourceGovernanceService adminResourceGovernanceService; + private final AdminStorageGovernanceService adminStorageGovernanceService; + private final AdminConfigSnapshotService adminConfigSnapshotService; + private final AdminMutableSettingsService adminMutableSettingsService; + private final AdminUserGovernanceService adminUserGovernanceService; private final CustomUserDetailsService userDetailsService; @GetMapping("/summary") public ApiResponse summary() { - return ApiResponse.success(adminService.getSummary()); + return ApiResponse.success(adminInspectionQueryService.getSummary()); + } + + @GetMapping("/settings") + public ApiResponse settings() { + return ApiResponse.success(adminConfigSnapshotService.getSettings()); + } + + @PatchMapping("/settings/registration/invite-code") + public ApiResponse updateRegistrationInviteCode( + @Valid @RequestBody AdminRegistrationInviteCodeUpdateRequest request) { + return ApiResponse.success(adminMutableSettingsService.updateRegistrationInviteCode(request.inviteCode())); + } + + @PostMapping("/settings/registration/invite-code/rotate") + public ApiResponse rotateRegistrationInviteCode() { + return ApiResponse.success(adminMutableSettingsService.rotateRegistrationInviteCode()); + } + + @GetMapping("/filesystem") + public ApiResponse filesystem() { + return ApiResponse.success(adminConfigSnapshotService.getFilesystem()); } @PatchMapping("/settings/offline-transfer-storage-limit") public ApiResponse updateOfflineTransferStorageLimit( @Valid @RequestBody AdminOfflineTransferStorageLimitUpdateRequest request) { - return ApiResponse.success(adminService.updateOfflineTransferStorageLimit( + return ApiResponse.success(adminMutableSettingsService.updateOfflineTransferStorageLimit( request.offlineTransferStorageLimitBytes() )); } @@ -54,7 +83,7 @@ public class AdminController { public ApiResponse> users(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "") String query) { - return ApiResponse.success(adminService.listUsers(page, size, query)); + return ApiResponse.success(adminUserGovernanceService.listUsers(page, size, query)); } @GetMapping("/files") @@ -62,7 +91,7 @@ public class AdminController { @RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "") String query, @RequestParam(defaultValue = "") String ownerQuery) { - return ApiResponse.success(adminService.listFiles(page, size, query, ownerQuery)); + return ApiResponse.success(adminInspectionQueryService.listFiles(page, size, query, ownerQuery)); } @GetMapping("/file-blobs") @@ -72,7 +101,7 @@ public class AdminController { @RequestParam(required = false) Long storagePolicyId, @RequestParam(defaultValue = "") String objectKey, @RequestParam(required = false) FileEntityType entityType) { - return ApiResponse.success(adminService.listFileBlobs(page, size, userQuery, storagePolicyId, objectKey, entityType)); + return ApiResponse.success(adminInspectionQueryService.listFileBlobs(page, size, userQuery, storagePolicyId, objectKey, entityType)); } @GetMapping("/shares") @@ -83,12 +112,12 @@ public class AdminController { @RequestParam(defaultValue = "") String token, @RequestParam(required = false) Boolean passwordProtected, @RequestParam(required = false) Boolean expired) { - return ApiResponse.success(adminService.listShares(page, size, userQuery, fileName, token, passwordProtected, expired)); + return ApiResponse.success(adminInspectionQueryService.listShares(page, size, userQuery, fileName, token, passwordProtected, expired)); } @DeleteMapping("/shares/{shareId}") public ApiResponse deleteShare(@PathVariable Long shareId) { - adminService.deleteShare(shareId); + adminResourceGovernanceService.deleteShare(shareId); return ApiResponse.success(); } @@ -100,37 +129,54 @@ public class AdminController { @RequestParam(required = false) BackgroundTaskStatus status, @RequestParam(required = false) BackgroundTaskFailureCategory failureCategory, @RequestParam(required = false) AdminTaskLeaseState leaseState) { - return ApiResponse.success(adminService.listTasks(page, size, userQuery, type, status, failureCategory, leaseState)); + return ApiResponse.success(adminTaskQueryService.listTasks(page, size, userQuery, type, status, failureCategory, leaseState)); } @GetMapping("/tasks/{taskId}") public ApiResponse task(@PathVariable Long taskId) { - return ApiResponse.success(adminService.getTask(taskId)); + return ApiResponse.success(adminTaskQueryService.getTask(taskId)); } @GetMapping("/storage-policies") public ApiResponse> storagePolicies() { - return ApiResponse.success(adminService.listStoragePolicies()); + return ApiResponse.success(adminStoragePolicyQueryService.listStoragePolicies()); + } + + @GetMapping("/audits") + public ApiResponse> audits(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "") String actorQuery, + @RequestParam(defaultValue = "") String actionType, + @RequestParam(defaultValue = "") String targetType, + @RequestParam(required = false) Long targetId) { + return ApiResponse.success(adminAuditQueryService.listAuditLogs( + page, + size, + actorQuery, + actionType, + targetType, + targetId + )); } @PostMapping("/storage-policies") public ApiResponse createStoragePolicy( @Valid @RequestBody AdminStoragePolicyUpsertRequest request) { - return ApiResponse.success(adminService.createStoragePolicy(request)); + return ApiResponse.success(adminStorageGovernanceService.createStoragePolicy(request)); } @PutMapping("/storage-policies/{policyId}") public ApiResponse updateStoragePolicy( @PathVariable Long policyId, @Valid @RequestBody AdminStoragePolicyUpsertRequest request) { - return ApiResponse.success(adminService.updateStoragePolicy(policyId, request)); + return ApiResponse.success(adminStorageGovernanceService.updateStoragePolicy(policyId, request)); } @PatchMapping("/storage-policies/{policyId}/status") public ApiResponse updateStoragePolicyStatus( @PathVariable Long policyId, @Valid @RequestBody AdminStoragePolicyStatusUpdateRequest request) { - return ApiResponse.success(adminService.updateStoragePolicyStatus(policyId, request.enabled())); + return ApiResponse.success(adminStorageGovernanceService.updateStoragePolicyStatus(policyId, request.enabled())); } @PostMapping("/storage-policies/migrations") @@ -138,48 +184,48 @@ public class AdminController { @AuthenticationPrincipal UserDetails userDetails, @Valid @RequestBody AdminStoragePolicyMigrationCreateRequest request) { User user = userDetailsService.loadDomainUser(userDetails.getUsername()); - return ApiResponse.success(toTaskResponse(adminService.createStoragePolicyMigrationTask(user, request))); + return ApiResponse.success(toTaskResponse(adminStorageGovernanceService.createStoragePolicyMigrationTask(user, request))); } @DeleteMapping("/files/{fileId}") public ApiResponse deleteFile(@PathVariable Long fileId) { - adminService.deleteFile(fileId); + adminResourceGovernanceService.deleteFile(fileId); return ApiResponse.success(); } @PatchMapping("/users/{userId}/role") public ApiResponse updateUserRole(@PathVariable Long userId, @Valid @RequestBody AdminUserRoleUpdateRequest request) { - return ApiResponse.success(adminService.updateUserRole(userId, request.role())); + return ApiResponse.success(adminUserGovernanceService.updateUserRole(userId, request.role())); } @PatchMapping("/users/{userId}/status") public ApiResponse updateUserStatus(@PathVariable Long userId, @Valid @RequestBody AdminUserStatusUpdateRequest request) { - return ApiResponse.success(adminService.updateUserBanned(userId, request.banned())); + return ApiResponse.success(adminUserGovernanceService.updateUserBanned(userId, request.banned())); } @PutMapping("/users/{userId}/password") public ApiResponse updateUserPassword(@PathVariable Long userId, @Valid @RequestBody AdminUserPasswordUpdateRequest request) { - return ApiResponse.success(adminService.updateUserPassword(userId, request.newPassword())); + return ApiResponse.success(adminUserGovernanceService.updateUserPassword(userId, request.newPassword())); } @PatchMapping("/users/{userId}/storage-quota") public ApiResponse updateUserStorageQuota(@PathVariable Long userId, @Valid @RequestBody AdminUserStorageQuotaUpdateRequest request) { - return ApiResponse.success(adminService.updateUserStorageQuota(userId, request.storageQuotaBytes())); + return ApiResponse.success(adminUserGovernanceService.updateUserStorageQuota(userId, request.storageQuotaBytes())); } @PatchMapping("/users/{userId}/max-upload-size") public ApiResponse updateUserMaxUploadSize(@PathVariable Long userId, @Valid @RequestBody AdminUserMaxUploadSizeUpdateRequest request) { - return ApiResponse.success(adminService.updateUserMaxUploadSize(userId, request.maxUploadSizeBytes())); + return ApiResponse.success(adminUserGovernanceService.updateUserMaxUploadSize(userId, request.maxUploadSizeBytes())); } @PostMapping("/users/{userId}/password/reset") public ApiResponse resetUserPassword(@PathVariable Long userId) { - return ApiResponse.success(adminService.resetUserPassword(userId)); + return ApiResponse.success(adminUserGovernanceService.resetUserPassword(userId)); } private BackgroundTaskResponse toTaskResponse(BackgroundTask task) { diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminFilesystemResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminFilesystemResponse.java new file mode 100644 index 0000000..deedaa6 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminFilesystemResponse.java @@ -0,0 +1,45 @@ +package com.yoyuzh.admin; + +public record AdminFilesystemResponse( + OverviewSection overview, + AdminStoragePolicyResponse defaultPolicy, + UploadSection upload, + MediaProcessingSection mediaProcessing, + CacheSection cache, + WebdavSection webdav +) { + + public record OverviewSection( + String storageProvider, + long totalFiles, + long totalBlobs, + long totalEntities + ) { + } + + public record UploadSection( + boolean proxyUpload, + boolean directSingleUpload, + boolean directMultipartUpload, + long effectiveMaxFileSizeBytes + ) { + } + + public record MediaProcessingSection( + boolean metadataExtractionEnabled, + boolean nativeThumbnailSupport + ) { + } + + public record CacheSection( + String backend, + long filesListTtlSeconds, + long directoryVersionTtlSeconds + ) { + } + + public record WebdavSection( + boolean enabled + ) { + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminInspectionQueryService.java b/backend/src/main/java/com/yoyuzh/admin/AdminInspectionQueryService.java new file mode 100644 index 0000000..f17c78a --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminInspectionQueryService.java @@ -0,0 +1,191 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.auth.RegistrationInviteService; +import com.yoyuzh.auth.User; +import com.yoyuzh.auth.UserRepository; +import com.yoyuzh.common.PageResponse; +import com.yoyuzh.files.core.FileBlobRepository; +import com.yoyuzh.files.core.FileEntity; +import com.yoyuzh.files.core.FileEntityRepository; +import com.yoyuzh.files.core.FileEntityType; +import com.yoyuzh.files.core.StoredFile; +import com.yoyuzh.files.core.StoredFileEntityRepository; +import com.yoyuzh.files.core.StoredFileRepository; +import com.yoyuzh.files.share.FileShareLink; +import com.yoyuzh.files.share.FileShareLinkRepository; +import com.yoyuzh.transfer.OfflineTransferSessionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminInspectionQueryService { + + private final UserRepository userRepository; + private final StoredFileRepository storedFileRepository; + private final FileBlobRepository fileBlobRepository; + private final RegistrationInviteService registrationInviteService; + private final OfflineTransferSessionRepository offlineTransferSessionRepository; + private final AdminMetricsService adminMetricsService; + private final FileEntityRepository fileEntityRepository; + private final StoredFileEntityRepository storedFileEntityRepository; + private final FileShareLinkRepository fileShareLinkRepository; + + public AdminSummaryResponse getSummary() { + AdminMetricsSnapshot metrics = adminMetricsService.getSnapshot(); + return new AdminSummaryResponse( + userRepository.count(), + storedFileRepository.count(), + fileBlobRepository.sumAllBlobSize(), + metrics.downloadTrafficBytes(), + metrics.requestCount(), + metrics.transferUsageBytes(), + offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now()), + metrics.offlineTransferStorageLimitBytes(), + metrics.dailyActiveUsers(), + metrics.requestTimeline(), + registrationInviteService.getCurrentInviteCode() + ); + } + + public PageResponse listFiles(int page, int size, String query, String ownerQuery) { + Page result = storedFileRepository.searchAdminFiles( + normalizeQuery(query), + normalizeQuery(ownerQuery), + PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "user.username") + .and(Sort.by(Sort.Direction.DESC, "createdAt"))) + ); + List items = result.getContent().stream() + .map(this::toFileResponse) + .toList(); + return new PageResponse<>(items, result.getTotalElements(), page, size); + } + + public PageResponse listFileBlobs(int page, + int size, + String userQuery, + Long storagePolicyId, + String objectKey, + FileEntityType entityType) { + Page result = fileEntityRepository.searchAdminEntities( + normalizeQuery(userQuery), + storagePolicyId, + normalizeQuery(objectKey), + entityType, + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + List items = result.getContent().stream() + .map(this::toFileBlobResponse) + .toList(); + return new PageResponse<>(items, result.getTotalElements(), page, size); + } + + public PageResponse listShares(int page, + int size, + String userQuery, + String fileName, + String token, + Boolean passwordProtected, + Boolean expired) { + Page result = fileShareLinkRepository.searchAdminShares( + normalizeQuery(userQuery), + normalizeQuery(fileName), + normalizeQuery(token), + passwordProtected, + expired, + LocalDateTime.now(), + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + List items = result.getContent().stream() + .map(this::toAdminShareResponse) + .toList(); + return new PageResponse<>(items, result.getTotalElements(), page, size); + } + + private AdminFileResponse toFileResponse(StoredFile storedFile) { + User owner = storedFile.getUser(); + return new AdminFileResponse( + storedFile.getId(), + storedFile.getFilename(), + storedFile.getPath(), + storedFile.getSize(), + storedFile.getContentType(), + storedFile.isDirectory(), + storedFile.getCreatedAt(), + owner.getId(), + owner.getUsername(), + owner.getEmail() + ); + } + + private AdminFileBlobResponse toFileBlobResponse(FileEntity entity) { + var blob = fileBlobRepository.findByObjectKey(entity.getObjectKey()).orElse(null); + long linkedStoredFileCount = storedFileEntityRepository.countByFileEntityId(entity.getId()); + long linkedOwnerCount = storedFileEntityRepository.countDistinctOwnersByFileEntityId(entity.getId()); + return new AdminFileBlobResponse( + entity.getId(), + blob == null ? null : blob.getId(), + entity.getObjectKey(), + entity.getEntityType(), + entity.getStoragePolicyId(), + entity.getSize(), + StringUtils.hasText(entity.getContentType()) ? entity.getContentType() : blob == null ? null : blob.getContentType(), + entity.getReferenceCount(), + linkedStoredFileCount, + linkedOwnerCount, + storedFileEntityRepository.findSampleOwnerUsernameByFileEntityId(entity.getId()), + storedFileEntityRepository.findSampleOwnerEmailByFileEntityId(entity.getId()), + entity.getCreatedBy() == null ? null : entity.getCreatedBy().getId(), + entity.getCreatedBy() == null ? null : entity.getCreatedBy().getUsername(), + entity.getCreatedAt(), + blob == null ? null : blob.getCreatedAt(), + blob == null, + linkedStoredFileCount == 0, + entity.getReferenceCount() == null || entity.getReferenceCount() != linkedStoredFileCount + ); + } + + private AdminShareResponse toAdminShareResponse(FileShareLink shareLink) { + StoredFile file = shareLink.getFile(); + User owner = shareLink.getOwner(); + boolean expired = shareLink.getExpiresAt() != null && shareLink.getExpiresAt().isBefore(LocalDateTime.now()); + return new AdminShareResponse( + shareLink.getId(), + shareLink.getToken(), + shareLink.getShareNameOrDefault(), + shareLink.hasPassword(), + expired, + shareLink.getCreatedAt(), + shareLink.getExpiresAt(), + shareLink.getMaxDownloads(), + shareLink.getDownloadCountOrZero(), + shareLink.getViewCountOrZero(), + shareLink.isAllowImportEnabled(), + shareLink.isAllowDownloadEnabled(), + owner.getId(), + owner.getUsername(), + owner.getEmail(), + file.getId(), + file.getFilename(), + file.getPath(), + file.getContentType(), + file.getSize(), + file.isDirectory() + ); + } + + private String normalizeQuery(String query) { + if (query == null) { + return ""; + } + return query.trim(); + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminMutableSettingsService.java b/backend/src/main/java/com/yoyuzh/admin/AdminMutableSettingsService.java new file mode 100644 index 0000000..3e17391 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminMutableSettingsService.java @@ -0,0 +1,66 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.auth.RegistrationInviteService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class AdminMutableSettingsService { + + private final RegistrationInviteService registrationInviteService; + private final AdminMetricsService adminMetricsService; + private final AdminAuditService adminAuditService; + + @Transactional + public AdminRegistrationInviteCodeResponse updateRegistrationInviteCode(String inviteCode) { + String normalizedInviteCode = normalizeQuery(inviteCode); + String currentInviteCode = registrationInviteService.updateCurrentInviteCode(normalizedInviteCode); + adminAuditService.record( + AdminAuditAction.UPDATE_REGISTRATION_INVITE_CODE, + "SYSTEM_SETTING", + null, + "Updated registration invite code", + Map.of("inviteCodeLength", currentInviteCode.length()) + ); + return new AdminRegistrationInviteCodeResponse(currentInviteCode); + } + + @Transactional + public AdminRegistrationInviteCodeResponse rotateRegistrationInviteCode() { + String currentInviteCode = registrationInviteService.rotateCurrentInviteCode(); + adminAuditService.record( + AdminAuditAction.ROTATE_REGISTRATION_INVITE_CODE, + "SYSTEM_SETTING", + null, + "Rotated registration invite code", + Map.of("inviteCodeLength", currentInviteCode.length()) + ); + return new AdminRegistrationInviteCodeResponse(currentInviteCode); + } + + @Transactional + public AdminOfflineTransferStorageLimitResponse updateOfflineTransferStorageLimit(long offlineTransferStorageLimitBytes) { + AdminOfflineTransferStorageLimitResponse response = adminMetricsService.updateOfflineTransferStorageLimit( + offlineTransferStorageLimitBytes + ); + adminAuditService.record( + AdminAuditAction.UPDATE_OFFLINE_TRANSFER_STORAGE_LIMIT, + "SYSTEM_SETTING", + null, + "Updated offline transfer storage limit", + Map.of("offlineTransferStorageLimitBytes", response.offlineTransferStorageLimitBytes()) + ); + return response; + } + + private String normalizeQuery(String query) { + if (query == null) { + return ""; + } + return query.trim(); + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminRegistrationInviteCodeResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminRegistrationInviteCodeResponse.java new file mode 100644 index 0000000..36ad49c --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminRegistrationInviteCodeResponse.java @@ -0,0 +1,6 @@ +package com.yoyuzh.admin; + +public record AdminRegistrationInviteCodeResponse( + String currentInviteCode +) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminRegistrationInviteCodeUpdateRequest.java b/backend/src/main/java/com/yoyuzh/admin/AdminRegistrationInviteCodeUpdateRequest.java new file mode 100644 index 0000000..9062e90 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminRegistrationInviteCodeUpdateRequest.java @@ -0,0 +1,11 @@ +package com.yoyuzh.admin; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record AdminRegistrationInviteCodeUpdateRequest( + @NotBlank(message = "邀请码不能为空") + @Size(max = 64, message = "邀请码长度不能超过 64 个字符") + String inviteCode +) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminResourceGovernanceService.java b/backend/src/main/java/com/yoyuzh/admin/AdminResourceGovernanceService.java new file mode 100644 index 0000000..079d01d --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminResourceGovernanceService.java @@ -0,0 +1,60 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import com.yoyuzh.files.core.FileService; +import com.yoyuzh.files.core.StoredFile; +import com.yoyuzh.files.core.StoredFileRepository; +import com.yoyuzh.files.share.FileShareLink; +import com.yoyuzh.files.share.FileShareLinkRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class AdminResourceGovernanceService { + + private final StoredFileRepository storedFileRepository; + private final FileService fileService; + private final FileShareLinkRepository fileShareLinkRepository; + private final AdminAuditService adminAuditService; + + @Transactional + public void deleteShare(Long shareId) { + FileShareLink shareLink = fileShareLinkRepository.findById(shareId) + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "share not found")); + fileShareLinkRepository.delete(shareLink); + Map details = new LinkedHashMap<>(); + details.put("token", shareLink.getToken()); + adminAuditService.record( + AdminAuditAction.DELETE_SHARE, + "SHARE", + shareId, + "Deleted share link", + details + ); + } + + @Transactional + public void deleteFile(Long fileId) { + StoredFile storedFile = storedFileRepository.findById(fileId) + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "file not found")); + fileService.delete(storedFile.getUser(), fileId); + Map details = new LinkedHashMap<>(); + details.put("ownerUserId", storedFile.getUser().getId()); + details.put("path", storedFile.getPath()); + details.put("filename", storedFile.getFilename()); + details.put("directory", storedFile.isDirectory()); + adminAuditService.record( + AdminAuditAction.DELETE_FILE, + "FILE", + fileId, + "Deleted file", + details + ); + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminService.java b/backend/src/main/java/com/yoyuzh/admin/AdminService.java deleted file mode 100644 index cbfa551..0000000 --- a/backend/src/main/java/com/yoyuzh/admin/AdminService.java +++ /dev/null @@ -1,611 +0,0 @@ -package com.yoyuzh.admin; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.yoyuzh.auth.AuthTokenInvalidationService; -import com.yoyuzh.auth.PasswordPolicy; -import com.yoyuzh.auth.RefreshTokenService; -import com.yoyuzh.auth.RegistrationInviteService; -import com.yoyuzh.auth.User; -import com.yoyuzh.auth.UserRepository; -import com.yoyuzh.auth.UserRole; -import com.yoyuzh.common.BusinessException; -import com.yoyuzh.common.ErrorCode; -import com.yoyuzh.common.PageResponse; -import com.yoyuzh.config.RedisCacheNames; -import com.yoyuzh.files.core.FileEntity; -import com.yoyuzh.files.core.FileEntityRepository; -import com.yoyuzh.files.core.FileEntityType; -import com.yoyuzh.files.core.FileService; -import com.yoyuzh.files.core.StoredFile; -import com.yoyuzh.files.core.StoredFileEntityRepository; -import com.yoyuzh.files.core.StoredFileRepository; -import com.yoyuzh.files.core.FileBlobRepository; -import com.yoyuzh.files.policy.StoragePolicy; -import com.yoyuzh.files.policy.StoragePolicyRepository; -import com.yoyuzh.files.policy.StoragePolicyService; -import com.yoyuzh.files.share.FileShareLink; -import com.yoyuzh.files.share.FileShareLinkRepository; -import com.yoyuzh.files.tasks.BackgroundTask; -import com.yoyuzh.files.tasks.BackgroundTaskFailureCategory; -import com.yoyuzh.files.tasks.BackgroundTaskRepository; -import com.yoyuzh.files.tasks.BackgroundTaskService; -import com.yoyuzh.files.tasks.BackgroundTaskStatus; -import com.yoyuzh.files.tasks.BackgroundTaskType; -import com.yoyuzh.transfer.OfflineTransferSessionRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; - -import java.security.SecureRandom; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class AdminService { - - private final UserRepository userRepository; - private final StoredFileRepository storedFileRepository; - private final FileBlobRepository fileBlobRepository; - private final FileService fileService; - private final PasswordEncoder passwordEncoder; - private final RefreshTokenService refreshTokenService; - private final AuthTokenInvalidationService authTokenInvalidationService; - private final RegistrationInviteService registrationInviteService; - private final OfflineTransferSessionRepository offlineTransferSessionRepository; - private final AdminMetricsService adminMetricsService; - private final StoragePolicyRepository storagePolicyRepository; - private final StoragePolicyService storagePolicyService; - private final FileEntityRepository fileEntityRepository; - private final StoredFileEntityRepository storedFileEntityRepository; - private final BackgroundTaskRepository backgroundTaskRepository; - private final BackgroundTaskService backgroundTaskService; - private final FileShareLinkRepository fileShareLinkRepository; - private final ObjectMapper objectMapper; - private final SecureRandom secureRandom = new SecureRandom(); - - public AdminSummaryResponse getSummary() { - AdminMetricsSnapshot metrics = adminMetricsService.getSnapshot(); - return new AdminSummaryResponse( - userRepository.count(), - storedFileRepository.count(), - fileBlobRepository.sumAllBlobSize(), - metrics.downloadTrafficBytes(), - metrics.requestCount(), - metrics.transferUsageBytes(), - offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now()), - metrics.offlineTransferStorageLimitBytes(), - metrics.dailyActiveUsers(), - metrics.requestTimeline(), - registrationInviteService.getCurrentInviteCode() - ); - } - - public PageResponse listUsers(int page, int size, String query) { - Page result = userRepository.searchByUsernameOrEmail( - normalizeQuery(query), - PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) - ); - List items = result.getContent().stream() - .map(this::toUserResponse) - .toList(); - return new PageResponse<>(items, result.getTotalElements(), page, size); - } - - public PageResponse listFiles(int page, int size, String query, String ownerQuery) { - Page result = storedFileRepository.searchAdminFiles( - normalizeQuery(query), - normalizeQuery(ownerQuery), - PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "user.username") - .and(Sort.by(Sort.Direction.DESC, "createdAt"))) - ); - List items = result.getContent().stream() - .map(this::toFileResponse) - .toList(); - return new PageResponse<>(items, result.getTotalElements(), page, size); - } - - public PageResponse listFileBlobs(int page, - int size, - String userQuery, - Long storagePolicyId, - String objectKey, - FileEntityType entityType) { - Page result = fileEntityRepository.searchAdminEntities( - normalizeQuery(userQuery), - storagePolicyId, - normalizeQuery(objectKey), - entityType, - PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) - ); - List items = result.getContent().stream() - .map(this::toFileBlobResponse) - .toList(); - return new PageResponse<>(items, result.getTotalElements(), page, size); - } - - public PageResponse listShares(int page, - int size, - String userQuery, - String fileName, - String token, - Boolean passwordProtected, - Boolean expired) { - Page result = fileShareLinkRepository.searchAdminShares( - normalizeQuery(userQuery), - normalizeQuery(fileName), - normalizeQuery(token), - passwordProtected, - expired, - LocalDateTime.now(), - PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) - ); - List items = result.getContent().stream() - .map(this::toAdminShareResponse) - .toList(); - return new PageResponse<>(items, result.getTotalElements(), page, size); - } - - @Transactional - public void deleteShare(Long shareId) { - FileShareLink shareLink = fileShareLinkRepository.findById(shareId) - .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "share not found")); - fileShareLinkRepository.delete(shareLink); - } - - public PageResponse listTasks(int page, - int size, - String userQuery, - BackgroundTaskType type, - BackgroundTaskStatus status, - BackgroundTaskFailureCategory failureCategory, - AdminTaskLeaseState leaseState) { - String failureCategoryPattern = failureCategory == null - ? null - : "\"failureCategory\":\"" + failureCategory.name() + "\""; - Page result = backgroundTaskRepository.searchAdminTasks( - normalizeQuery(userQuery), - type, - status, - failureCategoryPattern, - leaseState == null ? null : leaseState.name(), - LocalDateTime.now(), - PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) - ); - Map ownerById = userRepository.findAllById(result.getContent().stream() - .map(BackgroundTask::getUserId) - .collect(Collectors.toSet())) - .stream() - .collect(Collectors.toMap(User::getId, user -> user)); - List items = result.getContent().stream() - .map(task -> toAdminTaskResponse(task, ownerById.get(task.getUserId()))) - .toList(); - return new PageResponse<>(items, result.getTotalElements(), page, size); - } - - public AdminTaskResponse getTask(Long taskId) { - BackgroundTask task = backgroundTaskRepository.findById(taskId) - .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "task not found")); - User owner = userRepository.findById(task.getUserId()).orElse(null); - return toAdminTaskResponse(task, owner); - } - - @Cacheable(cacheNames = RedisCacheNames.STORAGE_POLICIES, key = "'all'") - public List listStoragePolicies() { - return storagePolicyRepository.findAll(Sort.by(Sort.Direction.DESC, "defaultPolicy") - .and(Sort.by(Sort.Direction.DESC, "enabled")) - .and(Sort.by(Sort.Direction.ASC, "id"))) - .stream() - .map(this::toStoragePolicyResponse) - .toList(); - } - - @Transactional - @CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true) - public AdminStoragePolicyResponse createStoragePolicy(AdminStoragePolicyUpsertRequest request) { - StoragePolicy policy = new StoragePolicy(); - policy.setDefaultPolicy(false); - applyStoragePolicyUpsert(policy, request); - return toStoragePolicyResponse(storagePolicyRepository.save(policy)); - } - - @Transactional - @CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true) - public AdminStoragePolicyResponse updateStoragePolicy(Long policyId, AdminStoragePolicyUpsertRequest request) { - StoragePolicy policy = getRequiredStoragePolicy(policyId); - applyStoragePolicyUpsert(policy, request); - return toStoragePolicyResponse(storagePolicyRepository.save(policy)); - } - - @Transactional - @CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true) - public AdminStoragePolicyResponse updateStoragePolicyStatus(Long policyId, boolean enabled) { - StoragePolicy policy = getRequiredStoragePolicy(policyId); - if (policy.isDefaultPolicy() && !enabled) { - throw new BusinessException(ErrorCode.UNKNOWN, "榛樿瀛樺偍绛栫暐涓嶈兘鍋滅敤"); - } - policy.setEnabled(enabled); - return toStoragePolicyResponse(storagePolicyRepository.save(policy)); - } - - @Transactional - public BackgroundTask createStoragePolicyMigrationTask(User user, AdminStoragePolicyMigrationCreateRequest request) { - StoragePolicy sourcePolicy = getRequiredStoragePolicy(request.sourcePolicyId()); - StoragePolicy targetPolicy = getRequiredStoragePolicy(request.targetPolicyId()); - if (sourcePolicy.getId().equals(targetPolicy.getId())) { - throw new BusinessException(ErrorCode.UNKNOWN, "婧愬瓨鍌ㄧ瓥鐣ュ拰鐩爣瀛樺偍绛栫暐涓嶈兘鐩稿悓"); - } - if (!targetPolicy.isEnabled()) { - throw new BusinessException(ErrorCode.UNKNOWN, "target storage policy must be enabled"); - } - - long candidateEntityCount = fileEntityRepository.countByStoragePolicyIdAndEntityType( - sourcePolicy.getId(), - FileEntityType.VERSION - ); - long candidateStoredFileCount = storedFileEntityRepository.countDistinctStoredFilesByStoragePolicyIdAndEntityType( - sourcePolicy.getId(), - FileEntityType.VERSION - ); - - Map state = new LinkedHashMap<>(); - state.put("sourcePolicyId", sourcePolicy.getId()); - state.put("sourcePolicyName", sourcePolicy.getName()); - state.put("targetPolicyId", targetPolicy.getId()); - state.put("targetPolicyName", targetPolicy.getName()); - state.put("candidateEntityCount", candidateEntityCount); - state.put("candidateStoredFileCount", candidateStoredFileCount); - state.put("migrationPerformed", false); - state.put("migrationMode", "skeleton"); - state.put("entityType", FileEntityType.VERSION.name()); - state.put("message", "storage policy migration skeleton queued; worker will validate and recount candidates without moving object data"); - - Map privateState = new LinkedHashMap<>(state); - privateState.put("taskType", BackgroundTaskType.STORAGE_POLICY_MIGRATION.name()); - - return backgroundTaskService.createQueuedTask( - user, - BackgroundTaskType.STORAGE_POLICY_MIGRATION, - state, - privateState, - request.correlationId() - ); - } - - @Transactional - public void deleteFile(Long fileId) { - StoredFile storedFile = storedFileRepository.findById(fileId) - .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "file not found")); - fileService.delete(storedFile.getUser(), fileId); - } - - @Transactional - public AdminUserResponse updateUserRole(Long userId, UserRole role) { - User user = getRequiredUser(userId); - user.setRole(role); - return toUserResponse(userRepository.save(user)); - } - - @Transactional - public AdminUserResponse updateUserBanned(Long userId, boolean banned) { - User user = getRequiredUser(userId); - user.setBanned(banned); - authTokenInvalidationService.revokeAccessTokensForUser(user.getId()); - user.setActiveSessionId(UUID.randomUUID().toString()); - user.setDesktopActiveSessionId(UUID.randomUUID().toString()); - user.setMobileActiveSessionId(UUID.randomUUID().toString()); - refreshTokenService.revokeAllForUser(user.getId()); - return toUserResponse(userRepository.save(user)); - } - - @Transactional - public AdminUserResponse updateUserPassword(Long userId, String newPassword) { - if (!PasswordPolicy.isStrong(newPassword)) { - throw new BusinessException(ErrorCode.UNKNOWN, PasswordPolicy.VALIDATION_MESSAGE); - } - User user = getRequiredUser(userId); - user.setPasswordHash(passwordEncoder.encode(newPassword)); - authTokenInvalidationService.revokeAccessTokensForUser(user.getId()); - user.setActiveSessionId(UUID.randomUUID().toString()); - user.setDesktopActiveSessionId(UUID.randomUUID().toString()); - user.setMobileActiveSessionId(UUID.randomUUID().toString()); - refreshTokenService.revokeAllForUser(user.getId()); - return toUserResponse(userRepository.save(user)); - } - - @Transactional - public AdminUserResponse updateUserStorageQuota(Long userId, long storageQuotaBytes) { - User user = getRequiredUser(userId); - user.setStorageQuotaBytes(storageQuotaBytes); - return toUserResponse(userRepository.save(user)); - } - - @Transactional - public AdminUserResponse updateUserMaxUploadSize(Long userId, long maxUploadSizeBytes) { - User user = getRequiredUser(userId); - user.setMaxUploadSizeBytes(maxUploadSizeBytes); - return toUserResponse(userRepository.save(user)); - } - - @Transactional - public AdminPasswordResetResponse resetUserPassword(Long userId) { - String temporaryPassword = generateTemporaryPassword(); - updateUserPassword(userId, temporaryPassword); - return new AdminPasswordResetResponse(temporaryPassword); - } - - @Transactional - public AdminOfflineTransferStorageLimitResponse updateOfflineTransferStorageLimit(long offlineTransferStorageLimitBytes) { - return adminMetricsService.updateOfflineTransferStorageLimit(offlineTransferStorageLimitBytes); - } - - private AdminUserResponse toUserResponse(User user) { - long usedStorageBytes = storedFileRepository.sumFileSizeByUserId(user.getId()); - return new AdminUserResponse( - user.getId(), - user.getUsername(), - user.getEmail(), - user.getPhoneNumber(), - user.getCreatedAt(), - user.getRole(), - user.isBanned(), - usedStorageBytes, - user.getStorageQuotaBytes(), - user.getMaxUploadSizeBytes() - ); - } - - private AdminFileResponse toFileResponse(StoredFile storedFile) { - User owner = storedFile.getUser(); - return new AdminFileResponse( - storedFile.getId(), - storedFile.getFilename(), - storedFile.getPath(), - storedFile.getSize(), - storedFile.getContentType(), - storedFile.isDirectory(), - storedFile.getCreatedAt(), - owner.getId(), - owner.getUsername(), - owner.getEmail() - ); - } - - private AdminStoragePolicyResponse toStoragePolicyResponse(StoragePolicy policy) { - return new AdminStoragePolicyResponse( - policy.getId(), - policy.getName(), - policy.getType(), - policy.getBucketName(), - policy.getEndpoint(), - policy.getRegion(), - policy.isPrivateBucket(), - policy.getPrefix(), - policy.getCredentialMode(), - policy.getMaxSizeBytes(), - storagePolicyService.readCapabilities(policy), - policy.isEnabled(), - policy.isDefaultPolicy(), - policy.getCreatedAt(), - policy.getUpdatedAt() - ); - } - - private AdminFileBlobResponse toFileBlobResponse(FileEntity entity) { - var blob = fileBlobRepository.findByObjectKey(entity.getObjectKey()).orElse(null); - long linkedStoredFileCount = storedFileEntityRepository.countByFileEntityId(entity.getId()); - long linkedOwnerCount = storedFileEntityRepository.countDistinctOwnersByFileEntityId(entity.getId()); - return new AdminFileBlobResponse( - entity.getId(), - blob == null ? null : blob.getId(), - entity.getObjectKey(), - entity.getEntityType(), - entity.getStoragePolicyId(), - entity.getSize(), - StringUtils.hasText(entity.getContentType()) ? entity.getContentType() : blob == null ? null : blob.getContentType(), - entity.getReferenceCount(), - linkedStoredFileCount, - linkedOwnerCount, - storedFileEntityRepository.findSampleOwnerUsernameByFileEntityId(entity.getId()), - storedFileEntityRepository.findSampleOwnerEmailByFileEntityId(entity.getId()), - entity.getCreatedBy() == null ? null : entity.getCreatedBy().getId(), - entity.getCreatedBy() == null ? null : entity.getCreatedBy().getUsername(), - entity.getCreatedAt(), - blob == null ? null : blob.getCreatedAt(), - blob == null, - linkedStoredFileCount == 0, - entity.getReferenceCount() == null || entity.getReferenceCount() != linkedStoredFileCount - ); - } - - private AdminShareResponse toAdminShareResponse(FileShareLink shareLink) { - StoredFile file = shareLink.getFile(); - User owner = shareLink.getOwner(); - boolean expired = shareLink.getExpiresAt() != null && shareLink.getExpiresAt().isBefore(LocalDateTime.now()); - return new AdminShareResponse( - shareLink.getId(), - shareLink.getToken(), - shareLink.getShareNameOrDefault(), - shareLink.hasPassword(), - expired, - shareLink.getCreatedAt(), - shareLink.getExpiresAt(), - shareLink.getMaxDownloads(), - shareLink.getDownloadCountOrZero(), - shareLink.getViewCountOrZero(), - shareLink.isAllowImportEnabled(), - shareLink.isAllowDownloadEnabled(), - owner.getId(), - owner.getUsername(), - owner.getEmail(), - file.getId(), - file.getFilename(), - file.getPath(), - file.getContentType(), - file.getSize(), - file.isDirectory() - ); - } - - private AdminTaskResponse toAdminTaskResponse(BackgroundTask task, User owner) { - Map state = parseState(task.getPublicStateJson()); - return new AdminTaskResponse( - task.getId(), - task.getType(), - task.getStatus(), - task.getUserId(), - owner == null ? null : owner.getUsername(), - owner == null ? null : owner.getEmail(), - task.getPublicStateJson(), - task.getCorrelationId(), - task.getErrorMessage(), - task.getAttemptCount(), - task.getMaxAttempts(), - task.getNextRunAt(), - task.getLeaseOwner(), - task.getLeaseExpiresAt(), - task.getHeartbeatAt(), - task.getCreatedAt(), - task.getUpdatedAt(), - task.getFinishedAt(), - readStringState(state, "failureCategory"), - readBooleanState(state, "retryScheduled"), - readStringState(state, "workerOwner"), - resolveLeaseState(task) - ); - } - - private void applyStoragePolicyUpsert(StoragePolicy policy, AdminStoragePolicyUpsertRequest request) { - if (policy.isDefaultPolicy() && !request.enabled()) { - throw new BusinessException(ErrorCode.UNKNOWN, "榛樿瀛樺偍绛栫暐涓嶈兘鍋滅敤"); - } - validateStoragePolicyRequest(request); - policy.setName(request.name().trim()); - policy.setType(request.type()); - policy.setBucketName(normalizeNullable(request.bucketName())); - policy.setEndpoint(normalizeNullable(request.endpoint())); - policy.setRegion(normalizeNullable(request.region())); - policy.setPrivateBucket(request.privateBucket()); - policy.setPrefix(normalizePrefix(request.prefix())); - policy.setCredentialMode(request.credentialMode()); - policy.setMaxSizeBytes(request.maxSizeBytes()); - policy.setCapabilitiesJson(storagePolicyService.writeCapabilities(request.capabilities())); - policy.setEnabled(request.enabled()); - } - - private User getRequiredUser(Long userId) { - return userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "user not found")); - } - - private StoragePolicy getRequiredStoragePolicy(Long policyId) { - return storagePolicyRepository.findById(policyId) - .orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "storage policy not found")); - } - - private String normalizeQuery(String query) { - if (query == null) { - return ""; - } - return query.trim(); - } - - private String normalizeNullable(String value) { - if (!StringUtils.hasText(value)) { - return null; - } - return value.trim(); - } - - private String normalizePrefix(String prefix) { - if (!StringUtils.hasText(prefix)) { - return ""; - } - return prefix.trim(); - } - - private Map parseState(String json) { - if (!StringUtils.hasText(json)) { - return Map.of(); - } - try { - return objectMapper.readValue(json, new TypeReference>() { - }); - } catch (JsonProcessingException ex) { - return Map.of(); - } - } - - private String readStringState(Map state, String key) { - Object value = state.get(key); - return value == null ? null : String.valueOf(value); - } - - private Boolean readBooleanState(Map state, String key) { - Object value = state.get(key); - if (value instanceof Boolean boolValue) { - return boolValue; - } - if (value instanceof String stringValue) { - return Boolean.parseBoolean(stringValue); - } - return null; - } - - private AdminTaskLeaseState resolveLeaseState(BackgroundTask task) { - if (!StringUtils.hasText(task.getLeaseOwner()) || task.getLeaseExpiresAt() == null) { - return AdminTaskLeaseState.NONE; - } - return task.getLeaseExpiresAt().isBefore(LocalDateTime.now()) - ? AdminTaskLeaseState.EXPIRED - : AdminTaskLeaseState.ACTIVE; - } - - private void validateStoragePolicyRequest(AdminStoragePolicyUpsertRequest request) { - if (request.type() == com.yoyuzh.files.policy.StoragePolicyType.LOCAL - && request.credentialMode() != com.yoyuzh.files.policy.StoragePolicyCredentialMode.NONE) { - throw new BusinessException(ErrorCode.UNKNOWN, "鏈湴瀛樺偍绛栫暐蹇呴』浣跨敤 NONE 鍑瘉妯″紡"); - } - if (request.type() == com.yoyuzh.files.policy.StoragePolicyType.S3_COMPATIBLE - && !StringUtils.hasText(request.bucketName())) { - throw new BusinessException(ErrorCode.UNKNOWN, "S3 瀛樺偍绛栫暐蹇呴』鎻愪緵 bucketName"); - } - } - - private String generateTemporaryPassword() { - String lowers = "abcdefghjkmnpqrstuvwxyz"; - String uppers = "ABCDEFGHJKMNPQRSTUVWXYZ"; - String digits = "23456789"; - String specials = "!@#$%^&*"; - String all = lowers + uppers + digits + specials; - char[] password = new char[12]; - password[0] = lowers.charAt(secureRandom.nextInt(lowers.length())); - password[1] = uppers.charAt(secureRandom.nextInt(uppers.length())); - password[2] = digits.charAt(secureRandom.nextInt(digits.length())); - password[3] = specials.charAt(secureRandom.nextInt(specials.length())); - for (int i = 4; i < password.length; i += 1) { - password[i] = all.charAt(secureRandom.nextInt(all.length())); - } - for (int i = password.length - 1; i > 0; i -= 1) { - int j = secureRandom.nextInt(i + 1); - char tmp = password[i]; - password[i] = password[j]; - password[j] = tmp; - } - return new String(password); - } -} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminSettingsResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminSettingsResponse.java new file mode 100644 index 0000000..47a8f5c --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminSettingsResponse.java @@ -0,0 +1,73 @@ +package com.yoyuzh.admin; + +import java.util.List; + +public record AdminSettingsResponse( + SiteSection site, + RegistrationSection registration, + UserSessionSection userSession, + TransferSection transfer, + MediaProcessingSection mediaProcessing, + QueueSection queue, + AppearanceSection appearance, + ServerSection server +) { + + public record SiteSection( + boolean supported, + boolean writeSupported + ) { + } + + public record RegistrationSection( + boolean inviteCodeRequired, + String currentInviteCode, + List managementRoles, + boolean writeSupported + ) { + } + + public record UserSessionSection( + long accessExpirationSeconds, + long refreshExpirationSeconds, + boolean tokenBlacklistEnabled, + long tokenBlacklistTtlBufferSeconds, + boolean writeSupported + ) { + } + + public record TransferSection( + long offlineTransferStorageLimitBytes, + boolean writeSupported + ) { + } + + public record MediaProcessingSection( + boolean metadataExtractionEnabled, + boolean thumbnailGenerationEnabled, + boolean videoPosterEnabled, + boolean writeSupported + ) { + } + + public record QueueSection( + String backend, + long mediaMetadataFixedDelayMs, + long mediaMetadataInitialDelayMs, + boolean writeSupported + ) { + } + + public record AppearanceSection( + boolean supported, + boolean writeSupported + ) { + } + + public record ServerSection( + String storageProvider, + boolean redisEnabled, + boolean writeSupported + ) { + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminStorageGovernanceService.java b/backend/src/main/java/com/yoyuzh/admin/AdminStorageGovernanceService.java new file mode 100644 index 0000000..17f5737 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminStorageGovernanceService.java @@ -0,0 +1,199 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.auth.User; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import com.yoyuzh.config.RedisCacheNames; +import com.yoyuzh.files.core.FileEntityRepository; +import com.yoyuzh.files.core.FileEntityType; +import com.yoyuzh.files.core.StoredFileEntityRepository; +import com.yoyuzh.files.policy.StoragePolicy; +import com.yoyuzh.files.policy.StoragePolicyRepository; +import com.yoyuzh.files.policy.StoragePolicyService; +import com.yoyuzh.files.tasks.BackgroundTask; +import com.yoyuzh.files.tasks.BackgroundTaskCommandService; +import com.yoyuzh.files.tasks.BackgroundTaskType; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class AdminStorageGovernanceService { + + private final StoragePolicyRepository storagePolicyRepository; + private final StoragePolicyService storagePolicyService; + private final FileEntityRepository fileEntityRepository; + private final StoredFileEntityRepository storedFileEntityRepository; + private final BackgroundTaskCommandService backgroundTaskCommandService; + private final AdminAuditService adminAuditService; + + @Transactional + @CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true) + public AdminStoragePolicyResponse createStoragePolicy(AdminStoragePolicyUpsertRequest request) { + StoragePolicy policy = new StoragePolicy(); + policy.setDefaultPolicy(false); + applyStoragePolicyUpsert(policy, request); + AdminStoragePolicyResponse response = AdminStoragePolicyResponses.from(storagePolicyService, storagePolicyRepository.save(policy)); + adminAuditService.record( + AdminAuditAction.CREATE_STORAGE_POLICY, + "STORAGE_POLICY", + response.id(), + "Created storage policy", + Map.of( + "name", response.name(), + "type", response.type().name(), + "enabled", response.enabled() + ) + ); + return response; + } + + @Transactional + @CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true) + public AdminStoragePolicyResponse updateStoragePolicy(Long policyId, AdminStoragePolicyUpsertRequest request) { + StoragePolicy policy = getRequiredStoragePolicy(policyId); + applyStoragePolicyUpsert(policy, request); + AdminStoragePolicyResponse response = AdminStoragePolicyResponses.from(storagePolicyService, storagePolicyRepository.save(policy)); + adminAuditService.record( + AdminAuditAction.UPDATE_STORAGE_POLICY, + "STORAGE_POLICY", + policyId, + "Updated storage policy", + Map.of( + "name", response.name(), + "type", response.type().name(), + "enabled", response.enabled() + ) + ); + return response; + } + + @Transactional + @CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true) + public AdminStoragePolicyResponse updateStoragePolicyStatus(Long policyId, boolean enabled) { + StoragePolicy policy = getRequiredStoragePolicy(policyId); + if (policy.isDefaultPolicy() && !enabled) { + throw new BusinessException(ErrorCode.UNKNOWN, "姒涙顓荤€涙ê鍋嶇粵鏍殣娑撳秷鍏橀崑婊呮暏"); + } + policy.setEnabled(enabled); + AdminStoragePolicyResponse response = AdminStoragePolicyResponses.from(storagePolicyService, storagePolicyRepository.save(policy)); + adminAuditService.record( + AdminAuditAction.UPDATE_STORAGE_POLICY_STATUS, + "STORAGE_POLICY", + policyId, + enabled ? "Enabled storage policy" : "Disabled storage policy", + Map.of("enabled", enabled) + ); + return response; + } + + @Transactional + public BackgroundTask createStoragePolicyMigrationTask(User user, AdminStoragePolicyMigrationCreateRequest request) { + StoragePolicy sourcePolicy = getRequiredStoragePolicy(request.sourcePolicyId()); + StoragePolicy targetPolicy = getRequiredStoragePolicy(request.targetPolicyId()); + if (sourcePolicy.getId().equals(targetPolicy.getId())) { + throw new BusinessException(ErrorCode.UNKNOWN, "濠ф劕鐡ㄩ崒銊х摜閻c儱鎷伴惄顔界垼鐎涙ê鍋嶇粵鏍殣娑撳秷鍏橀惄绋挎倱"); + } + if (!targetPolicy.isEnabled()) { + throw new BusinessException(ErrorCode.UNKNOWN, "target storage policy must be enabled"); + } + + long candidateEntityCount = fileEntityRepository.countByStoragePolicyIdAndEntityType( + sourcePolicy.getId(), + FileEntityType.VERSION + ); + long candidateStoredFileCount = storedFileEntityRepository.countDistinctStoredFilesByStoragePolicyIdAndEntityType( + sourcePolicy.getId(), + FileEntityType.VERSION + ); + + Map state = new LinkedHashMap<>(); + state.put("sourcePolicyId", sourcePolicy.getId()); + state.put("sourcePolicyName", sourcePolicy.getName()); + state.put("targetPolicyId", targetPolicy.getId()); + state.put("targetPolicyName", targetPolicy.getName()); + state.put("candidateEntityCount", candidateEntityCount); + state.put("candidateStoredFileCount", candidateStoredFileCount); + state.put("migrationPerformed", false); + state.put("migrationMode", "skeleton"); + state.put("entityType", FileEntityType.VERSION.name()); + state.put("message", "storage policy migration skeleton queued; worker will validate and recount candidates without moving object data"); + + Map privateState = new LinkedHashMap<>(state); + privateState.put("taskType", BackgroundTaskType.STORAGE_POLICY_MIGRATION.name()); + + BackgroundTask task = backgroundTaskCommandService.createQueuedTask( + user, + BackgroundTaskType.STORAGE_POLICY_MIGRATION, + state, + privateState, + request.correlationId() + ); + Map auditDetails = new LinkedHashMap<>(); + auditDetails.put("sourcePolicyId", sourcePolicy.getId()); + auditDetails.put("targetPolicyId", targetPolicy.getId()); + auditDetails.put("correlationId", request.correlationId()); + adminAuditService.record( + AdminAuditAction.CREATE_STORAGE_POLICY_MIGRATION_TASK, + "TASK", + task.getId(), + "Created storage policy migration task", + auditDetails + ); + return task; + } + + private void applyStoragePolicyUpsert(StoragePolicy policy, AdminStoragePolicyUpsertRequest request) { + if (policy.isDefaultPolicy() && !request.enabled()) { + throw new BusinessException(ErrorCode.UNKNOWN, "姒涙顓荤€涙ê鍋嶇粵鏍殣娑撳秷鍏橀崑婊呮暏"); + } + validateStoragePolicyRequest(request); + policy.setName(request.name().trim()); + policy.setType(request.type()); + policy.setBucketName(normalizeNullable(request.bucketName())); + policy.setEndpoint(normalizeNullable(request.endpoint())); + policy.setRegion(normalizeNullable(request.region())); + policy.setPrivateBucket(request.privateBucket()); + policy.setPrefix(normalizePrefix(request.prefix())); + policy.setCredentialMode(request.credentialMode()); + policy.setMaxSizeBytes(request.maxSizeBytes()); + policy.setCapabilitiesJson(storagePolicyService.writeCapabilities(request.capabilities())); + policy.setEnabled(request.enabled()); + } + + private StoragePolicy getRequiredStoragePolicy(Long policyId) { + return storagePolicyRepository.findById(policyId) + .orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "storage policy not found")); + } + + private String normalizeNullable(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } + + private String normalizePrefix(String prefix) { + if (!StringUtils.hasText(prefix)) { + return ""; + } + return prefix.trim(); + } + + private void validateStoragePolicyRequest(AdminStoragePolicyUpsertRequest request) { + if (request.type() == com.yoyuzh.files.policy.StoragePolicyType.LOCAL + && request.credentialMode() != com.yoyuzh.files.policy.StoragePolicyCredentialMode.NONE) { + throw new BusinessException(ErrorCode.UNKNOWN, "閺堫剙婀寸€涙ê鍋嶇粵鏍殣韫囧懘銆忔担璺ㄦ暏 NONE 閸戭叀鐦夊Ο鈥崇础"); + } + if (request.type() == com.yoyuzh.files.policy.StoragePolicyType.S3_COMPATIBLE + && !StringUtils.hasText(request.bucketName())) { + throw new BusinessException(ErrorCode.UNKNOWN, "S3 鐎涙ê鍋嶇粵鏍殣韫囧懘銆忛幓鎰返 bucketName"); + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyQueryService.java b/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyQueryService.java new file mode 100644 index 0000000..8a35f4a --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyQueryService.java @@ -0,0 +1,29 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.config.RedisCacheNames; +import com.yoyuzh.files.policy.StoragePolicyRepository; +import com.yoyuzh.files.policy.StoragePolicyService; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminStoragePolicyQueryService { + + private final StoragePolicyRepository storagePolicyRepository; + private final StoragePolicyService storagePolicyService; + + @Cacheable(cacheNames = RedisCacheNames.STORAGE_POLICIES, key = "'all'") + public List listStoragePolicies() { + return storagePolicyRepository.findAll(Sort.by(Sort.Direction.DESC, "defaultPolicy") + .and(Sort.by(Sort.Direction.DESC, "enabled")) + .and(Sort.by(Sort.Direction.ASC, "id"))) + .stream() + .map(policy -> AdminStoragePolicyResponses.from(storagePolicyService, policy)) + .toList(); + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyResponses.java b/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyResponses.java new file mode 100644 index 0000000..9071c30 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyResponses.java @@ -0,0 +1,30 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.files.policy.StoragePolicy; +import com.yoyuzh.files.policy.StoragePolicyService; + +final class AdminStoragePolicyResponses { + + private AdminStoragePolicyResponses() { + } + + static AdminStoragePolicyResponse from(StoragePolicyService storagePolicyService, StoragePolicy policy) { + return new AdminStoragePolicyResponse( + policy.getId(), + policy.getName(), + policy.getType(), + policy.getBucketName(), + policy.getEndpoint(), + policy.getRegion(), + policy.isPrivateBucket(), + policy.getPrefix(), + policy.getCredentialMode(), + policy.getMaxSizeBytes(), + storagePolicyService.readCapabilities(policy), + policy.isEnabled(), + policy.isDefaultPolicy(), + policy.getCreatedAt(), + policy.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminTaskQueryService.java b/backend/src/main/java/com/yoyuzh/admin/AdminTaskQueryService.java new file mode 100644 index 0000000..c867aa9 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminTaskQueryService.java @@ -0,0 +1,148 @@ +package com.yoyuzh.admin; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.auth.User; +import com.yoyuzh.auth.UserRepository; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import com.yoyuzh.common.PageResponse; +import com.yoyuzh.files.tasks.BackgroundTask; +import com.yoyuzh.files.tasks.BackgroundTaskFailureCategory; +import com.yoyuzh.files.tasks.BackgroundTaskRepository; +import com.yoyuzh.files.tasks.BackgroundTaskStatus; +import com.yoyuzh.files.tasks.BackgroundTaskType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AdminTaskQueryService { + + private final BackgroundTaskRepository backgroundTaskRepository; + private final UserRepository userRepository; + private final ObjectMapper objectMapper; + + public PageResponse listTasks(int page, + int size, + String userQuery, + BackgroundTaskType type, + BackgroundTaskStatus status, + BackgroundTaskFailureCategory failureCategory, + AdminTaskLeaseState leaseState) { + String failureCategoryPattern = failureCategory == null + ? null + : "\"failureCategory\":\"" + failureCategory.name() + "\""; + Page result = backgroundTaskRepository.searchAdminTasks( + normalizeQuery(userQuery), + type, + status, + failureCategoryPattern, + leaseState == null ? null : leaseState.name(), + LocalDateTime.now(), + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + Map ownerById = userRepository.findAllById(result.getContent().stream() + .map(BackgroundTask::getUserId) + .collect(Collectors.toSet())) + .stream() + .collect(Collectors.toMap(User::getId, user -> user)); + return new PageResponse<>( + result.getContent().stream() + .map(task -> toAdminTaskResponse(task, ownerById.get(task.getUserId()))) + .toList(), + result.getTotalElements(), + page, + size + ); + } + + public AdminTaskResponse getTask(Long taskId) { + BackgroundTask task = backgroundTaskRepository.findById(taskId) + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "task not found")); + User owner = userRepository.findById(task.getUserId()).orElse(null); + return toAdminTaskResponse(task, owner); + } + + private AdminTaskResponse toAdminTaskResponse(BackgroundTask task, User owner) { + Map state = parseState(task.getPublicStateJson()); + return new AdminTaskResponse( + task.getId(), + task.getType(), + task.getStatus(), + task.getUserId(), + owner == null ? null : owner.getUsername(), + owner == null ? null : owner.getEmail(), + task.getPublicStateJson(), + task.getCorrelationId(), + task.getErrorMessage(), + task.getAttemptCount(), + task.getMaxAttempts(), + task.getNextRunAt(), + task.getLeaseOwner(), + task.getLeaseExpiresAt(), + task.getHeartbeatAt(), + task.getCreatedAt(), + task.getUpdatedAt(), + task.getFinishedAt(), + readStringState(state, "failureCategory"), + readBooleanState(state, "retryScheduled"), + readStringState(state, "workerOwner"), + resolveLeaseState(task) + ); + } + + private Map parseState(String json) { + if (!StringUtils.hasText(json)) { + return Map.of(); + } + try { + return objectMapper.readValue(json, new TypeReference>() { + }); + } catch (JsonProcessingException ex) { + return Map.of(); + } + } + + private String readStringState(Map state, String key) { + Object value = state.get(key); + return value == null ? null : String.valueOf(value); + } + + private Boolean readBooleanState(Map state, String key) { + Object value = state.get(key); + if (value instanceof Boolean boolValue) { + return boolValue; + } + if (value instanceof String stringValue) { + return Boolean.parseBoolean(stringValue); + } + return null; + } + + private AdminTaskLeaseState resolveLeaseState(BackgroundTask task) { + if (!StringUtils.hasText(task.getLeaseOwner()) || task.getLeaseExpiresAt() == null) { + return AdminTaskLeaseState.NONE; + } + return task.getLeaseExpiresAt().isBefore(LocalDateTime.now()) + ? AdminTaskLeaseState.EXPIRED + : AdminTaskLeaseState.ACTIVE; + } + + private String normalizeQuery(String query) { + if (query == null) { + return ""; + } + return query.trim(); + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminUserGovernanceService.java b/backend/src/main/java/com/yoyuzh/admin/AdminUserGovernanceService.java new file mode 100644 index 0000000..a8965ed --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminUserGovernanceService.java @@ -0,0 +1,202 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.auth.AuthSessionPolicy; +import com.yoyuzh.auth.AuthTokenInvalidationService; +import com.yoyuzh.auth.PasswordPolicy; +import com.yoyuzh.auth.RefreshTokenService; +import com.yoyuzh.auth.User; +import com.yoyuzh.auth.UserRepository; +import com.yoyuzh.auth.UserRole; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import com.yoyuzh.common.PageResponse; +import com.yoyuzh.files.core.StoredFileRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class AdminUserGovernanceService { + + private final UserRepository userRepository; + private final StoredFileRepository storedFileRepository; + private final PasswordEncoder passwordEncoder; + private final RefreshTokenService refreshTokenService; + private final AuthTokenInvalidationService authTokenInvalidationService; + private final AuthSessionPolicy authSessionPolicy; + private final AdminAuditService adminAuditService; + private final SecureRandom secureRandom = new SecureRandom(); + + public PageResponse listUsers(int page, int size, String query) { + Page result = userRepository.searchByUsernameOrEmail( + normalizeQuery(query), + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + return new PageResponse<>( + result.getContent().stream().map(this::toUserResponse).toList(), + result.getTotalElements(), + page, + size + ); + } + + @Transactional + public AdminUserResponse updateUserRole(Long userId, UserRole role) { + User user = getRequiredUser(userId); + user.setRole(role); + AdminUserResponse response = toUserResponse(userRepository.save(user)); + adminAuditService.record( + AdminAuditAction.UPDATE_USER_ROLE, + "USER", + userId, + "Updated user role", + Map.of("role", role.name()) + ); + return response; + } + + @Transactional + public AdminUserResponse updateUserBanned(Long userId, boolean banned) { + User user = getRequiredUser(userId); + user.setBanned(banned); + authTokenInvalidationService.revokeAccessTokensForUser(user.getId()); + authSessionPolicy.rotateAllActiveSessions(user); + refreshTokenService.revokeAllForUser(user.getId()); + AdminUserResponse response = toUserResponse(userRepository.save(user)); + adminAuditService.record( + AdminAuditAction.UPDATE_USER_BANNED, + "USER", + userId, + banned ? "Banned user" : "Unbanned user", + Map.of("banned", banned) + ); + return response; + } + + @Transactional + public AdminUserResponse updateUserPassword(Long userId, String newPassword) { + return updateUserPasswordInternal(userId, newPassword, AdminAuditAction.UPDATE_USER_PASSWORD); + } + + @Transactional + public AdminUserResponse updateUserStorageQuota(Long userId, long storageQuotaBytes) { + User user = getRequiredUser(userId); + user.setStorageQuotaBytes(storageQuotaBytes); + AdminUserResponse response = toUserResponse(userRepository.save(user)); + adminAuditService.record( + AdminAuditAction.UPDATE_USER_STORAGE_QUOTA, + "USER", + userId, + "Updated user storage quota", + Map.of("storageQuotaBytes", storageQuotaBytes) + ); + return response; + } + + @Transactional + public AdminUserResponse updateUserMaxUploadSize(Long userId, long maxUploadSizeBytes) { + User user = getRequiredUser(userId); + user.setMaxUploadSizeBytes(maxUploadSizeBytes); + AdminUserResponse response = toUserResponse(userRepository.save(user)); + adminAuditService.record( + AdminAuditAction.UPDATE_USER_MAX_UPLOAD_SIZE, + "USER", + userId, + "Updated user max upload size", + Map.of("maxUploadSizeBytes", maxUploadSizeBytes) + ); + return response; + } + + @Transactional + public AdminPasswordResetResponse resetUserPassword(Long userId) { + String temporaryPassword = generateTemporaryPassword(); + updateUserPasswordInternal(userId, temporaryPassword, AdminAuditAction.RESET_USER_PASSWORD); + return new AdminPasswordResetResponse(temporaryPassword); + } + + private AdminUserResponse updateUserPasswordInternal(Long userId, String newPassword, AdminAuditAction action) { + if (!PasswordPolicy.isStrong(newPassword)) { + throw new BusinessException(ErrorCode.UNKNOWN, PasswordPolicy.VALIDATION_MESSAGE); + } + User user = getRequiredUser(userId); + user.setPasswordHash(passwordEncoder.encode(newPassword)); + authTokenInvalidationService.revokeAccessTokensForUser(user.getId()); + authSessionPolicy.rotateAllActiveSessions(user); + refreshTokenService.revokeAllForUser(user.getId()); + AdminUserResponse response = toUserResponse(userRepository.save(user)); + Map details = new LinkedHashMap<>(); + details.put("passwordLength", newPassword.length()); + details.put("temporaryPassword", action == AdminAuditAction.RESET_USER_PASSWORD); + adminAuditService.record( + action, + "USER", + userId, + action == AdminAuditAction.RESET_USER_PASSWORD + ? "Reset user password" + : "Updated user password", + details + ); + return response; + } + + private AdminUserResponse toUserResponse(User user) { + long usedStorageBytes = storedFileRepository.sumFileSizeByUserId(user.getId()); + return new AdminUserResponse( + user.getId(), + user.getUsername(), + user.getEmail(), + user.getPhoneNumber(), + user.getCreatedAt(), + user.getRole(), + user.isBanned(), + usedStorageBytes, + user.getStorageQuotaBytes(), + user.getMaxUploadSizeBytes() + ); + } + + private User getRequiredUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "user not found")); + } + + private String normalizeQuery(String query) { + if (query == null) { + return ""; + } + return query.trim(); + } + + private String generateTemporaryPassword() { + String lowers = "abcdefghjkmnpqrstuvwxyz"; + String uppers = "ABCDEFGHJKMNPQRSTUVWXYZ"; + String digits = "23456789"; + String specials = "!@#$%^&*"; + String all = lowers + uppers + digits + specials; + char[] password = new char[12]; + password[0] = lowers.charAt(secureRandom.nextInt(lowers.length())); + password[1] = uppers.charAt(secureRandom.nextInt(uppers.length())); + password[2] = digits.charAt(secureRandom.nextInt(digits.length())); + password[3] = specials.charAt(secureRandom.nextInt(specials.length())); + for (int i = 4; i < password.length; i += 1) { + password[i] = all.charAt(secureRandom.nextInt(all.length())); + } + for (int i = password.length - 1; i > 0; i -= 1) { + int j = secureRandom.nextInt(i + 1); + char tmp = password[i]; + password[i] = password[j]; + password[j] = tmp; + } + return new String(password); + } +} diff --git a/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionRuntimeStateV2Response.java b/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionRuntimeStateV2Response.java new file mode 100644 index 0000000..78d4ed1 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionRuntimeStateV2Response.java @@ -0,0 +1,13 @@ +package com.yoyuzh.api.v2.files; + +import java.time.LocalDateTime; + +public record UploadSessionRuntimeStateV2Response( + String phase, + long uploadedBytes, + int uploadedPartCount, + Integer progressPercent, + LocalDateTime lastUpdatedAt, + LocalDateTime expiresAt +) { +} diff --git a/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Controller.java b/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Controller.java index d956df5..c2c56d8 100644 --- a/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Controller.java +++ b/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Controller.java @@ -4,6 +4,7 @@ import com.yoyuzh.api.v2.ApiV2Response; import com.yoyuzh.auth.CustomUserDetailsService; import com.yoyuzh.auth.User; import com.yoyuzh.files.upload.UploadSession; +import com.yoyuzh.files.upload.UploadSessionRuntimeState; import com.yoyuzh.files.upload.UploadSessionCreateCommand; import com.yoyuzh.files.upload.UploadSessionUploadMode; import com.yoyuzh.files.upload.UploadSessionPartCommand; @@ -142,10 +143,24 @@ public class UploadSessionV2Controller { session.getExpiresAt(), session.getCreatedAt(), session.getUpdatedAt(), + uploadSessionService.getRuntimeState(session.getSessionId()) + .map(this::toRuntimeResponse) + .orElse(null), toStrategyResponse(session.getSessionId(), uploadMode) ); } + private UploadSessionRuntimeStateV2Response toRuntimeResponse(UploadSessionRuntimeState runtimeState) { + return new UploadSessionRuntimeStateV2Response( + runtimeState.phase(), + runtimeState.uploadedBytes(), + runtimeState.uploadedPartCount(), + runtimeState.progressPercent(), + runtimeState.lastUpdatedAt(), + runtimeState.expiresAt() + ); + } + private UploadSessionV2StrategyResponse toStrategyResponse(String sessionId, UploadSessionUploadMode uploadMode) { String sessionBasePath = "/api/v2/files/upload-sessions/" + sessionId; return switch (uploadMode) { diff --git a/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Response.java b/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Response.java index 2641dd6..67e1b7f 100644 --- a/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Response.java +++ b/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Response.java @@ -19,6 +19,7 @@ public record UploadSessionV2Response( LocalDateTime expiresAt, LocalDateTime createdAt, LocalDateTime updatedAt, + UploadSessionRuntimeStateV2Response runtime, UploadSessionV2StrategyResponse strategy ) { } diff --git a/backend/src/main/java/com/yoyuzh/api/v2/tasks/BackgroundTaskV2Controller.java b/backend/src/main/java/com/yoyuzh/api/v2/tasks/BackgroundTaskV2Controller.java index 402f907..878914e 100644 --- a/backend/src/main/java/com/yoyuzh/api/v2/tasks/BackgroundTaskV2Controller.java +++ b/backend/src/main/java/com/yoyuzh/api/v2/tasks/BackgroundTaskV2Controller.java @@ -5,7 +5,7 @@ import com.yoyuzh.auth.CustomUserDetailsService; import com.yoyuzh.auth.User; import com.yoyuzh.common.PageResponse; import com.yoyuzh.files.tasks.BackgroundTask; -import com.yoyuzh.files.tasks.BackgroundTaskService; +import com.yoyuzh.files.tasks.BackgroundTaskCommandService; import com.yoyuzh.files.tasks.BackgroundTaskType; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; @@ -29,7 +29,7 @@ import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor public class BackgroundTaskV2Controller { - private final BackgroundTaskService backgroundTaskService; + private final BackgroundTaskCommandService backgroundTaskCommandService; private final CustomUserDetailsService userDetailsService; @GetMapping @@ -37,7 +37,7 @@ public class BackgroundTaskV2Controller { @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { User user = userDetailsService.loadDomainUser(userDetails.getUsername()); - var result = backgroundTaskService.listOwnedTasks( + var result = backgroundTaskCommandService.listOwnedTasks( user, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) ); @@ -53,21 +53,21 @@ public class BackgroundTaskV2Controller { public ApiV2Response get(@AuthenticationPrincipal UserDetails userDetails, @PathVariable Long id) { User user = userDetailsService.loadDomainUser(userDetails.getUsername()); - return ApiV2Response.success(toResponse(backgroundTaskService.getOwnedTask(user, id))); + return ApiV2Response.success(toResponse(backgroundTaskCommandService.getOwnedTask(user, id))); } @DeleteMapping("/{id}") public ApiV2Response cancel(@AuthenticationPrincipal UserDetails userDetails, @PathVariable Long id) { User user = userDetailsService.loadDomainUser(userDetails.getUsername()); - return ApiV2Response.success(toResponse(backgroundTaskService.cancelOwnedTask(user, id))); + return ApiV2Response.success(toResponse(backgroundTaskCommandService.cancelOwnedTask(user, id))); } @PostMapping("/{id}/retry") public ApiV2Response retry(@AuthenticationPrincipal UserDetails userDetails, @PathVariable Long id) { User user = userDetailsService.loadDomainUser(userDetails.getUsername()); - return ApiV2Response.success(toResponse(backgroundTaskService.retryOwnedTask(user, id))); + return ApiV2Response.success(toResponse(backgroundTaskCommandService.retryOwnedTask(user, id))); } @PostMapping("/archive") @@ -92,7 +92,7 @@ public class BackgroundTaskV2Controller { BackgroundTaskType type, CreateBackgroundTaskRequest request) { User user = userDetailsService.loadDomainUser(userDetails.getUsername()); - BackgroundTask task = backgroundTaskService.createQueuedFileTask( + BackgroundTask task = backgroundTaskCommandService.createQueuedFileTask( user, type, request.fileId(), diff --git a/backend/src/main/java/com/yoyuzh/auth/AuthService.java b/backend/src/main/java/com/yoyuzh/auth/AuthService.java index fa3ce87..675a946 100644 --- a/backend/src/main/java/com/yoyuzh/auth/AuthService.java +++ b/backend/src/main/java/com/yoyuzh/auth/AuthService.java @@ -44,6 +44,8 @@ public class AuthService { private final AuthenticationManager authenticationManager; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenService refreshTokenService; + private final AuthTokenInvalidationService authTokenInvalidationService; + private final AuthSessionPolicy authSessionPolicy; private final FileService fileService; private final FileContentStorage fileContentStorage; private final RegistrationInviteService registrationInviteService; @@ -115,13 +117,20 @@ public class AuthService { } final String finalCandidate = candidate; - User user = userRepository.findByUsername(finalCandidate).orElseGet(() -> { + UserRole desiredRole = resolveDevLoginRole(finalCandidate); + User user = userRepository.findByUsername(finalCandidate).map(existing -> { + if (existing.getRole() != desiredRole) { + existing.setRole(desiredRole); + return userRepository.save(existing); + } + return existing; + }).orElseGet(() -> { User created = new User(); created.setUsername(finalCandidate); created.setDisplayName(finalCandidate); created.setEmail(finalCandidate + "@dev.local"); created.setPasswordHash(passwordEncoder.encode("1")); - created.setRole(UserRole.USER); + created.setRole(desiredRole); created.setPreferredLanguage("zh-CN"); return userRepository.save(created); }); @@ -291,6 +300,7 @@ public class AuthService { } private AuthResponse issueFreshTokens(User user, AuthClientType clientType) { + authTokenInvalidationService.revokeAccessTokensForUser(user.getId(), clientType); refreshTokenService.revokeAllForUser(user.getId(), clientType); return issueTokens(user, refreshTokenService.issueRefreshToken(user, clientType), clientType); } @@ -300,31 +310,20 @@ public class AuthService { String accessToken = jwtTokenProvider.generateAccessToken( sessionUser.getId(), sessionUser.getUsername(), - getActiveSessionId(sessionUser, clientType), + authSessionPolicy.getActiveSessionId(sessionUser, clientType), clientType ); return AuthResponse.issued(accessToken, refreshToken, toProfile(sessionUser)); } private User rotateActiveSession(User user, AuthClientType clientType) { - String nextSessionId = UUID.randomUUID().toString(); - if (clientType == AuthClientType.MOBILE) { - user.setMobileActiveSessionId(nextSessionId); - } else { - user.setDesktopActiveSessionId(nextSessionId); - user.setActiveSessionId(nextSessionId); - } + authSessionPolicy.rotateActiveSession(user, clientType); return userRepository.save(user); } private void rotateAllActiveSessions(User user) { - user.setActiveSessionId(UUID.randomUUID().toString()); - user.setDesktopActiveSessionId(UUID.randomUUID().toString()); - user.setMobileActiveSessionId(UUID.randomUUID().toString()); - } - - private String getActiveSessionId(User user, AuthClientType clientType) { - return clientType == AuthClientType.MOBILE ? user.getMobileActiveSessionId() : user.getDesktopActiveSessionId(); + authTokenInvalidationService.revokeAccessTokensForUser(user.getId()); + authSessionPolicy.rotateAllActiveSessions(user); } private String normalizeOptionalText(String value) { @@ -335,6 +334,16 @@ public class AuthService { return trimmed.isEmpty() ? null : trimmed; } + private UserRole resolveDevLoginRole(String username) { + if ("admin".equalsIgnoreCase(username)) { + return UserRole.ADMIN; + } + if ("operator".equalsIgnoreCase(username) || "moderator".equalsIgnoreCase(username)) { + return UserRole.MODERATOR; + } + return UserRole.USER; + } + private String normalizePreferredLanguage(String preferredLanguage) { if (preferredLanguage == null || preferredLanguage.trim().isEmpty()) { return "zh-CN"; diff --git a/backend/src/main/java/com/yoyuzh/auth/AuthSessionPolicy.java b/backend/src/main/java/com/yoyuzh/auth/AuthSessionPolicy.java new file mode 100644 index 0000000..b8217bb --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/auth/AuthSessionPolicy.java @@ -0,0 +1,35 @@ +package com.yoyuzh.auth; + +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +public class AuthSessionPolicy { + + public void rotateActiveSession(User user, AuthClientType clientType) { + String nextSessionId = nextSessionId(); + if (clientType == AuthClientType.MOBILE) { + user.setMobileActiveSessionId(nextSessionId); + return; + } + user.setDesktopActiveSessionId(nextSessionId); + user.setActiveSessionId(nextSessionId); + } + + public void rotateAllActiveSessions(User user) { + user.setActiveSessionId(nextSessionId()); + user.setDesktopActiveSessionId(nextSessionId()); + user.setMobileActiveSessionId(nextSessionId()); + } + + public String getActiveSessionId(User user, AuthClientType clientType) { + return clientType == AuthClientType.MOBILE + ? user.getMobileActiveSessionId() + : user.getDesktopActiveSessionId(); + } + + private String nextSessionId() { + return UUID.randomUUID().toString(); + } +} diff --git a/backend/src/main/java/com/yoyuzh/auth/AuthTokenInvalidationService.java b/backend/src/main/java/com/yoyuzh/auth/AuthTokenInvalidationService.java new file mode 100644 index 0000000..448191d --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/auth/AuthTokenInvalidationService.java @@ -0,0 +1,108 @@ +package com.yoyuzh.auth; + +import com.yoyuzh.config.AppRedisProperties; +import com.yoyuzh.config.JwtProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.Duration; +import java.time.Instant; + +@Service +@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true") +public class AuthTokenInvalidationService { + + private final StringRedisTemplate redisTemplate; + private final AppRedisProperties redisProperties; + private final JwtProperties jwtProperties; + + public AuthTokenInvalidationService(StringRedisTemplate redisTemplate, + AppRedisProperties redisProperties, + JwtProperties jwtProperties) { + this.redisTemplate = redisTemplate; + this.redisProperties = redisProperties; + this.jwtProperties = jwtProperties; + } + + public void revokeAccessTokensForUser(Long userId) { + revokeAccessTokensForUser(userId, AuthClientType.DESKTOP); + revokeAccessTokensForUser(userId, AuthClientType.MOBILE); + } + + public void revokeAccessTokensForUser(Long userId, AuthClientType clientType) { + if (userId == null || clientType == null) { + return; + } + redisTemplate.opsForValue().set( + buildAccessInvalidationKey(userId, clientType), + Long.toString(Instant.now().getEpochSecond()), + Duration.ofSeconds(jwtProperties.getAccessExpirationSeconds() + redisProperties.getTtlBufferSeconds()) + ); + } + + public boolean isAccessTokenRevoked(Long userId, AuthClientType clientType, Instant issuedAt) { + if (userId == null || clientType == null || issuedAt == null) { + return false; + } + String rawValue = redisTemplate.opsForValue().get(buildAccessInvalidationKey(userId, clientType)); + if (!StringUtils.hasText(rawValue)) { + return false; + } + long revokedBeforeEpochSecond = normalizeRevokedBefore(rawValue); + if (revokedBeforeEpochSecond <= 0L) { + return false; + } + return issuedAt.getEpochSecond() < revokedBeforeEpochSecond; + } + + public void blacklistRefreshTokenHash(String tokenHash, Instant expiresAt) { + if (!StringUtils.hasText(tokenHash) || expiresAt == null) { + return; + } + Duration ttl = Duration.between(Instant.now(), expiresAt) + .plusSeconds(redisProperties.getTtlBufferSeconds()); + if (ttl.isNegative() || ttl.isZero()) { + ttl = Duration.ofSeconds(redisProperties.getTtlBufferSeconds()); + } + redisTemplate.opsForValue().set(buildRefreshTokenBlacklistKey(tokenHash), "1", ttl); + } + + public boolean isRefreshTokenHashBlacklisted(String tokenHash) { + if (!StringUtils.hasText(tokenHash)) { + return false; + } + return Boolean.TRUE.equals(redisTemplate.hasKey(buildRefreshTokenBlacklistKey(tokenHash))); + } + + private String buildAccessInvalidationKey(Long userId, AuthClientType clientType) { + return buildAuthKey("access-revoked-before", userId.toString(), clientType.name()); + } + + private String buildRefreshTokenBlacklistKey(String tokenHash) { + return buildAuthKey("refresh-blacklist", tokenHash); + } + + private long normalizeRevokedBefore(String rawValue) { + long parsed = Long.parseLong(rawValue); + if (parsed > 9_999_999_999L) { + return parsed / 1000L; + } + return parsed; + } + + private String buildAuthKey(String... segments) { + StringBuilder builder = new StringBuilder(); + builder.append(redisProperties.getKeyPrefix()) + .append(':') + .append(redisProperties.getNamespaces().getAuth()); + for (String segment : segments) { + if (!StringUtils.hasText(segment)) { + continue; + } + builder.append(':').append(segment.trim()); + } + return builder.toString(); + } +} diff --git a/backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java b/backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java index c8ad2ef..b73112e 100644 --- a/backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java +++ b/backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java @@ -79,6 +79,11 @@ public class JwtTokenProvider { return uid == null ? null : Long.parseLong(uid.toString()); } + public Instant getIssuedAt(String token) { + Date issuedAt = parseClaims(token).getIssuedAt(); + return issuedAt == null ? null : issuedAt.toInstant(); + } + public String getSessionId(String token) { Object sessionId = parseClaims(token).get("sid"); return sessionId == null ? null : sessionId.toString(); diff --git a/backend/src/main/java/com/yoyuzh/auth/NoOpAuthTokenInvalidationService.java b/backend/src/main/java/com/yoyuzh/auth/NoOpAuthTokenInvalidationService.java new file mode 100644 index 0000000..f6c0397 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/auth/NoOpAuthTokenInvalidationService.java @@ -0,0 +1,37 @@ +package com.yoyuzh.auth; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.time.Instant; + +@Service +@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "false", matchIfMissing = true) +public class NoOpAuthTokenInvalidationService extends AuthTokenInvalidationService { + + public NoOpAuthTokenInvalidationService() { + super(null, null, null); + } + + @Override + public void revokeAccessTokensForUser(Long userId) { + } + + @Override + public void revokeAccessTokensForUser(Long userId, AuthClientType clientType) { + } + + @Override + public boolean isAccessTokenRevoked(Long userId, AuthClientType clientType, Instant issuedAt) { + return false; + } + + @Override + public void blacklistRefreshTokenHash(String tokenHash, Instant expiresAt) { + } + + @Override + public boolean isRefreshTokenHashBlacklisted(String tokenHash) { + return false; + } +} diff --git a/backend/src/main/java/com/yoyuzh/auth/RefreshTokenRepository.java b/backend/src/main/java/com/yoyuzh/auth/RefreshTokenRepository.java index 686f9d6..cd8b874 100644 --- a/backend/src/main/java/com/yoyuzh/auth/RefreshTokenRepository.java +++ b/backend/src/main/java/com/yoyuzh/auth/RefreshTokenRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; public interface RefreshTokenRepository extends JpaRepository { @@ -34,4 +35,19 @@ public interface RefreshTokenRepository extends JpaRepository :now + """) + List findActiveByUserId(@Param("userId") Long userId, @Param("now") LocalDateTime now); + + @Query(""" + select token from RefreshToken token + where token.user.id = :userId and token.revoked = false and token.expiresAt > :now + and (token.clientType = :clientType or (:clientType = 'DESKTOP' and token.clientType is null)) + """) + List findActiveByUserIdAndClientType(@Param("userId") Long userId, + @Param("clientType") String clientType, + @Param("now") LocalDateTime now); } diff --git a/backend/src/main/java/com/yoyuzh/auth/RefreshTokenService.java b/backend/src/main/java/com/yoyuzh/auth/RefreshTokenService.java index e11b738..40d00ef 100644 --- a/backend/src/main/java/com/yoyuzh/auth/RefreshTokenService.java +++ b/backend/src/main/java/com/yoyuzh/auth/RefreshTokenService.java @@ -11,9 +11,12 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.Base64; import java.util.HexFormat; +import java.util.List; @Service @RequiredArgsConstructor @@ -23,6 +26,7 @@ public class RefreshTokenService { private final RefreshTokenRepository refreshTokenRepository; private final JwtProperties jwtProperties; + private final AuthTokenInvalidationService authTokenInvalidationService; private final SecureRandom secureRandom = new SecureRandom(); @Transactional @@ -47,7 +51,12 @@ public class RefreshTokenService { @Transactional(noRollbackFor = BusinessException.class) public RotatedRefreshToken rotateRefreshToken(String rawToken) { - RefreshToken existing = refreshTokenRepository.findForUpdateByTokenHash(hashToken(rawToken)) + String tokenHash = hashToken(rawToken); + if (authTokenInvalidationService.isRefreshTokenHashBlacklisted(tokenHash)) { + throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "刷新令牌无效或已使用"); + } + + RefreshToken existing = refreshTokenRepository.findForUpdateByTokenHash(tokenHash) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "刷新令牌无效")); if (existing.isRevoked()) { @@ -56,12 +65,14 @@ public class RefreshTokenService { if (existing.getExpiresAt().isBefore(LocalDateTime.now())) { existing.revoke(LocalDateTime.now()); + authTokenInvalidationService.blacklistRefreshTokenHash(existing.getTokenHash(), toInstant(existing.getExpiresAt())); throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "刷新令牌已过期"); } User user = existing.getUser(); AuthClientType clientType = AuthClientType.fromHeader(existing.getClientType()); existing.revoke(LocalDateTime.now()); + authTokenInvalidationService.blacklistRefreshTokenHash(existing.getTokenHash(), toInstant(existing.getExpiresAt())); revokeAllForUser(user.getId(), clientType); String nextRefreshToken = issueRefreshToken(user, clientType); @@ -70,12 +81,18 @@ public class RefreshTokenService { @Transactional public void revokeAllForUser(Long userId) { - refreshTokenRepository.revokeAllActiveByUserId(userId, LocalDateTime.now()); + LocalDateTime now = LocalDateTime.now(); + List tokens = refreshTokenRepository.findActiveByUserId(userId, now); + refreshTokenRepository.revokeAllActiveByUserId(userId, now); + blacklistRefreshTokens(tokens); } @Transactional public void revokeAllForUser(Long userId, AuthClientType clientType) { - refreshTokenRepository.revokeAllActiveByUserIdAndClientType(userId, clientType.name(), LocalDateTime.now()); + LocalDateTime now = LocalDateTime.now(); + List tokens = refreshTokenRepository.findActiveByUserIdAndClientType(userId, clientType.name(), now); + refreshTokenRepository.revokeAllActiveByUserIdAndClientType(userId, clientType.name(), now); + blacklistRefreshTokens(tokens); } private String generateRawToken() { @@ -97,6 +114,16 @@ public class RefreshTokenService { } } + private void blacklistRefreshTokens(List tokens) { + for (RefreshToken token : tokens) { + authTokenInvalidationService.blacklistRefreshTokenHash(token.getTokenHash(), toInstant(token.getExpiresAt())); + } + } + + private Instant toInstant(LocalDateTime dateTime) { + return dateTime.atZone(ZoneId.systemDefault()).toInstant(); + } + public record RotatedRefreshToken(User user, String refreshToken, AuthClientType clientType) { } } diff --git a/backend/src/main/java/com/yoyuzh/auth/RegistrationInviteService.java b/backend/src/main/java/com/yoyuzh/auth/RegistrationInviteService.java index de501fc..f16e871 100644 --- a/backend/src/main/java/com/yoyuzh/auth/RegistrationInviteService.java +++ b/backend/src/main/java/com/yoyuzh/auth/RegistrationInviteService.java @@ -28,6 +28,20 @@ public class RegistrationInviteService { return ensureCurrentState().getInviteCode(); } + @Transactional + public String updateCurrentInviteCode(String inviteCode) { + RegistrationInviteState state = ensureCurrentStateForUpdate(); + state.setInviteCode(requireValidInviteCode(inviteCode)); + return registrationInviteStateRepository.save(state).getInviteCode(); + } + + @Transactional + public String rotateCurrentInviteCode() { + RegistrationInviteState state = ensureCurrentStateForUpdate(); + state.setInviteCode(generateNextInviteCode(state.getInviteCode())); + return registrationInviteStateRepository.save(state).getInviteCode(); + } + @Transactional public void consumeInviteCode(String inviteCode) { RegistrationInviteState state = ensureCurrentStateForUpdate(); @@ -93,4 +107,15 @@ public class RegistrationInviteService { private String normalize(String value) { return value == null ? "" : value.trim(); } + + private String requireValidInviteCode(String inviteCode) { + String normalized = normalize(inviteCode); + if (!StringUtils.hasText(normalized)) { + throw new BusinessException(ErrorCode.UNKNOWN, "邀请码不能为空"); + } + if (normalized.length() > 64) { + throw new BusinessException(ErrorCode.UNKNOWN, "邀请码长度不能超过 64 个字符"); + } + return normalized; + } } diff --git a/backend/src/main/java/com/yoyuzh/auth/UserRole.java b/backend/src/main/java/com/yoyuzh/auth/UserRole.java index 0c1b828..fe8f185 100644 --- a/backend/src/main/java/com/yoyuzh/auth/UserRole.java +++ b/backend/src/main/java/com/yoyuzh/auth/UserRole.java @@ -3,5 +3,9 @@ package com.yoyuzh.auth; public enum UserRole { USER, MODERATOR, - ADMIN + ADMIN; + + public boolean canAccessAdmin() { + return this == MODERATOR || this == ADMIN; + } } diff --git a/backend/src/main/java/com/yoyuzh/common/broker/InMemoryLightweightBrokerService.java b/backend/src/main/java/com/yoyuzh/common/broker/InMemoryLightweightBrokerService.java new file mode 100644 index 0000000..2e54faf --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/common/broker/InMemoryLightweightBrokerService.java @@ -0,0 +1,44 @@ +package com.yoyuzh.common.broker; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Deque; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; + +@Service +@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "false", matchIfMissing = true) +public class InMemoryLightweightBrokerService implements LightweightBrokerService { + + private final ConcurrentHashMap>> queues = new ConcurrentHashMap<>(); + + @Override + public void publish(String topic, Map payload) { + queues.computeIfAbsent(topic, ignored -> new ConcurrentLinkedDeque<>()) + .offerLast(copyPayload(payload)); + } + + @Override + public Optional> poll(String topic) { + Deque> queue = queues.get(topic); + if (queue == null) { + return Optional.empty(); + } + Map payload = queue.pollFirst(); + return payload == null ? Optional.empty() : Optional.of(new LinkedHashMap<>(payload)); + } + + @Override + public void requeue(String topic, Map payload) { + queues.computeIfAbsent(topic, ignored -> new ConcurrentLinkedDeque<>()) + .offerFirst(copyPayload(payload)); + } + + private Map copyPayload(Map payload) { + return payload == null ? new LinkedHashMap<>() : new LinkedHashMap<>(payload); + } +} diff --git a/backend/src/main/java/com/yoyuzh/common/broker/LightweightBrokerService.java b/backend/src/main/java/com/yoyuzh/common/broker/LightweightBrokerService.java new file mode 100644 index 0000000..74b0597 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/common/broker/LightweightBrokerService.java @@ -0,0 +1,13 @@ +package com.yoyuzh.common.broker; + +import java.util.Map; +import java.util.Optional; + +public interface LightweightBrokerService { + + void publish(String topic, Map payload); + + Optional> poll(String topic); + + void requeue(String topic, Map payload); +} diff --git a/backend/src/main/java/com/yoyuzh/common/broker/RedisLightweightBrokerService.java b/backend/src/main/java/com/yoyuzh/common/broker/RedisLightweightBrokerService.java new file mode 100644 index 0000000..8138c9a --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/common/broker/RedisLightweightBrokerService.java @@ -0,0 +1,87 @@ +package com.yoyuzh.common.broker; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.config.AppRedisProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +@Service +@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true") +public class RedisLightweightBrokerService implements LightweightBrokerService { + + private static final Logger log = LoggerFactory.getLogger(RedisLightweightBrokerService.class); + + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper; + private final AppRedisProperties redisProperties; + + public RedisLightweightBrokerService(StringRedisTemplate stringRedisTemplate, + ObjectMapper objectMapper, + AppRedisProperties redisProperties) { + this.stringRedisTemplate = stringRedisTemplate; + this.objectMapper = objectMapper; + this.redisProperties = redisProperties; + } + + @Override + public void publish(String topic, Map payload) { + stringRedisTemplate.opsForList().rightPush(buildQueueKey(topic), toJson(payload)); + } + + @Override + public Optional> poll(String topic) { + String queueKey = buildQueueKey(topic); + while (true) { + String payload = stringRedisTemplate.opsForList().leftPop(queueKey); + if (!StringUtils.hasText(payload)) { + return Optional.empty(); + } + try { + return Optional.of(parsePayload(payload)); + } catch (IllegalStateException ex) { + log.warn("Dropping malformed broker payload for topic {}", topic, ex); + } + } + } + + @Override + public void requeue(String topic, Map payload) { + stringRedisTemplate.opsForList().leftPush(buildQueueKey(topic), toJson(payload)); + } + + private String buildQueueKey(String topic) { + return redisProperties.getKeyPrefix() + + ":" + + redisProperties.getNamespaces().getBroker() + + ":" + + topic + + ":queue"; + } + + private String toJson(Map payload) { + try { + return objectMapper.writeValueAsString(payload == null ? Map.of() : payload); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("Failed to serialize broker payload", ex); + } + } + + private Map parsePayload(String payload) { + try { + return objectMapper.readValue(payload, new TypeReference>() { + }); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("Failed to parse broker payload", ex); + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/common/lock/DistributedLockService.java b/backend/src/main/java/com/yoyuzh/common/lock/DistributedLockService.java new file mode 100644 index 0000000..aec5178 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/common/lock/DistributedLockService.java @@ -0,0 +1,32 @@ +package com.yoyuzh.common.lock; + +import java.time.Duration; +import java.util.function.Supplier; + +public interface DistributedLockService { + + T executeWithLock(String lockName, Duration ttl, Supplier action); + + default void runWithLock(String lockName, Duration ttl, Runnable action) { + executeWithLock(lockName, ttl, () -> { + action.run(); + return null; + }); + } + + static DistributedLockService noOp() { + return NoOpHolder.INSTANCE; + } + + final class NoOpHolder { + private static final DistributedLockService INSTANCE = new DistributedLockService() { + @Override + public T executeWithLock(String lockName, Duration ttl, Supplier action) { + return action.get(); + } + }; + + private NoOpHolder() { + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/common/lock/NoOpDistributedLockService.java b/backend/src/main/java/com/yoyuzh/common/lock/NoOpDistributedLockService.java new file mode 100644 index 0000000..0044e46 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/common/lock/NoOpDistributedLockService.java @@ -0,0 +1,17 @@ +package com.yoyuzh.common.lock; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.function.Supplier; + +@Service +@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "false", matchIfMissing = true) +public class NoOpDistributedLockService implements DistributedLockService { + + @Override + public T executeWithLock(String lockName, Duration ttl, Supplier action) { + return action.get(); + } +} diff --git a/backend/src/main/java/com/yoyuzh/common/lock/RedisDistributedLockService.java b/backend/src/main/java/com/yoyuzh/common/lock/RedisDistributedLockService.java new file mode 100644 index 0000000..cbce15e --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/common/lock/RedisDistributedLockService.java @@ -0,0 +1,63 @@ +package com.yoyuzh.common.lock; + +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import com.yoyuzh.config.AppRedisProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; + +@Service +@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true") +public class RedisDistributedLockService implements DistributedLockService { + + private static final DefaultRedisScript RELEASE_SCRIPT = new DefaultRedisScript<>( + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", + Long.class + ); + + private final StringRedisTemplate stringRedisTemplate; + private final AppRedisProperties redisProperties; + + public RedisDistributedLockService(StringRedisTemplate stringRedisTemplate, + AppRedisProperties redisProperties) { + this.stringRedisTemplate = stringRedisTemplate; + this.redisProperties = redisProperties; + } + + @Override + public T executeWithLock(String lockName, Duration ttl, Supplier action) { + if (!StringUtils.hasText(lockName)) { + return action.get(); + } + + String key = buildLockKey(lockName); + String ownerToken = UUID.randomUUID().toString(); + Duration effectiveTtl = ttl == null || ttl.isZero() || ttl.isNegative() + ? Duration.ofSeconds(60) + : ttl; + Boolean acquired = stringRedisTemplate.opsForValue().setIfAbsent(key, ownerToken, effectiveTtl); + if (!Boolean.TRUE.equals(acquired)) { + throw new BusinessException(ErrorCode.UNKNOWN, "操作正在处理中,请稍后重试"); + } + + try { + return action.get(); + } finally { + stringRedisTemplate.execute(RELEASE_SCRIPT, List.of(key), ownerToken); + } + } + + private String buildLockKey(String lockName) { + return redisProperties.getKeyPrefix() + + ":" + redisProperties.getNamespaces().getLocks() + + ":" + lockName.trim(); + } +} diff --git a/backend/src/main/java/com/yoyuzh/config/AndroidReleaseService.java b/backend/src/main/java/com/yoyuzh/config/AndroidReleaseService.java index 776a9cd..0b79ea5 100644 --- a/backend/src/main/java/com/yoyuzh/config/AndroidReleaseService.java +++ b/backend/src/main/java/com/yoyuzh/config/AndroidReleaseService.java @@ -5,6 +5,7 @@ import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.ErrorCode; import com.yoyuzh.files.storage.FileContentStorage; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import java.io.IOException; @@ -18,6 +19,7 @@ public class AndroidReleaseService { private final ObjectMapper objectMapper; private final AndroidReleaseProperties androidReleaseProperties; + @Cacheable(cacheNames = RedisCacheNames.ANDROID_RELEASE, key = "'latest'") public AndroidReleaseResponse getLatestRelease() { AndroidReleaseMetadata metadata = loadReleaseMetadata(); return new AndroidReleaseResponse( diff --git a/backend/src/main/java/com/yoyuzh/config/AppRedisProperties.java b/backend/src/main/java/com/yoyuzh/config/AppRedisProperties.java new file mode 100644 index 0000000..bf6b3fe --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/config/AppRedisProperties.java @@ -0,0 +1,159 @@ +package com.yoyuzh.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.redis") +public class AppRedisProperties { + + private boolean enabled; + private String keyPrefix = "yoyuzh"; + private long ttlBufferSeconds = 60; + private final Cache cache = new Cache(); + private final Namespaces namespaces = new Namespaces(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getKeyPrefix() { + return keyPrefix; + } + + public void setKeyPrefix(String keyPrefix) { + this.keyPrefix = keyPrefix; + } + + public long getTtlBufferSeconds() { + return ttlBufferSeconds; + } + + public void setTtlBufferSeconds(long ttlBufferSeconds) { + this.ttlBufferSeconds = ttlBufferSeconds; + } + + public Cache getCache() { + return cache; + } + + public Namespaces getNamespaces() { + return namespaces; + } + + public static class Cache { + private long filesListTtlSeconds = 60; + private long directoryVersionTtlSeconds = 3600; + private long adminSummaryTtlSeconds = 30; + private long storagePoliciesTtlSeconds = 300; + private long androidReleaseTtlSeconds = 60; + + public long getFilesListTtlSeconds() { + return filesListTtlSeconds; + } + + public void setFilesListTtlSeconds(long filesListTtlSeconds) { + this.filesListTtlSeconds = filesListTtlSeconds; + } + + public long getDirectoryVersionTtlSeconds() { + return directoryVersionTtlSeconds; + } + + public void setDirectoryVersionTtlSeconds(long directoryVersionTtlSeconds) { + this.directoryVersionTtlSeconds = directoryVersionTtlSeconds; + } + + public long getAdminSummaryTtlSeconds() { + return adminSummaryTtlSeconds; + } + + public void setAdminSummaryTtlSeconds(long adminSummaryTtlSeconds) { + this.adminSummaryTtlSeconds = adminSummaryTtlSeconds; + } + + public long getStoragePoliciesTtlSeconds() { + return storagePoliciesTtlSeconds; + } + + public void setStoragePoliciesTtlSeconds(long storagePoliciesTtlSeconds) { + this.storagePoliciesTtlSeconds = storagePoliciesTtlSeconds; + } + + public long getAndroidReleaseTtlSeconds() { + return androidReleaseTtlSeconds; + } + + public void setAndroidReleaseTtlSeconds(long androidReleaseTtlSeconds) { + this.androidReleaseTtlSeconds = androidReleaseTtlSeconds; + } + } + + public static class Namespaces { + private String cache = "cache"; + private String auth = "auth"; + private String transferSessions = "transfer-sessions"; + private String uploadState = "upload-state"; + private String locks = "locks"; + private String fileEvents = "file-events"; + private String broker = "broker"; + + public String getCache() { + return cache; + } + + public void setCache(String cache) { + this.cache = cache; + } + + public String getAuth() { + return auth; + } + + public void setAuth(String auth) { + this.auth = auth; + } + + public String getTransferSessions() { + return transferSessions; + } + + public void setTransferSessions(String transferSessions) { + this.transferSessions = transferSessions; + } + + public String getUploadState() { + return uploadState; + } + + public void setUploadState(String uploadState) { + this.uploadState = uploadState; + } + + public String getLocks() { + return locks; + } + + public void setLocks(String locks) { + this.locks = locks; + } + + public String getFileEvents() { + return fileEvents; + } + + public void setFileEvents(String fileEvents) { + this.fileEvents = fileEvents; + } + + public String getBroker() { + return broker; + } + + public void setBroker(String broker) { + this.broker = broker; + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java b/backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java index f7ca462..8b94c7e 100644 --- a/backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java @@ -1,6 +1,8 @@ package com.yoyuzh.config; import com.yoyuzh.admin.AdminMetricsService; +import com.yoyuzh.auth.AuthClientType; +import com.yoyuzh.auth.AuthTokenInvalidationService; import com.yoyuzh.auth.CustomUserDetailsService; import com.yoyuzh.auth.JwtTokenProvider; import com.yoyuzh.auth.User; @@ -24,6 +26,7 @@ import java.io.IOException; public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; + private final AuthTokenInvalidationService authTokenInvalidationService; private final CustomUserDetailsService userDetailsService; private final AdminMetricsService adminMetricsService; @@ -36,6 +39,15 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { String token = header.substring(7); if (jwtTokenProvider.validateToken(token) && SecurityContextHolder.getContext().getAuthentication() == null) { + Long userId = jwtTokenProvider.getUserId(token); + AuthClientType clientType = jwtTokenProvider.getClientType(token); + if (authTokenInvalidationService.isAccessTokenRevoked( + userId, + clientType, + jwtTokenProvider.getIssuedAt(token))) { + filterChain.doFilter(request, response); + return; + } String username = jwtTokenProvider.getUsername(token); User domainUser; try { diff --git a/backend/src/main/java/com/yoyuzh/config/RedisCacheNames.java b/backend/src/main/java/com/yoyuzh/config/RedisCacheNames.java new file mode 100644 index 0000000..2524af9 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/config/RedisCacheNames.java @@ -0,0 +1,21 @@ +package com.yoyuzh.config; + +import java.util.Set; + +public final class RedisCacheNames { + + public static final String FILES_LIST = "files:list"; + public static final String ADMIN_SUMMARY = "admin:summary"; + public static final String STORAGE_POLICIES = "admin:storage-policies"; + public static final String ANDROID_RELEASE = "android:release"; + + public static final Set ALL = Set.of( + FILES_LIST, + ADMIN_SUMMARY, + STORAGE_POLICIES, + ANDROID_RELEASE + ); + + private RedisCacheNames() { + } +} diff --git a/backend/src/main/java/com/yoyuzh/config/RedisConfiguration.java b/backend/src/main/java/com/yoyuzh/config/RedisConfiguration.java new file mode 100644 index 0000000..d7155bc --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/config/RedisConfiguration.java @@ -0,0 +1,73 @@ +package com.yoyuzh.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.support.NoOpCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +@Configuration +@EnableCaching +public class RedisConfiguration { + + @Bean + @ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true") + public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory, + ObjectMapper objectMapper, + AppRedisProperties redisProperties) { + RedisCacheConfiguration baseConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .computePrefixWith(cacheName -> redisProperties.getKeyPrefix() + + ":" + redisProperties.getNamespaces().getCache() + + ":" + cacheName + ":") + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer( + redisValueSerializer(objectMapper))) + .disableCachingNullValues(); + + Map cacheConfigurations = new HashMap<>(); + cacheConfigurations.put( + RedisCacheNames.FILES_LIST, + baseConfiguration.entryTtl(Duration.ofSeconds(redisProperties.getCache().getFilesListTtlSeconds())) + ); + cacheConfigurations.put( + RedisCacheNames.ADMIN_SUMMARY, + baseConfiguration.entryTtl(Duration.ofSeconds(redisProperties.getCache().getAdminSummaryTtlSeconds())) + ); + cacheConfigurations.put( + RedisCacheNames.STORAGE_POLICIES, + baseConfiguration.entryTtl(Duration.ofSeconds(redisProperties.getCache().getStoragePoliciesTtlSeconds())) + ); + cacheConfigurations.put( + RedisCacheNames.ANDROID_RELEASE, + baseConfiguration.entryTtl(Duration.ofSeconds(redisProperties.getCache().getAndroidReleaseTtlSeconds())) + ); + + return RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(baseConfiguration) + .withInitialCacheConfigurations(cacheConfigurations) + .initialCacheNames(RedisCacheNames.ALL) + .build(); + } + + @Bean + @ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "false", matchIfMissing = true) + public CacheManager noOpCacheManager() { + return new NoOpCacheManager(); + } + + static GenericJackson2JsonRedisSerializer redisValueSerializer(ObjectMapper objectMapper) { + return new GenericJackson2JsonRedisSerializer(objectMapper.copy()); + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/core/ContentAssetBindingService.java b/backend/src/main/java/com/yoyuzh/files/core/ContentAssetBindingService.java new file mode 100644 index 0000000..e4edbd4 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/core/ContentAssetBindingService.java @@ -0,0 +1,78 @@ +package com.yoyuzh.files.core; + +import com.yoyuzh.auth.User; +import com.yoyuzh.files.policy.StoragePolicyCapabilities; +import com.yoyuzh.files.policy.StoragePolicyService; + +import java.util.Optional; + +final class ContentAssetBindingService { + + private final FileEntityRepository fileEntityRepository; + private final StoredFileEntityRepository storedFileEntityRepository; + private final StoragePolicyService storagePolicyService; + + ContentAssetBindingService(FileEntityRepository fileEntityRepository, + StoredFileEntityRepository storedFileEntityRepository, + StoragePolicyService storagePolicyService) { + this.fileEntityRepository = fileEntityRepository; + this.storedFileEntityRepository = storedFileEntityRepository; + this.storagePolicyService = storagePolicyService; + } + + FileEntity createOrReferencePrimaryEntity(User user, FileBlob blob) { + if (fileEntityRepository == null) { + return createTransientPrimaryEntity(user, blob); + } + + Optional existingEntity = fileEntityRepository.findByObjectKeyAndEntityType( + blob.getObjectKey(), + FileEntityType.VERSION + ); + if (existingEntity.isPresent()) { + FileEntity entity = existingEntity.get(); + entity.setReferenceCount(entity.getReferenceCount() + 1); + return fileEntityRepository.save(entity); + } + + return fileEntityRepository.save(createTransientPrimaryEntity(user, blob)); + } + + StoragePolicyCapabilities resolveDefaultStoragePolicyCapabilities() { + if (storagePolicyService == null) { + return null; + } + return storagePolicyService.readCapabilities(storagePolicyService.ensureDefaultPolicy()); + } + + void savePrimaryEntityRelation(StoredFile storedFile, FileEntity primaryEntity) { + if (storedFileEntityRepository == null) { + return; + } + + StoredFileEntity relation = new StoredFileEntity(); + relation.setStoredFile(storedFile); + relation.setFileEntity(primaryEntity); + relation.setEntityRole("PRIMARY"); + storedFileEntityRepository.save(relation); + } + + private FileEntity createTransientPrimaryEntity(User user, FileBlob blob) { + FileEntity entity = new FileEntity(); + entity.setObjectKey(blob.getObjectKey()); + entity.setContentType(blob.getContentType()); + entity.setSize(blob.getSize()); + entity.setEntityType(FileEntityType.VERSION); + entity.setReferenceCount(1); + entity.setCreatedBy(user); + entity.setStoragePolicyId(resolveDefaultStoragePolicyId()); + return entity; + } + + private Long resolveDefaultStoragePolicyId() { + if (storagePolicyService == null) { + return null; + } + return storagePolicyService.ensureDefaultPolicy().getId(); + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/core/ContentBlobLifecycleService.java b/backend/src/main/java/com/yoyuzh/files/core/ContentBlobLifecycleService.java new file mode 100644 index 0000000..5d50577 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/core/ContentBlobLifecycleService.java @@ -0,0 +1,103 @@ +package com.yoyuzh.files.core; + +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import com.yoyuzh.files.storage.FileContentStorage; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +final class ContentBlobLifecycleService { + + private final StoredFileRepository storedFileRepository; + private final FileBlobRepository fileBlobRepository; + private final FileContentStorage fileContentStorage; + + ContentBlobLifecycleService(StoredFileRepository storedFileRepository, + FileBlobRepository fileBlobRepository, + FileContentStorage fileContentStorage) { + this.storedFileRepository = storedFileRepository; + this.fileBlobRepository = fileBlobRepository; + this.fileContentStorage = fileContentStorage; + } + + T executeAfterBlobStored(String objectKey, Supplier operation) { + try { + return operation.get(); + } catch (RuntimeException ex) { + try { + fileContentStorage.deleteBlob(objectKey); + } catch (RuntimeException cleanupEx) { + ex.addSuppressed(cleanupEx); + } + throw ex; + } + } + + void cleanupWrittenBlobs(List writtenBlobObjectKeys, RuntimeException ex) { + for (String objectKey : writtenBlobObjectKeys) { + try { + fileContentStorage.deleteBlob(objectKey); + } catch (RuntimeException cleanupEx) { + ex.addSuppressed(cleanupEx); + } + } + } + + FileBlob createAndSaveBlob(String objectKey, String contentType, long size) { + FileBlob blob = new FileBlob(); + blob.setObjectKey(objectKey); + blob.setContentType(contentType); + blob.setSize(size); + return fileBlobRepository.save(blob); + } + + FileBlob getRequiredBlob(StoredFile storedFile) { + if (storedFile.isDirectory() || storedFile.getBlob() == null) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件内容不存在"); + } + return storedFile.getBlob(); + } + + List collectBlobsToDelete(List filesToDelete) { + Map candidates = new HashMap<>(); + for (StoredFile file : filesToDelete) { + if (file.getBlob() == null || file.getBlob().getId() == null) { + continue; + } + BlobDeletionCandidate candidate = candidates.computeIfAbsent( + file.getBlob().getId(), + ignored -> new BlobDeletionCandidate(file.getBlob()) + ); + candidate.referencesToDelete += 1; + } + + List blobsToDelete = new ArrayList<>(); + for (BlobDeletionCandidate candidate : candidates.values()) { + long currentReferences = storedFileRepository.countByBlobId(candidate.blob.getId()); + if (currentReferences == candidate.referencesToDelete) { + blobsToDelete.add(candidate.blob); + } + } + return blobsToDelete; + } + + void deleteBlobs(List blobsToDelete) { + for (FileBlob blob : blobsToDelete) { + fileContentStorage.deleteBlob(blob.getObjectKey()); + fileBlobRepository.delete(blob); + } + } + + private static final class BlobDeletionCandidate { + private final FileBlob blob; + private long referencesToDelete; + + private BlobDeletionCandidate(FileBlob blob) { + this.blob = blob; + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/core/ExternalImportRulesService.java b/backend/src/main/java/com/yoyuzh/files/core/ExternalImportRulesService.java new file mode 100644 index 0000000..8cd8230 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/core/ExternalImportRulesService.java @@ -0,0 +1,75 @@ +package com.yoyuzh.files.core; + +import com.yoyuzh.auth.User; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import org.springframework.util.StringUtils; + +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +final class ExternalImportRulesService { + + private final WorkspaceNodeRulesService workspaceNodeRulesService; + private final FileUploadRulesService fileUploadRulesService; + + ExternalImportRulesService(WorkspaceNodeRulesService workspaceNodeRulesService, + FileUploadRulesService fileUploadRulesService) { + this.workspaceNodeRulesService = workspaceNodeRulesService; + this.fileUploadRulesService = fileUploadRulesService; + } + + List normalizeDirectories(List directories) { + if (directories == null || directories.isEmpty()) { + return List.of(); + } + return directories.stream() + .map(workspaceNodeRulesService::normalizeDirectoryPath) + .distinct() + .sorted(Comparator.comparingInt(String::length).thenComparing(String::compareTo)) + .toList(); + } + + List normalizeFiles(List files) { + if (files == null || files.isEmpty()) { + return List.of(); + } + return files.stream() + .map(file -> new FileService.ExternalFileImport( + workspaceNodeRulesService.normalizeDirectoryPath(file.path()), + workspaceNodeRulesService.normalizeLeafName(file.filename()), + StringUtils.hasText(file.contentType()) ? file.contentType().trim() : "application/octet-stream", + file.content() == null ? new byte[0] : file.content() + )) + .toList(); + } + + void validateBatch(User recipient, + List directories, + List files) { + fileUploadRulesService.ensureWithinStorageQuota(recipient, files.stream().mapToLong(FileService.ExternalFileImport::size).sum()); + + Set plannedTargets = new LinkedHashSet<>(); + for (String directory : directories) { + if ("/".equals(directory)) { + continue; + } + if (!plannedTargets.add(directory)) { + continue; + } + String parentPath = workspaceNodeRulesService.extractParentPath(directory); + String directoryName = workspaceNodeRulesService.extractLeafName(directory); + workspaceNodeRulesService.ensureNodeNameAvailable(recipient.getId(), parentPath, directoryName, "解压目标已存在"); + } + + for (FileService.ExternalFileImport file : files) { + String logicalPath = workspaceNodeRulesService.buildTargetLogicalPath(file.path(), file.filename()); + if (plannedTargets.contains(logicalPath) || !plannedTargets.add(logicalPath)) { + throw new BusinessException(ErrorCode.UNKNOWN, "解压目标已存在"); + } + workspaceNodeRulesService.ensureNodeNameAvailable(recipient.getId(), file.path(), file.filename(), "同目录下文件已存在"); + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/core/FileController.java b/backend/src/main/java/com/yoyuzh/files/core/FileController.java index ef17f5d..1bf7caf 100644 --- a/backend/src/main/java/com/yoyuzh/files/core/FileController.java +++ b/backend/src/main/java/com/yoyuzh/files/core/FileController.java @@ -1,11 +1,18 @@ package com.yoyuzh.files.core; +import com.yoyuzh.api.v2.ApiV2Exception; +import com.yoyuzh.api.v2.shares.CreateShareV2Request; +import com.yoyuzh.api.v2.shares.ImportShareV2Request; +import com.yoyuzh.api.v2.shares.ShareV2Response; import com.yoyuzh.auth.CustomUserDetailsService; import com.yoyuzh.common.ApiResponse; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; import com.yoyuzh.common.PageResponse; import com.yoyuzh.files.share.CreateFileShareLinkResponse; import com.yoyuzh.files.share.FileShareDetailsResponse; import com.yoyuzh.files.share.ImportSharedFileRequest; +import com.yoyuzh.files.share.ShareV2Service; import com.yoyuzh.files.upload.CompleteUploadRequest; import com.yoyuzh.files.upload.InitiateUploadRequest; import com.yoyuzh.files.upload.InitiateUploadResponse; @@ -37,6 +44,7 @@ public class FileController { private final FileService fileService; private final CustomUserDetailsService userDetailsService; + private final ShareV2Service shareV2Service; @Operation(summary = "上传文件") @PostMapping("/upload") @@ -147,16 +155,47 @@ public class FileController { @Operation(summary = "创建分享链接") @PostMapping("/{fileId}/share-links") public ApiResponse createShareLink(@AuthenticationPrincipal UserDetails userDetails, - @PathVariable Long fileId) { - return ApiResponse.success( - fileService.createShareLink(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId) - ); + @PathVariable Long fileId) { + try { + ShareV2Response response = shareV2Service.createShare( + userDetailsService.loadDomainUser(userDetails.getUsername()), + new CreateShareV2Request(fileId, null, null, null, null, null, null) + ); + if (response.file() == null) { + throw new BusinessException(ErrorCode.UNKNOWN, "share file metadata missing"); + } + return ApiResponse.success(new CreateFileShareLinkResponse( + response.token(), + response.file().filename(), + response.file().size(), + response.file().contentType(), + response.createdAt() + )); + } catch (ApiV2Exception ex) { + throw mapLegacyShareApiException(ex); + } } @Operation(summary = "查看分享详情") @GetMapping("/share-links/{token}") public ApiResponse getShareDetails(@PathVariable String token) { - return ApiResponse.success(fileService.getShareDetails(token)); + try { + ShareV2Response response = shareV2Service.getShare(token); + if (response.file() == null) { + throw new BusinessException(ErrorCode.PERMISSION_DENIED, "璇ュ垎浜摼鎺ラ渶瑕侀獙璇佸瘑鐮?"); + } + return ApiResponse.success(new FileShareDetailsResponse( + response.token(), + response.ownerUsername(), + response.file().filename(), + response.file().size(), + response.file().contentType(), + response.file().directory(), + response.createdAt() + )); + } catch (ApiV2Exception ex) { + throw mapLegacyShareApiException(ex); + } } @Operation(summary = "导入共享文件") @@ -164,13 +203,17 @@ public class FileController { public ApiResponse importSharedFile(@AuthenticationPrincipal UserDetails userDetails, @PathVariable String token, @Valid @RequestBody ImportSharedFileRequest request) { - return ApiResponse.success( - fileService.importSharedFile( - userDetailsService.loadDomainUser(userDetails.getUsername()), - token, - request.path() - ) - ); + try { + return ApiResponse.success( + shareV2Service.importSharedFile( + userDetailsService.loadDomainUser(userDetails.getUsername()), + token, + new ImportShareV2Request(request.path(), null) + ) + ); + } catch (ApiV2Exception ex) { + throw mapLegacyShareApiException(ex); + } } @Operation(summary = "删除文件") @@ -190,4 +233,14 @@ public class FileController { fileId )); } + + private BusinessException mapLegacyShareApiException(ApiV2Exception ex) { + ErrorCode code = switch (ex.getErrorCode()) { + case FILE_NOT_FOUND -> ErrorCode.FILE_NOT_FOUND; + case PERMISSION_DENIED -> ErrorCode.PERMISSION_DENIED; + case NOT_LOGGED_IN -> ErrorCode.NOT_LOGGED_IN; + case BAD_REQUEST, INTERNAL_ERROR -> ErrorCode.UNKNOWN; + }; + return new BusinessException(code, ex.getMessage()); + } } diff --git a/backend/src/main/java/com/yoyuzh/files/core/FileListDirectoryCacheService.java b/backend/src/main/java/com/yoyuzh/files/core/FileListDirectoryCacheService.java new file mode 100644 index 0000000..3441abc --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/core/FileListDirectoryCacheService.java @@ -0,0 +1,45 @@ +package com.yoyuzh.files.core; + +import com.yoyuzh.common.PageResponse; + +import java.util.Collection; +import java.util.function.Supplier; + +public interface FileListDirectoryCacheService { + + PageResponse getOrLoad(Long userId, + String path, + int page, + int size, + Supplier> loader); + + void touchDirectories(Long userId, Collection paths); + + default void touchDirectory(Long userId, String path) { + touchDirectories(userId, java.util.List.of(path)); + } + + static FileListDirectoryCacheService noOp() { + return NoOpHolder.INSTANCE; + } + + final class NoOpHolder { + private static final FileListDirectoryCacheService INSTANCE = new FileListDirectoryCacheService() { + @Override + public PageResponse getOrLoad(Long userId, + String path, + int page, + int size, + Supplier> loader) { + return loader.get(); + } + + @Override + public void touchDirectories(Long userId, Collection paths) { + } + }; + + private NoOpHolder() { + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/core/FileService.java b/backend/src/main/java/com/yoyuzh/files/core/FileService.java index a211a4d..f8221e3 100644 --- a/backend/src/main/java/com/yoyuzh/files/core/FileService.java +++ b/backend/src/main/java/com/yoyuzh/files/core/FileService.java @@ -5,6 +5,7 @@ import com.yoyuzh.auth.User; import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.ErrorCode; import com.yoyuzh.common.PageResponse; +import com.yoyuzh.common.lock.DistributedLockService; import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.files.events.FileEventService; import com.yoyuzh.files.events.FileEventType; @@ -17,6 +18,7 @@ import com.yoyuzh.files.share.FileShareLink; import com.yoyuzh.files.share.FileShareLinkRepository; import com.yoyuzh.files.storage.FileContentStorage; import com.yoyuzh.files.storage.PreparedUpload; +import com.yoyuzh.files.tasks.MediaMetadataTaskBrokerPublisher; import com.yoyuzh.files.upload.CompleteUploadRequest; import com.yoyuzh.files.upload.InitiateUploadRequest; import com.yoyuzh.files.upload.InitiateUploadResponse; @@ -40,6 +42,7 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; import java.util.ArrayList; @@ -64,7 +67,6 @@ public class FileService { private static final long RECYCLE_BIN_RETENTION_DAYS = 10L; private final StoredFileRepository storedFileRepository; - private final FileBlobRepository fileBlobRepository; private final FileEntityRepository fileEntityRepository; private final StoredFileEntityRepository storedFileEntityRepository; private final FileContentStorage fileContentStorage; @@ -76,8 +78,19 @@ public class FileService { private final String packageDownloadSecret; private final long packageDownloadTtlSeconds; private final Clock clock; + private final WorkspaceNodeRulesService workspaceNodeRulesService; + private final FileUploadRulesService fileUploadRulesService; + private final ExternalImportRulesService externalImportRulesService; + private final ContentAssetBindingService contentAssetBindingService; + private final ContentBlobLifecycleService contentBlobLifecycleService; @Autowired(required = false) private FileEventService fileEventService; + @Autowired(required = false) + private FileListDirectoryCacheService fileListDirectoryCacheService = FileListDirectoryCacheService.noOp(); + @Autowired(required = false) + private DistributedLockService distributedLockService = DistributedLockService.noOp(); + @Autowired(required = false) + private MediaMetadataTaskBrokerPublisher mediaMetadataTaskBrokerPublisher; @Autowired public FileService(StoredFileRepository storedFileRepository, @@ -103,7 +116,6 @@ public class FileService { FileStorageProperties properties, Clock clock) { this.storedFileRepository = storedFileRepository; - this.fileBlobRepository = fileBlobRepository; this.fileEntityRepository = fileEntityRepository; this.storedFileEntityRepository = storedFileEntityRepository; this.fileContentStorage = fileContentStorage; @@ -119,6 +131,11 @@ public class FileService { : null; this.packageDownloadTtlSeconds = Math.max(1, properties.getS3().getPackageDownloadTtlSeconds()); this.clock = clock; + this.workspaceNodeRulesService = new WorkspaceNodeRulesService(storedFileRepository, fileContentStorage); + this.fileUploadRulesService = new FileUploadRulesService(storedFileRepository, storagePolicyService, workspaceNodeRulesService, maxFileSize); + this.externalImportRulesService = new ExternalImportRulesService(workspaceNodeRulesService, fileUploadRulesService); + this.contentAssetBindingService = new ContentAssetBindingService(fileEntityRepository, storedFileEntityRepository, storagePolicyService); + this.contentBlobLifecycleService = new ContentBlobLifecycleService(storedFileRepository, fileBlobRepository, fileContentStorage); } FileService(StoredFileRepository storedFileRepository, @@ -144,13 +161,13 @@ public class FileService { public FileMetadataResponse upload(User user, String path, MultipartFile multipartFile) { String normalizedPath = normalizeDirectoryPath(path); String filename = normalizeUploadFilename(multipartFile.getOriginalFilename()); - validateUpload(user, normalizedPath, filename, multipartFile.getSize()); + fileUploadRulesService.validateUpload(user, normalizedPath, filename, multipartFile.getSize()); ensureDirectoryHierarchy(user, normalizedPath); String objectKey = createBlobObjectKey(); - return executeAfterBlobStored(objectKey, () -> { + return contentBlobLifecycleService.executeAfterBlobStored(objectKey, () -> { fileContentStorage.uploadBlob(objectKey, multipartFile); - FileBlob blob = createAndSaveBlob(objectKey, multipartFile.getContentType(), multipartFile.getSize()); + FileBlob blob = contentBlobLifecycleService.createAndSaveBlob(objectKey, multipartFile.getContentType(), multipartFile.getSize()); return saveFileMetadata(user, normalizedPath, filename, multipartFile.getContentType(), multipartFile.getSize(), blob); }); } @@ -158,10 +175,10 @@ public class FileService { public InitiateUploadResponse initiateUpload(User user, InitiateUploadRequest request) { String normalizedPath = normalizeDirectoryPath(request.path()); String filename = normalizeLeafName(request.filename()); - validateUpload(user, normalizedPath, filename, request.size()); + fileUploadRulesService.validateUpload(user, normalizedPath, filename, request.size()); String objectKey = createBlobObjectKey(); - StoragePolicyCapabilities capabilities = resolveDefaultStoragePolicyCapabilities(); + StoragePolicyCapabilities capabilities = contentAssetBindingService.resolveDefaultStoragePolicyCapabilities(); if (capabilities != null && !capabilities.directUpload()) { return new InitiateUploadResponse(false, "", "POST", Map.of(), objectKey); } @@ -187,12 +204,12 @@ public class FileService { String normalizedPath = normalizeDirectoryPath(request.path()); String filename = normalizeLeafName(request.filename()); String objectKey = normalizeBlobObjectKey(request.storageName()); - validateUpload(user, normalizedPath, filename, request.size()); + fileUploadRulesService.validateUpload(user, normalizedPath, filename, request.size()); ensureDirectoryHierarchy(user, normalizedPath); - return executeAfterBlobStored(objectKey, () -> { + return contentBlobLifecycleService.executeAfterBlobStored(objectKey, () -> { fileContentStorage.completeBlobUpload(objectKey, request.contentType(), request.size()); - FileBlob blob = createAndSaveBlob(objectKey, request.contentType(), request.size()); + FileBlob blob = contentBlobLifecycleService.createAndSaveBlob(objectKey, request.contentType(), request.size()); return saveFileMetadata(user, normalizedPath, filename, request.contentType(), request.size(), blob); }); } @@ -205,9 +222,7 @@ public class FileService { } String parentPath = extractParentPath(normalizedPath); String directoryName = extractLeafName(normalizedPath); - if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), parentPath, directoryName)) { - throw new BusinessException(ErrorCode.UNKNOWN, "目录已存在"); - } + workspaceNodeRulesService.ensureNodeNameAvailable(user.getId(), parentPath, directoryName, "目录已存在"); fileContentStorage.createDirectory(user.getId(), normalizedPath); @@ -215,18 +230,23 @@ public class FileService { storedFile.setUser(user); storedFile.setFilename(directoryName); storedFile.setPath(parentPath); + storedFile.setLegacyStorageName(directoryName); storedFile.setContentType("directory"); storedFile.setSize(0L); storedFile.setDirectory(true); - return toResponse(storedFileRepository.save(storedFile)); + FileMetadataResponse response = toResponse(storedFileRepository.save(storedFile)); + touchDirectoryListings(user, parentPath); + return response; } public PageResponse list(User user, String path, int page, int size) { String normalizedPath = normalizeDirectoryPath(path); - Page result = storedFileRepository.findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc( - user.getId(), normalizedPath, PageRequest.of(page, size)); - List items = result.getContent().stream().map(this::toResponse).toList(); - return new PageResponse<>(items, result.getTotalElements(), page, size); + return fileListDirectoryCacheService.getOrLoad(user.getId(), normalizedPath, page, size, () -> { + Page result = storedFileRepository.findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc( + user.getId(), normalizedPath, PageRequest.of(page, size)); + List items = result.getContent().stream().map(this::toResponse).toList(); + return new PageResponse<>(items, result.getTotalElements(), page, size); + }); } public List recent(User user) { @@ -244,8 +264,9 @@ public class FileService { @Transactional public void ensureDefaultDirectories(User user) { + boolean createdAny = false; for (String directoryName : DEFAULT_DIRECTORIES) { - if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), "/", directoryName)) { + if (workspaceNodeRulesService.existsNodeName(user.getId(), "/", directoryName)) { continue; } @@ -256,10 +277,15 @@ public class FileService { storedFile.setUser(user); storedFile.setFilename(directoryName); storedFile.setPath("/"); + storedFile.setLegacyStorageName(directoryName); storedFile.setContentType("directory"); storedFile.setSize(0L); storedFile.setDirectory(true); storedFileRepository.save(storedFile); + createdAny = true; + } + if (createdAny) { + touchDirectoryListings(user, "/"); } } @@ -275,33 +301,42 @@ public class FileService { filesToRecycle.addAll(descendants); } moveToRecycleBin(filesToRecycle, storedFile.getId()); + touchDirectoryListings(user, extractParentPath(fromPath)); recordFileEvent(user, FileEventType.DELETED, storedFile, fromPath, buildLogicalPath(storedFile)); } @Transactional public FileMetadataResponse restoreFromRecycleBin(User user, Long fileId) { - StoredFile recycleRoot = getOwnedRecycleRootFile(user, fileId); - String fromPath = buildLogicalPath(recycleRoot); - String toPath = buildTargetLogicalPath(requireRecycleOriginalPath(recycleRoot), recycleRoot.getFilename()); - List recycleGroupItems = loadRecycleGroupItems(recycleRoot); - long additionalBytes = recycleGroupItems.stream() - .filter(item -> !item.isDirectory()) - .mapToLong(StoredFile::getSize) - .sum(); - ensureWithinStorageQuota(user, additionalBytes); - validateRecycleRestoreTargets(user.getId(), recycleGroupItems); - ensureRecycleRestoreParentHierarchy(user, recycleRoot); + return distributedLockService.executeWithLock( + "files:recycle-restore:" + fileId, + Duration.ofSeconds(120), + () -> { + StoredFile recycleRoot = getOwnedRecycleRootFile(user, fileId); + String fromPath = buildLogicalPath(recycleRoot); + String restoreParentPath = requireRecycleOriginalPath(recycleRoot); + String toPath = buildTargetLogicalPath(restoreParentPath, recycleRoot.getFilename()); + List recycleGroupItems = loadRecycleGroupItems(recycleRoot); + long additionalBytes = recycleGroupItems.stream() + .filter(item -> !item.isDirectory()) + .mapToLong(StoredFile::getSize) + .sum(); + fileUploadRulesService.ensureWithinStorageQuota(user, additionalBytes); + validateRecycleRestoreTargets(user.getId(), recycleGroupItems); + ensureRecycleRestoreParentHierarchy(user, recycleRoot); - for (StoredFile item : recycleGroupItems) { - item.setPath(requireRecycleOriginalPath(item)); - item.setDeletedAt(null); - item.setRecycleOriginalPath(null); - item.setRecycleGroupId(null); - item.setRecycleRoot(false); - } - storedFileRepository.saveAll(recycleGroupItems); - recordFileEvent(user, FileEventType.RESTORED, recycleRoot, fromPath, toPath); - return toResponse(recycleRoot); + for (StoredFile item : recycleGroupItems) { + item.setPath(requireRecycleOriginalPath(item)); + item.setDeletedAt(null); + item.setRecycleOriginalPath(null); + item.setRecycleGroupId(null); + item.setRecycleRoot(false); + } + storedFileRepository.saveAll(recycleGroupItems); + touchDirectoryListings(user, restoreParentPath); + recordFileEvent(user, FileEventType.RESTORED, recycleRoot, fromPath, toPath); + return toResponse(recycleRoot); + } + ); } @Scheduled(fixedDelay = 60 * 60 * 1000L) @@ -312,11 +347,11 @@ public class FileService { return; } - List blobsToDelete = collectBlobsToDelete( + List blobsToDelete = contentBlobLifecycleService.collectBlobsToDelete( expiredItems.stream().filter(item -> !item.isDirectory()).toList() ); storedFileRepository.deleteAll(expiredItems); - deleteBlobs(blobsToDelete); + contentBlobLifecycleService.deleteBlobs(blobsToDelete); } @Transactional @@ -327,9 +362,7 @@ public class FileService { if (sanitizedFilename.equals(storedFile.getFilename())) { return toResponse(storedFile); } - if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), storedFile.getPath(), sanitizedFilename)) { - throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在"); - } + workspaceNodeRulesService.ensureNodeNameAvailable(user.getId(), storedFile.getPath(), sanitizedFilename, "同目录下文件已存在"); if (storedFile.isDirectory()) { String oldLogicalPath = buildLogicalPath(storedFile); @@ -353,6 +386,7 @@ public class FileService { storedFile.setFilename(sanitizedFilename); FileMetadataResponse response = toResponse(storedFileRepository.save(storedFile)); + touchDirectoryListings(user, storedFile.getPath()); recordFileEvent(user, FileEventType.RENAMED, storedFile, fromPath, buildLogicalPath(storedFile)); return response; } @@ -367,9 +401,7 @@ public class FileService { } ensureExistingDirectoryPath(user.getId(), normalizedTargetPath); - if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedTargetPath, storedFile.getFilename())) { - throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件"); - } + workspaceNodeRulesService.ensureNodeNameAvailable(user.getId(), normalizedTargetPath, storedFile.getFilename(), "目标目录已存在同名文件"); if (storedFile.isDirectory()) { String oldLogicalPath = buildLogicalPath(storedFile); @@ -396,6 +428,7 @@ public class FileService { storedFile.setPath(normalizedTargetPath); FileMetadataResponse response = toResponse(storedFileRepository.save(storedFile)); + touchDirectoryListings(user, extractParentPath(fromPath), normalizedTargetPath); recordFileEvent(user, FileEventType.MOVED, storedFile, fromPath, buildLogicalPath(storedFile)); return response; } @@ -405,13 +438,13 @@ public class FileService { StoredFile storedFile = getOwnedActiveFile(user, fileId, "复制"); String normalizedTargetPath = normalizeDirectoryPath(nextPath); ensureExistingDirectoryPath(user.getId(), normalizedTargetPath); - if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedTargetPath, storedFile.getFilename())) { - throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件"); - } + workspaceNodeRulesService.ensureNodeNameAvailable(user.getId(), normalizedTargetPath, storedFile.getFilename(), "目标目录已存在同名文件"); if (!storedFile.isDirectory()) { - ensureWithinStorageQuota(user, storedFile.getSize()); - return toResponse(saveCopiedStoredFile(copyStoredFile(storedFile, user, normalizedTargetPath), user)); + fileUploadRulesService.ensureWithinStorageQuota(user, storedFile.getSize()); + FileMetadataResponse response = toResponse(saveCopiedStoredFile(copyStoredFile(storedFile, user, normalizedTargetPath), user)); + touchDirectoryListings(user, normalizedTargetPath); + return response; } String oldLogicalPath = buildLogicalPath(storedFile); @@ -425,7 +458,7 @@ public class FileService { .filter(descendant -> !descendant.isDirectory()) .mapToLong(StoredFile::getSize) .sum(); - ensureWithinStorageQuota(user, additionalBytes); + fileUploadRulesService.ensureWithinStorageQuota(user, additionalBytes); List copiedEntries = new ArrayList<>(); StoredFile copiedRoot = copyStoredFile(storedFile, user, normalizedTargetPath); @@ -438,9 +471,7 @@ public class FileService { .thenComparing(StoredFile::getFilename)) .forEach(descendant -> { String copiedPath = remapCopiedPath(descendant.getPath(), oldLogicalPath, newLogicalPath); - if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), copiedPath, descendant.getFilename())) { - throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件"); - } + workspaceNodeRulesService.ensureNodeNameAvailable(user.getId(), copiedPath, descendant.getFilename(), "目标目录已存在同名文件"); copiedEntries.add(copyStoredFile(descendant, user, copiedPath)); }); @@ -452,6 +483,7 @@ public class FileService { savedRoot = savedEntry; } } + touchDirectoryListings(user, normalizedTargetPath); return toResponse(savedRoot == null ? copiedRoot : savedRoot); } @@ -470,7 +502,7 @@ public class FileService { if (fileContentStorage.supportsDirectDownload()) { return ResponseEntity.status(302) .location(URI.create(fileContentStorage.createBlobDownloadUrl( - getRequiredBlob(storedFile).getObjectKey(), + contentBlobLifecycleService.getRequiredBlob(storedFile).getObjectKey(), storedFile.getFilename()))) .build(); } @@ -480,7 +512,7 @@ public class FileService { "attachment; filename*=UTF-8''" + URLEncoder.encode(storedFile.getFilename(), StandardCharsets.UTF_8)) .contentType(MediaType.parseMediaType( storedFile.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : storedFile.getContentType())) - .body(fileContentStorage.readBlob(getRequiredBlob(storedFile).getObjectKey())); + .body(fileContentStorage.readBlob(contentBlobLifecycleService.getRequiredBlob(storedFile).getObjectKey())); } public DownloadUrlResponse getDownloadUrl(User user, Long fileId) { @@ -496,7 +528,7 @@ public class FileService { if (fileContentStorage.supportsDirectDownload()) { return new DownloadUrlResponse(fileContentStorage.createBlobDownloadUrl( - getRequiredBlob(storedFile).getObjectKey(), + contentBlobLifecycleService.getRequiredBlob(storedFile).getObjectKey(), storedFile.getFilename() )); } @@ -553,7 +585,7 @@ public class FileService { sourceFile.getFilename(), sourceFile.getContentType(), sourceFile.getSize(), - getRequiredBlob(sourceFile) + contentBlobLifecycleService.getRequiredBlob(sourceFile) ); } @@ -566,12 +598,12 @@ public class FileService { byte[] content) { String normalizedPath = normalizeDirectoryPath(path); String normalizedFilename = normalizeLeafName(filename); - validateUpload(recipient, normalizedPath, normalizedFilename, size); + fileUploadRulesService.validateUpload(recipient, normalizedPath, normalizedFilename, size); ensureDirectoryHierarchy(recipient, normalizedPath); String objectKey = createBlobObjectKey(); - return executeAfterBlobStored(objectKey, () -> { + return contentBlobLifecycleService.executeAfterBlobStored(objectKey, () -> { fileContentStorage.storeBlob(objectKey, contentType, content); - FileBlob blob = createAndSaveBlob(objectKey, contentType, size); + FileBlob blob = contentBlobLifecycleService.createAndSaveBlob(objectKey, contentType, size); return saveFileMetadata( recipient, @@ -596,9 +628,9 @@ public class FileService { List directories, List files, ExternalImportProgressListener progressListener) { - List normalizedDirectories = normalizeExternalImportDirectories(directories); - List normalizedFiles = normalizeExternalImportFiles(files); - validateExternalImportBatch(recipient, normalizedDirectories, normalizedFiles); + List normalizedDirectories = externalImportRulesService.normalizeDirectories(directories); + List normalizedFiles = externalImportRulesService.normalizeFiles(files); + externalImportRulesService.validateBatch(recipient, normalizedDirectories, normalizedFiles); List writtenBlobObjectKeys = new ArrayList<>(); int totalDirectoryCount = normalizedDirectories.size(); @@ -619,7 +651,7 @@ public class FileService { processedDirectoryCount, totalDirectoryCount); } } catch (RuntimeException ex) { - cleanupWrittenBlobs(writtenBlobObjectKeys, ex); + contentBlobLifecycleService.cleanupWrittenBlobs(writtenBlobObjectKeys, ex); throw ex; } } @@ -658,7 +690,7 @@ public class FileService { } public ZipCompatibleArchive readZipCompatibleArchive(StoredFile source) { - byte[] archiveBytes = fileContentStorage.readBlob(getRequiredBlob(source).getObjectKey()); + byte[] archiveBytes = fileContentStorage.readBlob(contentBlobLifecycleService.getRequiredBlob(source).getObjectKey()); try (ZipInputStream zipInputStream = new ZipInputStream( new ByteArrayInputStream(archiveBytes), StandardCharsets.UTF_8)) { @@ -709,7 +741,7 @@ public class FileService { } private String buildPublicPackageDownloadUrl(StoredFile storedFile) { - FileBlob blob = getRequiredBlob(storedFile); + FileBlob blob = contentBlobLifecycleService.getRequiredBlob(storedFile); String base = packageDownloadBaseUrl.endsWith("/") ? packageDownloadBaseUrl.substring(0, packageDownloadBaseUrl.length() - 1) : packageDownloadBaseUrl; @@ -817,68 +849,30 @@ public class FileService { storedFile.setSize(size); storedFile.setDirectory(false); storedFile.setBlob(blob); + storedFile.setLegacyStorageName(blob.getObjectKey()); FileEntity primaryEntity = createOrReferencePrimaryEntity(user, blob); storedFile.setPrimaryEntity(primaryEntity); StoredFile savedFile = storedFileRepository.save(storedFile); savePrimaryEntityRelation(savedFile, primaryEntity); + touchDirectoryListings(user, normalizedPath); + publishMediaMetadataTrigger(savedFile); recordFileEvent(user, FileEventType.CREATED, savedFile, null, buildLogicalPath(savedFile)); return toResponse(savedFile); } private FileEntity createOrReferencePrimaryEntity(User user, FileBlob blob) { - if (fileEntityRepository == null) { - return createTransientPrimaryEntity(user, blob); - } - - Optional existingEntity = fileEntityRepository.findByObjectKeyAndEntityType( - blob.getObjectKey(), - FileEntityType.VERSION - ); - if (existingEntity.isPresent()) { - FileEntity entity = existingEntity.get(); - entity.setReferenceCount(entity.getReferenceCount() + 1); - return fileEntityRepository.save(entity); - } - - return fileEntityRepository.save(createTransientPrimaryEntity(user, blob)); - } - - private FileEntity createTransientPrimaryEntity(User user, FileBlob blob) { - FileEntity entity = new FileEntity(); - entity.setObjectKey(blob.getObjectKey()); - entity.setContentType(blob.getContentType()); - entity.setSize(blob.getSize()); - entity.setEntityType(FileEntityType.VERSION); - entity.setReferenceCount(1); - entity.setCreatedBy(user); - entity.setStoragePolicyId(resolveDefaultStoragePolicyId()); - return entity; - } - - private Long resolveDefaultStoragePolicyId() { - if (storagePolicyService == null) { - return null; - } - return storagePolicyService.ensureDefaultPolicy().getId(); - } - - private StoragePolicyCapabilities resolveDefaultStoragePolicyCapabilities() { - if (storagePolicyService == null) { - return null; - } - return storagePolicyService.readCapabilities(storagePolicyService.ensureDefaultPolicy()); + return contentAssetBindingService.createOrReferencePrimaryEntity(user, blob); } private void savePrimaryEntityRelation(StoredFile storedFile, FileEntity primaryEntity) { - if (storedFileEntityRepository == null) { + contentAssetBindingService.savePrimaryEntityRelation(storedFile, primaryEntity); + } + + private void publishMediaMetadataTrigger(StoredFile storedFile) { + if (mediaMetadataTaskBrokerPublisher == null) { return; } - - StoredFileEntity relation = new StoredFileEntity(); - relation.setStoredFile(storedFile); - relation.setFileEntity(primaryEntity); - relation.setEntityRole("PRIMARY"); - storedFileEntityRepository.save(relation); + mediaMetadataTaskBrokerPublisher.publishAfterCommit(storedFile); } private FileShareLink getShareLink(String token) { @@ -939,6 +933,10 @@ public class FileService { } private void validateUpload(User user, String normalizedPath, String filename, long size) { + if (fileUploadRulesService != null) { + fileUploadRulesService.validateUpload(user, normalizedPath, filename, size); + return; + } long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes()); StoragePolicy defaultPolicy = storagePolicyService == null ? null : storagePolicyService.ensureDefaultPolicy(); StoragePolicyCapabilities capabilities = defaultPolicy == null ? null : storagePolicyService.readCapabilities(defaultPolicy); @@ -951,9 +949,7 @@ public class FileService { if (size > effectiveMaxUploadSize) { throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制"); } - if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedPath, filename)) { - throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在"); - } + workspaceNodeRulesService.ensureNodeNameAvailable(user.getId(), normalizedPath, filename, "同目录下文件已存在"); ensureWithinStorageQuota(user, size); } @@ -985,7 +981,7 @@ public class FileService { private void validateExternalImportBatch(User recipient, List directories, List files) { - ensureWithinStorageQuota(recipient, files.stream().mapToLong(ExternalFileImport::size).sum()); + fileUploadRulesService.ensureWithinStorageQuota(recipient, files.stream().mapToLong(ExternalFileImport::size).sum()); Set plannedTargets = new LinkedHashSet<>(); for (String directory : directories) { @@ -997,9 +993,7 @@ public class FileService { } String parentPath = extractParentPath(directory); String directoryName = extractLeafName(directory); - if (storedFileRepository.existsByUserIdAndPathAndFilename(recipient.getId(), parentPath, directoryName)) { - throw new BusinessException(ErrorCode.UNKNOWN, "解压目标已存在"); - } + workspaceNodeRulesService.ensureNodeNameAvailable(recipient.getId(), parentPath, directoryName, "解压目标已存在"); } for (ExternalFileImport file : files) { @@ -1007,13 +1001,15 @@ public class FileService { if (plannedTargets.contains(logicalPath) || !plannedTargets.add(logicalPath)) { throw new BusinessException(ErrorCode.UNKNOWN, "解压目标已存在"); } - if (storedFileRepository.existsByUserIdAndPathAndFilename(recipient.getId(), file.path(), file.filename())) { - throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在"); - } + workspaceNodeRulesService.ensureNodeNameAvailable(recipient.getId(), file.path(), file.filename(), "同目录下文件已存在"); } } private void ensureWithinStorageQuota(User user, long additionalBytes) { + if (fileUploadRulesService != null) { + fileUploadRulesService.ensureWithinStorageQuota(user, additionalBytes); + return; + } if (additionalBytes <= 0) { return; } @@ -1026,48 +1022,18 @@ public class FileService { } private void ensureDirectoryHierarchy(User user, String normalizedPath) { - if ("/".equals(normalizedPath)) { - return; - } - - String[] segments = normalizedPath.substring(1).split("/"); - String currentPath = "/"; - - for (String segment : segments) { - Optional existing = storedFileRepository.findByUserIdAndPathAndFilename(user.getId(), currentPath, segment); - if (existing.isPresent()) { - if (!existing.get().isDirectory()) { - throw new BusinessException(ErrorCode.UNKNOWN, "目标路径不是目录"); - } - currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment; - continue; - } - - String logicalPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment; - fileContentStorage.ensureDirectory(user.getId(), logicalPath); - - StoredFile storedFile = new StoredFile(); - storedFile.setUser(user); - storedFile.setFilename(segment); - storedFile.setPath(currentPath); - storedFile.setContentType("directory"); - storedFile.setSize(0L); - storedFile.setDirectory(true); - storedFileRepository.save(storedFile); - - currentPath = logicalPath; - } + workspaceNodeRulesService.ensureDirectoryHierarchy(user, normalizedPath); } private void storeExternalImportFile(User recipient, ExternalFileImport file, List writtenBlobObjectKeys) { - validateUpload(recipient, file.path(), file.filename(), file.size()); + fileUploadRulesService.validateUpload(recipient, file.path(), file.filename(), file.size()); ensureDirectoryHierarchy(recipient, file.path()); String objectKey = createBlobObjectKey(); writtenBlobObjectKeys.add(objectKey); fileContentStorage.storeBlob(objectKey, file.contentType(), file.content()); - FileBlob blob = createAndSaveBlob(objectKey, file.contentType(), file.size()); + FileBlob blob = contentBlobLifecycleService.createAndSaveBlob(objectKey, file.contentType(), file.size()); saveFileMetadata( recipient, file.path(), @@ -1130,12 +1096,7 @@ public class FileService { } private void validateRecycleRestoreTargets(Long userId, List recycleGroupItems) { - for (StoredFile item : recycleGroupItems) { - String originalPath = requireRecycleOriginalPath(item); - if (storedFileRepository.existsByUserIdAndPathAndFilename(userId, originalPath, item.getFilename())) { - throw new BusinessException(ErrorCode.UNKNOWN, "原目录已存在同名文件,无法恢复"); - } - } + workspaceNodeRulesService.validateRecycleRestoreTargets(userId, recycleGroupItems, this::requireRecycleOriginalPath); } private void ensureRecycleRestoreParentHierarchy(User user, StoredFile recycleRoot) { @@ -1143,28 +1104,11 @@ public class FileService { } private void ensureExistingDirectoryPath(Long userId, String normalizedPath) { - if ("/".equals(normalizedPath)) { - return; - } - - String[] segments = normalizedPath.substring(1).split("/"); - String currentPath = "/"; - for (String segment : segments) { - StoredFile directory = storedFileRepository.findByUserIdAndPathAndFilename(userId, currentPath, segment) - .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "目标目录不存在")); - if (!directory.isDirectory()) { - throw new BusinessException(ErrorCode.UNKNOWN, "目标路径不是目录"); - } - currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment; - } + workspaceNodeRulesService.ensureExistingDirectoryPath(userId, normalizedPath); } private String normalizeUploadFilename(String originalFilename) { - String filename = StringUtils.cleanPath(originalFilename); - if (!StringUtils.hasText(filename)) { - throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空"); - } - return normalizeLeafName(filename); + return workspaceNodeRulesService.normalizeUploadFilename(originalFilename); } private FileMetadataResponse toResponse(StoredFile storedFile) { @@ -1180,30 +1124,15 @@ public class FileService { } private String normalizeDirectoryPath(String path) { - if (!StringUtils.hasText(path) || "/".equals(path.trim())) { - return "/"; - } - String normalized = path.replace("\\", "/").trim(); - if (!normalized.startsWith("/")) { - normalized = "/" + normalized; - } - normalized = normalized.replaceAll("/{2,}", "/"); - if (normalized.contains("..")) { - throw new BusinessException(ErrorCode.UNKNOWN, "路径不合法"); - } - if (normalized.endsWith("/") && normalized.length() > 1) { - normalized = normalized.substring(0, normalized.length() - 1); - } - return normalized; + return workspaceNodeRulesService.normalizeDirectoryPath(path); } private String extractParentPath(String normalizedPath) { - int lastSlash = normalizedPath.lastIndexOf('/'); - return lastSlash <= 0 ? "/" : normalizedPath.substring(0, lastSlash); + return workspaceNodeRulesService.extractParentPath(normalizedPath); } private String extractLeafName(String normalizedPath) { - return normalizedPath.substring(normalizedPath.lastIndexOf('/') + 1); + return workspaceNodeRulesService.extractLeafName(normalizedPath); } private String buildLogicalPath(StoredFile storedFile) { @@ -1213,9 +1142,7 @@ public class FileService { } private String buildTargetLogicalPath(String normalizedTargetPath, String filename) { - return "/".equals(normalizedTargetPath) - ? "/" + filename - : normalizedTargetPath + "/" + filename; + return workspaceNodeRulesService.buildTargetLogicalPath(normalizedTargetPath, filename); } private String remapCopiedPath(String currentPath, String oldLogicalPath, String newLogicalPath) { @@ -1277,7 +1204,7 @@ public class FileService { ArchiveBuildProgressState progressState) throws IOException { ensureParentDirectoryEntries(zipOutputStream, createdEntries, entryName, progressState); writeFileEntry(zipOutputStream, createdEntries, entryName, progressState, - fileContentStorage.readBlob(getRequiredBlob(file).getObjectKey())); + fileContentStorage.readBlob(contentBlobLifecycleService.getRequiredBlob(file).getObjectKey())); } private String buildZipEntryName(String rootDirectoryName, String rootLogicalPath, StoredFile storedFile) { @@ -1476,15 +1403,26 @@ public class FileService { fileEventService.record(user, eventType, storedFile.getId(), fromPath, toPath, payload); } + private void touchDirectoryListings(User user, String... paths) { + if (user == null || user.getId() == null || paths == null || paths.length == 0) { + return; + } + + List affectedPaths = new ArrayList<>(); + for (String path : paths) { + if (StringUtils.hasText(path)) { + affectedPaths.add(normalizeDirectoryPath(path)); + } + } + if (affectedPaths.isEmpty()) { + return; + } + + fileListDirectoryCacheService.touchDirectories(user.getId(), affectedPaths); + } + private String normalizeLeafName(String filename) { - String cleaned = StringUtils.cleanPath(filename == null ? "" : filename).trim(); - if (!StringUtils.hasText(cleaned)) { - throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空"); - } - if (cleaned.contains("/") || cleaned.contains("\\") || cleaned.contains("..")) { - throw new BusinessException(ErrorCode.UNKNOWN, "文件名不合法"); - } - return cleaned; + return workspaceNodeRulesService.normalizeLeafName(filename); } private String createBlobObjectKey() { @@ -1499,37 +1437,6 @@ public class FileService { return cleaned; } - private T executeAfterBlobStored(String objectKey, BlobWriteOperation operation) { - try { - return operation.run(); - } catch (RuntimeException ex) { - try { - fileContentStorage.deleteBlob(objectKey); - } catch (RuntimeException cleanupEx) { - ex.addSuppressed(cleanupEx); - } - throw ex; - } - } - - private void cleanupWrittenBlobs(List writtenBlobObjectKeys, RuntimeException ex) { - for (String objectKey : writtenBlobObjectKeys) { - try { - fileContentStorage.deleteBlob(objectKey); - } catch (RuntimeException cleanupEx) { - ex.addSuppressed(cleanupEx); - } - } - } - - private FileBlob createAndSaveBlob(String objectKey, String contentType, long size) { - FileBlob blob = new FileBlob(); - blob.setObjectKey(objectKey); - blob.setContentType(contentType); - blob.setSize(size); - return fileBlobRepository.save(blob); - } - private FileMetadataResponse importReferencedBlob(User recipient, String path, String filename, @@ -1538,7 +1445,7 @@ public class FileService { FileBlob blob) { String normalizedPath = normalizeDirectoryPath(path); String normalizedFilename = normalizeLeafName(filename); - validateUpload(recipient, normalizedPath, normalizedFilename, size); + fileUploadRulesService.validateUpload(recipient, normalizedPath, normalizedFilename, size); ensureDirectoryHierarchy(recipient, normalizedPath); return saveFileMetadata( recipient, @@ -1557,50 +1464,6 @@ public class FileService { return storedFile.getBlob(); } - private List collectBlobsToDelete(List filesToDelete) { - Map candidates = new HashMap<>(); - for (StoredFile file : filesToDelete) { - if (file.getBlob() == null || file.getBlob().getId() == null) { - continue; - } - BlobDeletionCandidate candidate = candidates.computeIfAbsent( - file.getBlob().getId(), - ignored -> new BlobDeletionCandidate(file.getBlob()) - ); - candidate.referencesToDelete += 1; - } - - List blobsToDelete = new ArrayList<>(); - for (BlobDeletionCandidate candidate : candidates.values()) { - long currentReferences = storedFileRepository.countByBlobId(candidate.blob.getId()); - if (currentReferences == candidate.referencesToDelete) { - blobsToDelete.add(candidate.blob); - } - } - return blobsToDelete; - } - - private void deleteBlobs(List blobsToDelete) { - for (FileBlob blob : blobsToDelete) { - fileContentStorage.deleteBlob(blob.getObjectKey()); - fileBlobRepository.delete(blob); - } - } - - private static final class BlobDeletionCandidate { - private final FileBlob blob; - private long referencesToDelete; - - private BlobDeletionCandidate(FileBlob blob) { - this.blob = blob; - } - } - - @FunctionalInterface - private interface BlobWriteOperation { - T run(); - } - public static record ZipCompatibleArchive(List entries, String commonRootDirectoryName) { } diff --git a/backend/src/main/java/com/yoyuzh/files/core/FileUploadRulesService.java b/backend/src/main/java/com/yoyuzh/files/core/FileUploadRulesService.java new file mode 100644 index 0000000..2529843 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/core/FileUploadRulesService.java @@ -0,0 +1,55 @@ +package com.yoyuzh.files.core; + +import com.yoyuzh.auth.User; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import com.yoyuzh.files.policy.StoragePolicy; +import com.yoyuzh.files.policy.StoragePolicyCapabilities; +import com.yoyuzh.files.policy.StoragePolicyService; + +public final class FileUploadRulesService { + + private final StoredFileRepository storedFileRepository; + private final StoragePolicyService storagePolicyService; + private final WorkspaceNodeRulesService workspaceNodeRulesService; + private final long maxFileSize; + + public FileUploadRulesService(StoredFileRepository storedFileRepository, + StoragePolicyService storagePolicyService, + WorkspaceNodeRulesService workspaceNodeRulesService, + long maxFileSize) { + this.storedFileRepository = storedFileRepository; + this.storagePolicyService = storagePolicyService; + this.workspaceNodeRulesService = workspaceNodeRulesService; + this.maxFileSize = maxFileSize; + } + + public void validateUpload(User user, String normalizedPath, String filename, long size) { + long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes()); + StoragePolicy defaultPolicy = storagePolicyService == null ? null : storagePolicyService.ensureDefaultPolicy(); + StoragePolicyCapabilities capabilities = defaultPolicy == null ? null : storagePolicyService.readCapabilities(defaultPolicy); + if (defaultPolicy != null && defaultPolicy.getMaxSizeBytes() > 0) { + effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, defaultPolicy.getMaxSizeBytes()); + } + if (capabilities != null && capabilities.maxObjectSize() > 0) { + effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, capabilities.maxObjectSize()); + } + if (size > effectiveMaxUploadSize) { + throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制"); + } + workspaceNodeRulesService.ensureNodeNameAvailable(user.getId(), normalizedPath, filename, "同目录下文件已存在"); + ensureWithinStorageQuota(user, size); + } + + public void ensureWithinStorageQuota(User user, long additionalBytes) { + if (additionalBytes <= 0) { + return; + } + + long usedBytes = storedFileRepository.sumFileSizeByUserId(user.getId()); + long quotaBytes = user.getStorageQuotaBytes(); + if (usedBytes > Long.MAX_VALUE - additionalBytes || usedBytes + additionalBytes > quotaBytes) { + throw new BusinessException(ErrorCode.UNKNOWN, "存储空间不足"); + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/core/NoOpFileListDirectoryCacheService.java b/backend/src/main/java/com/yoyuzh/files/core/NoOpFileListDirectoryCacheService.java new file mode 100644 index 0000000..12d0621 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/core/NoOpFileListDirectoryCacheService.java @@ -0,0 +1,26 @@ +package com.yoyuzh.files.core; + +import com.yoyuzh.common.PageResponse; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.function.Supplier; + +@Service +@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "false", matchIfMissing = true) +public class NoOpFileListDirectoryCacheService implements FileListDirectoryCacheService { + + @Override + public PageResponse getOrLoad(Long userId, + String path, + int page, + int size, + Supplier> loader) { + return loader.get(); + } + + @Override + public void touchDirectories(Long userId, Collection paths) { + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/core/RedisFileListDirectoryCacheService.java b/backend/src/main/java/com/yoyuzh/files/core/RedisFileListDirectoryCacheService.java new file mode 100644 index 0000000..de2df29 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/core/RedisFileListDirectoryCacheService.java @@ -0,0 +1,166 @@ +package com.yoyuzh.files.core; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.common.PageResponse; +import com.yoyuzh.config.AppRedisProperties; +import com.yoyuzh.config.RedisCacheNames; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; + +@Service +@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true") +public class RedisFileListDirectoryCacheService implements FileListDirectoryCacheService { + + private static final String SORT_CONTEXT = "directory-desc-created-desc"; + + private final CacheManager cacheManager; + private final StringRedisTemplate stringRedisTemplate; + private final AppRedisProperties redisProperties; + private final ObjectMapper objectMapper; + + public RedisFileListDirectoryCacheService(CacheManager cacheManager, + StringRedisTemplate stringRedisTemplate, + AppRedisProperties redisProperties, + ObjectMapper objectMapper) { + this.cacheManager = cacheManager; + this.stringRedisTemplate = stringRedisTemplate; + this.redisProperties = redisProperties; + this.objectMapper = objectMapper; + } + + @Override + public PageResponse getOrLoad(Long userId, + String path, + int page, + int size, + Supplier> loader) { + Cache cache = cacheManager.getCache(RedisCacheNames.FILES_LIST); + if (cache == null) { + return loader.get(); + } + + long version = readDirectoryVersion(userId, path); + String cacheKey = buildCacheKey(userId, path, page, size, version); + CachedFileListPage cached = readCachedPage(cache, cacheKey); + if (cached != null) { + return cached.toPageResponse(); + } + + PageResponse loaded = loader.get(); + cache.put(cacheKey, CachedFileListPage.from(loaded)); + return loaded; + } + + @Override + public void touchDirectories(Long userId, Collection paths) { + if (userId == null || paths == null || paths.isEmpty()) { + return; + } + + Set normalizedPaths = new LinkedHashSet<>(); + for (String path : paths) { + String normalized = normalizeDirectoryPath(path); + if (normalized != null) { + normalizedPaths.add(normalized); + } + } + if (normalizedPaths.isEmpty()) { + return; + } + + Duration ttl = Duration.ofSeconds(Math.max( + redisProperties.getCache().getDirectoryVersionTtlSeconds(), + redisProperties.getCache().getFilesListTtlSeconds() * 2 + )); + for (String path : normalizedPaths) { + String key = buildDirectoryVersionKey(userId, path); + stringRedisTemplate.opsForValue().increment(key); + stringRedisTemplate.expire(key, ttl); + } + } + + private CachedFileListPage readCachedPage(Cache cache, String cacheKey) { + Cache.ValueWrapper wrapper = cache.get(cacheKey); + if (wrapper == null || wrapper.get() == null) { + return null; + } + Object cachedValue = wrapper.get(); + if (cachedValue instanceof CachedFileListPage cachedFileListPage) { + return cachedFileListPage; + } + return objectMapper.convertValue(cachedValue, CachedFileListPage.class); + } + + private long readDirectoryVersion(Long userId, String path) { + String value = stringRedisTemplate.opsForValue().get(buildDirectoryVersionKey(userId, path)); + if (!StringUtils.hasText(value)) { + return 0L; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException ex) { + return 0L; + } + } + + private String buildCacheKey(Long userId, String path, int page, int size, long version) { + return "u:" + userId + + ":path:" + encode(path) + + ":page:" + page + + ":size:" + size + + ":sort:" + SORT_CONTEXT + + ":v:" + version; + } + + private String buildDirectoryVersionKey(Long userId, String path) { + return redisProperties.getKeyPrefix() + + ":" + redisProperties.getNamespaces().getCache() + + ":files-list:version:u:" + userId + + ":path:" + encode(path); + } + + private String encode(String value) { + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private String normalizeDirectoryPath(String path) { + if (!StringUtils.hasText(path)) { + return "/"; + } + String normalized = path.trim().replace("\\", "/"); + while (normalized.contains("//")) { + normalized = normalized.replace("//", "/"); + } + if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + while (normalized.length() > 1 && normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private record CachedFileListPage(List items, long total, int page, int size) { + private static CachedFileListPage from(PageResponse response) { + return new CachedFileListPage(response.items(), response.total(), response.page(), response.size()); + } + + private PageResponse toPageResponse() { + return new PageResponse<>(items, total, page, size); + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/core/WorkspaceNodeRulesService.java b/backend/src/main/java/com/yoyuzh/files/core/WorkspaceNodeRulesService.java new file mode 100644 index 0000000..c68c6e0 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/core/WorkspaceNodeRulesService.java @@ -0,0 +1,147 @@ +package com.yoyuzh.files.core; + +import com.yoyuzh.auth.User; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import com.yoyuzh.files.storage.FileContentStorage; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +public final class WorkspaceNodeRulesService { + + private final StoredFileRepository storedFileRepository; + private final FileContentStorage fileContentStorage; + + public WorkspaceNodeRulesService(StoredFileRepository storedFileRepository, + FileContentStorage fileContentStorage) { + this.storedFileRepository = storedFileRepository; + this.fileContentStorage = fileContentStorage; + } + + public String normalizeDirectoryPath(String path) { + if (!StringUtils.hasText(path) || "/".equals(path.trim())) { + return "/"; + } + String normalized = path.replace("\\", "/").trim(); + if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + normalized = normalized.replaceAll("/{2,}", "/"); + if (normalized.contains("..")) { + throw new BusinessException(ErrorCode.UNKNOWN, "路径不合法"); + } + if (normalized.endsWith("/") && normalized.length() > 1) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + public String extractParentPath(String normalizedPath) { + int lastSlash = normalizedPath.lastIndexOf('/'); + return lastSlash <= 0 ? "/" : normalizedPath.substring(0, lastSlash); + } + + public String extractLeafName(String normalizedPath) { + return normalizedPath.substring(normalizedPath.lastIndexOf('/') + 1); + } + + public String buildTargetLogicalPath(String normalizedTargetPath, String filename) { + return "/".equals(normalizedTargetPath) + ? "/" + filename + : normalizedTargetPath + "/" + filename; + } + + public String normalizeUploadFilename(String originalFilename) { + String filename = StringUtils.cleanPath(originalFilename); + if (!StringUtils.hasText(filename)) { + throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空"); + } + return normalizeLeafName(filename); + } + + public String normalizeLeafName(String filename) { + String cleaned = StringUtils.cleanPath(filename == null ? "" : filename).trim(); + if (!StringUtils.hasText(cleaned)) { + throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空"); + } + if (cleaned.contains("/") || cleaned.contains("\\") || cleaned.contains("..")) { + throw new BusinessException(ErrorCode.UNKNOWN, "文件名不合法"); + } + return cleaned; + } + + public boolean existsNodeName(Long userId, String path, String filename) { + return storedFileRepository.existsByUserIdAndPathAndFilename(userId, path, filename); + } + + public void ensureNodeNameAvailable(Long userId, String path, String filename, String errorMessage) { + if (existsNodeName(userId, path, filename)) { + throw new BusinessException(ErrorCode.UNKNOWN, errorMessage); + } + } + + public void ensureDirectoryHierarchy(User user, String normalizedPath) { + if ("/".equals(normalizedPath)) { + return; + } + + String[] segments = normalizedPath.substring(1).split("/"); + String currentPath = "/"; + + for (String segment : segments) { + Optional existing = storedFileRepository.findByUserIdAndPathAndFilename(user.getId(), currentPath, segment); + if (existing.isPresent()) { + if (!existing.get().isDirectory()) { + throw new BusinessException(ErrorCode.UNKNOWN, "目标路径不是目录"); + } + currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment; + continue; + } + + String logicalPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment; + fileContentStorage.ensureDirectory(user.getId(), logicalPath); + + StoredFile storedFile = new StoredFile(); + storedFile.setUser(user); + storedFile.setFilename(segment); + storedFile.setPath(currentPath); + storedFile.setContentType("directory"); + storedFile.setSize(0L); + storedFile.setDirectory(true); + storedFileRepository.save(storedFile); + + currentPath = logicalPath; + } + } + + public void ensureExistingDirectoryPath(Long userId, String normalizedPath) { + if ("/".equals(normalizedPath)) { + return; + } + + String[] segments = normalizedPath.substring(1).split("/"); + String currentPath = "/"; + for (String segment : segments) { + StoredFile directory = storedFileRepository.findByUserIdAndPathAndFilename(userId, currentPath, segment) + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "目标目录不存在")); + if (!directory.isDirectory()) { + throw new BusinessException(ErrorCode.UNKNOWN, "目标路径不是目录"); + } + currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment; + } + } + + public void validateRecycleRestoreTargets(Long userId, + List recycleGroupItems, + Function recycleOriginalPathResolver) { + for (StoredFile item : recycleGroupItems) { + String originalPath = recycleOriginalPathResolver.apply(item); + if (existsNodeName(userId, originalPath, item.getFilename())) { + throw new BusinessException(ErrorCode.UNKNOWN, "原目录已存在同名文件,无法恢复"); + } + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/events/FileEventCrossInstancePublisher.java b/backend/src/main/java/com/yoyuzh/files/events/FileEventCrossInstancePublisher.java new file mode 100644 index 0000000..57e80e2 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/events/FileEventCrossInstancePublisher.java @@ -0,0 +1,6 @@ +package com.yoyuzh.files.events; + +public interface FileEventCrossInstancePublisher { + + void publish(FileEvent event); +} diff --git a/backend/src/main/java/com/yoyuzh/files/events/FileEventDispatcher.java b/backend/src/main/java/com/yoyuzh/files/events/FileEventDispatcher.java new file mode 100644 index 0000000..8866a36 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/events/FileEventDispatcher.java @@ -0,0 +1,141 @@ +package com.yoyuzh.files.events; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class FileEventDispatcher { + + private static final String READY_EVENT_NAME = "READY"; + + private final FileEventPayloadCodec payloadCodec; + private final ConcurrentHashMap> subscriptions = new ConcurrentHashMap<>(); + + public FileEventDispatcher(FileEventPayloadCodec payloadCodec) { + this.payloadCodec = payloadCodec; + } + + public SseEmitter openStream(Long userId, String path, String clientId) { + String normalizedPath = normalizePath(path); + String normalizedClientId = normalizeClientId(clientId); + SseEmitter emitter = createEmitter(); + Subscription subscription = new Subscription(emitter, normalizedPath, normalizedClientId); + subscriptions.computeIfAbsent(userId, ignored -> ConcurrentHashMap.newKeySet()).add(subscription); + emitter.onCompletion(() -> removeSubscription(userId, subscription)); + emitter.onTimeout(() -> removeSubscription(userId, subscription)); + emitter.onError(ex -> removeSubscription(userId, subscription)); + + try { + emitter.send(SseEmitter.event() + .name(READY_EVENT_NAME) + .data(payloadCodec.createReadyPayload(normalizedPath, normalizedClientId))); + } catch (IOException ex) { + removeSubscription(userId, subscription); + throw new IllegalStateException("Failed to initialize file event stream", ex); + } + return emitter; + } + + public void broadcast(FileEvent event) { + Set userSubscriptions = subscriptions.get(event.getUserId()); + if (userSubscriptions == null || userSubscriptions.isEmpty()) { + return; + } + + for (Subscription subscription : userSubscriptions.toArray(new Subscription[0])) { + if (!subscription.matches(event)) { + continue; + } + try { + subscription.emitter.send(SseEmitter.event() + .name(event.getEventType().name()) + .data(payloadCodec.createEmitterPayload(event))); + } catch (IOException | IllegalStateException ex) { + removeSubscription(event.getUserId(), subscription); + } + } + } + + protected SseEmitter createEmitter() { + return new SseEmitter(); + } + + private void removeSubscription(Long userId, Subscription subscription) { + Set userSubscriptions = subscriptions.get(userId); + if (userSubscriptions == null) { + return; + } + userSubscriptions.remove(subscription); + if (userSubscriptions.isEmpty()) { + subscriptions.remove(userId, userSubscriptions); + } + } + + private String normalizeClientId(String clientId) { + if (!StringUtils.hasText(clientId)) { + return null; + } + String cleaned = clientId.trim(); + return cleaned.isEmpty() ? null : cleaned; + } + + private String normalizePath(String path) { + if (!StringUtils.hasText(path)) { + return "/"; + } + + String cleaned = path.trim().replace("\\", "/"); + while (cleaned.contains("//")) { + cleaned = cleaned.replace("//", "/"); + } + if (!cleaned.startsWith("/")) { + cleaned = "/" + cleaned; + } + if (cleaned.length() > 1 && cleaned.endsWith("/")) { + cleaned = cleaned.substring(0, cleaned.length() - 1); + } + return cleaned; + } + + private boolean isPathMatch(String filterPath, String eventPath) { + if (!StringUtils.hasText(filterPath) || "/".equals(filterPath)) { + return true; + } + if (!StringUtils.hasText(eventPath)) { + return false; + } + return Objects.equals(filterPath, eventPath) || eventPath.startsWith(filterPath + "/"); + } + + private final class Subscription { + private final SseEmitter emitter; + private final String path; + private final String clientId; + + private Subscription(SseEmitter emitter, String path, String clientId) { + this.emitter = emitter; + this.path = path; + this.clientId = clientId; + } + + private boolean matches(FileEvent event) { + boolean pathMatches; + if (event.getFromPath() != null && event.getToPath() != null) { + pathMatches = isPathMatch(path, event.getFromPath()) || isPathMatch(path, event.getToPath()); + } else { + String eventPath = event.getToPath() != null ? event.getToPath() : event.getFromPath(); + pathMatches = isPathMatch(path, eventPath); + } + if (!pathMatches) { + return false; + } + return clientId == null || event.getClientId() == null || !clientId.equals(event.getClientId()); + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/events/FileEventInstanceIdentity.java b/backend/src/main/java/com/yoyuzh/files/events/FileEventInstanceIdentity.java new file mode 100644 index 0000000..44c95b9 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/events/FileEventInstanceIdentity.java @@ -0,0 +1,23 @@ +package com.yoyuzh.files.events; + +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +public class FileEventInstanceIdentity { + + private final String instanceId; + + public FileEventInstanceIdentity() { + this(UUID.randomUUID().toString()); + } + + FileEventInstanceIdentity(String instanceId) { + this.instanceId = instanceId; + } + + public String getInstanceId() { + return instanceId; + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/events/FileEventPayloadCodec.java b/backend/src/main/java/com/yoyuzh/files/events/FileEventPayloadCodec.java new file mode 100644 index 0000000..ee37cbd --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/events/FileEventPayloadCodec.java @@ -0,0 +1,52 @@ +package com.yoyuzh.files.events; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +public class FileEventPayloadCodec { + + private final ObjectMapper objectMapper; + + public FileEventPayloadCodec(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public String toJson(Map payload) { + Map safePayload = payload == null ? new LinkedHashMap<>() : new LinkedHashMap<>(payload); + if (!safePayload.containsKey("createdAt")) { + safePayload.put("createdAt", LocalDateTime.now()); + } + try { + return objectMapper.writeValueAsString(safePayload); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("Failed to serialize file event payload", ex); + } + } + + public Map createReadyPayload(String path, String clientId) { + Map payload = new LinkedHashMap<>(); + payload.put("eventType", "READY"); + payload.put("path", path); + payload.put("clientId", clientId); + payload.put("createdAt", LocalDateTime.now()); + return payload; + } + + public Map createEmitterPayload(FileEvent event) { + Map payload = new LinkedHashMap<>(); + payload.put("eventType", event.getEventType().name()); + payload.put("fileId", event.getFileId()); + payload.put("fromPath", event.getFromPath()); + payload.put("toPath", event.getToPath()); + payload.put("clientId", event.getClientId()); + payload.put("createdAt", event.getCreatedAt()); + payload.put("payload", event.getPayloadJson()); + return payload; + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/events/FileEventPubSubMessage.java b/backend/src/main/java/com/yoyuzh/files/events/FileEventPubSubMessage.java new file mode 100644 index 0000000..580130b --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/events/FileEventPubSubMessage.java @@ -0,0 +1,17 @@ +package com.yoyuzh.files.events; + +import java.time.LocalDateTime; + +record FileEventPubSubMessage( + String originInstanceId, + Long eventId, + Long userId, + FileEventType eventType, + Long fileId, + String fromPath, + String toPath, + String clientId, + String payloadJson, + LocalDateTime createdAt +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/events/FileEventRedisPubSubConfiguration.java b/backend/src/main/java/com/yoyuzh/files/events/FileEventRedisPubSubConfiguration.java new file mode 100644 index 0000000..0c968ec --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/events/FileEventRedisPubSubConfiguration.java @@ -0,0 +1,23 @@ +package com.yoyuzh.files.events; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; + +@Configuration +public class FileEventRedisPubSubConfiguration { + + @Bean + @ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true") + public RedisMessageListenerContainer fileEventRedisMessageListenerContainer( + RedisConnectionFactory redisConnectionFactory, + RedisFileEventPubSubListener listener) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(redisConnectionFactory); + container.addMessageListener(listener, new ChannelTopic(listener.buildTopic())); + return container; + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/events/FileEventService.java b/backend/src/main/java/com/yoyuzh/files/events/FileEventService.java index c413c25..c1b168c 100644 --- a/backend/src/main/java/com/yoyuzh/files/events/FileEventService.java +++ b/backend/src/main/java/com/yoyuzh/files/events/FileEventService.java @@ -1,7 +1,5 @@ package com.yoyuzh.files.events; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import com.yoyuzh.auth.User; import jakarta.servlet.http.HttpServletRequest; import org.springframework.stereotype.Service; @@ -12,46 +10,29 @@ import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -import java.io.IOException; -import java.time.LocalDateTime; -import java.util.LinkedHashMap; import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; @Service public class FileEventService { private static final String CLIENT_ID_HEADER = "X-Yoyuzh-Client-Id"; - private static final String READY_EVENT_NAME = "READY"; private final FileEventRepository fileEventRepository; - private final ObjectMapper objectMapper; - private final ConcurrentHashMap> subscriptions = new ConcurrentHashMap<>(); + private final FileEventPayloadCodec payloadCodec; + private final FileEventDispatcher fileEventDispatcher; + private final FileEventCrossInstancePublisher fileEventCrossInstancePublisher; - public FileEventService(FileEventRepository fileEventRepository, ObjectMapper objectMapper) { + public FileEventService(FileEventRepository fileEventRepository, + FileEventPayloadCodec payloadCodec, + FileEventDispatcher fileEventDispatcher, + FileEventCrossInstancePublisher fileEventCrossInstancePublisher) { this.fileEventRepository = fileEventRepository; - this.objectMapper = objectMapper; + this.payloadCodec = payloadCodec; + this.fileEventDispatcher = fileEventDispatcher; + this.fileEventCrossInstancePublisher = fileEventCrossInstancePublisher; } public SseEmitter openStream(User user, String path, String clientId) { - String normalizedPath = normalizePath(path); - SseEmitter emitter = createEmitter(); - Subscription subscription = new Subscription(emitter, normalizedPath, normalizeClientId(clientId)); - subscriptions.computeIfAbsent(user.getId(), ignored -> ConcurrentHashMap.newKeySet()).add(subscription); - emitter.onCompletion(() -> removeSubscription(user.getId(), subscription)); - emitter.onTimeout(() -> removeSubscription(user.getId(), subscription)); - emitter.onError(ex -> removeSubscription(user.getId(), subscription)); - - try { - emitter.send(SseEmitter.event() - .name(READY_EVENT_NAME) - .data(createReadyPayload(normalizedPath, subscription.clientId))); - } catch (IOException ex) { - removeSubscription(user.getId(), subscription); - throw new IllegalStateException("Failed to initialize file event stream", ex); - } - return emitter; + return fileEventDispatcher.openStream(user.getId(), path, resolveClientId(clientId)); } public FileEvent record(User user, @@ -68,7 +49,7 @@ public class FileEventService { event.setFromPath(fromPath); event.setToPath(toPath); event.setClientId(resolveClientId(clientId)); - event.setPayloadJson(toJson(payload)); + event.setPayloadJson(payloadCodec.toJson(payload)); fileEventRepository.save(event); broadcast(event); return event; @@ -83,37 +64,14 @@ public class FileEventService { return record(user, eventType, fileId, fromPath, toPath, null, payload); } - protected SseEmitter createEmitter() { - return new SseEmitter(); + void broadcastReplicatedEvent(FileEvent event) { + fileEventDispatcher.broadcast(event); } private void broadcast(FileEvent event) { Runnable broadcastTask = () -> { - Set userSubscriptions = subscriptions.get(event.getUserId()); - if (userSubscriptions == null || userSubscriptions.isEmpty()) { - return; - } - - for (Subscription subscription : userSubscriptions.toArray(new Subscription[0])) { - if (!subscription.matches(event)) { - continue; - } - try { - Map payload = new LinkedHashMap<>(); - payload.put("eventType", event.getEventType().name()); - payload.put("fileId", event.getFileId()); - payload.put("fromPath", event.getFromPath()); - payload.put("toPath", event.getToPath()); - payload.put("clientId", event.getClientId()); - payload.put("createdAt", event.getCreatedAt()); - payload.put("payload", event.getPayloadJson()); - subscription.emitter.send(SseEmitter.event() - .name(event.getEventType().name()) - .data(payload)); - } catch (IOException | IllegalStateException ex) { - removeSubscription(event.getUserId(), subscription); - } - } + fileEventDispatcher.broadcast(event); + fileEventCrossInstancePublisher.publish(event); }; if (TransactionSynchronizationManager.isActualTransactionActive()) { @@ -129,32 +87,9 @@ public class FileEventService { broadcastTask.run(); } - private void removeSubscription(Long userId, Subscription subscription) { - Set userSubscriptions = subscriptions.get(userId); - if (userSubscriptions == null) { - return; - } - userSubscriptions.remove(subscription); - if (userSubscriptions.isEmpty()) { - subscriptions.remove(userId, userSubscriptions); - } - } - - private String toJson(Map payload) { - Map safePayload = payload == null ? new LinkedHashMap<>() : new LinkedHashMap<>(payload); - if (!safePayload.containsKey("createdAt")) { - safePayload.put("createdAt", LocalDateTime.now()); - } - try { - return objectMapper.writeValueAsString(safePayload); - } catch (JsonProcessingException ex) { - throw new IllegalStateException("Failed to serialize file event payload", ex); - } - } - private String resolveClientId(String explicitClientId) { if (StringUtils.hasText(explicitClientId)) { - return normalizeClientId(explicitClientId); + return explicitClientId.trim(); } ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); @@ -162,79 +97,7 @@ public class FileEventService { return null; } HttpServletRequest request = attributes.getRequest(); - return normalizeClientId(request.getHeader(CLIENT_ID_HEADER)); - } - - private String normalizeClientId(String clientId) { - if (!StringUtils.hasText(clientId)) { - return null; - } - String cleaned = clientId.trim(); - return cleaned.isEmpty() ? null : cleaned; - } - - private String normalizePath(String path) { - if (!StringUtils.hasText(path)) { - return "/"; - } - - String cleaned = path.trim().replace("\\", "/"); - while (cleaned.contains("//")) { - cleaned = cleaned.replace("//", "/"); - } - if (!cleaned.startsWith("/")) { - cleaned = "/" + cleaned; - } - if (cleaned.length() > 1 && cleaned.endsWith("/")) { - cleaned = cleaned.substring(0, cleaned.length() - 1); - } - return cleaned; - } - - private boolean isPathMatch(String filterPath, String eventPath) { - if (!StringUtils.hasText(filterPath) || "/".equals(filterPath)) { - return true; - } - if (!StringUtils.hasText(eventPath)) { - return false; - } - return Objects.equals(filterPath, eventPath) || eventPath.startsWith(filterPath + "/"); - } - - private Map createReadyPayload(String path, String clientId) { - Map payload = new LinkedHashMap<>(); - payload.put("eventType", READY_EVENT_NAME); - payload.put("path", path); - payload.put("clientId", clientId); - payload.put("createdAt", LocalDateTime.now()); - return payload; - } - - private final class Subscription { - private final SseEmitter emitter; - private final String path; - private final String clientId; - - private Subscription(SseEmitter emitter, String path, String clientId) { - this.emitter = emitter; - this.path = path; - this.clientId = clientId; - } - - private boolean matches(FileEvent event) { - boolean pathMatches; - if (event.getFromPath() != null && event.getToPath() != null) { - pathMatches = FileEventService.this.isPathMatch(path, event.getFromPath()) - || FileEventService.this.isPathMatch(path, event.getToPath()); - } else { - String eventPath = event.getToPath() != null ? event.getToPath() : event.getFromPath(); - pathMatches = FileEventService.this.isPathMatch(path, eventPath); - } - - if (!pathMatches) { - return false; - } - return clientId == null || event.getClientId() == null || !clientId.equals(event.getClientId()); - } + String requestClientId = request.getHeader(CLIENT_ID_HEADER); + return StringUtils.hasText(requestClientId) ? requestClientId.trim() : null; } } diff --git a/backend/src/main/java/com/yoyuzh/files/events/NoOpFileEventCrossInstancePublisher.java b/backend/src/main/java/com/yoyuzh/files/events/NoOpFileEventCrossInstancePublisher.java new file mode 100644 index 0000000..abda6cc --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/events/NoOpFileEventCrossInstancePublisher.java @@ -0,0 +1,14 @@ +package com.yoyuzh.files.events; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +@Service +@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "false", matchIfMissing = true) +public class NoOpFileEventCrossInstancePublisher implements FileEventCrossInstancePublisher { + + @Override + public void publish(FileEvent event) { + // Redis disabled: keep single-instance in-memory broadcast behavior. + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/events/RedisFileEventPubSubListener.java b/backend/src/main/java/com/yoyuzh/files/events/RedisFileEventPubSubListener.java new file mode 100644 index 0000000..8d65515 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/events/RedisFileEventPubSubListener.java @@ -0,0 +1,88 @@ +package com.yoyuzh.files.events; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.config.AppRedisProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; + +@Component +@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true") +public class RedisFileEventPubSubListener implements MessageListener { + + private final ObjectMapper objectMapper; + private final AppRedisProperties redisProperties; + private final FileEventService fileEventService; + private final String instanceId; + + @Autowired + public RedisFileEventPubSubListener(ObjectMapper objectMapper, + AppRedisProperties redisProperties, + FileEventService fileEventService, + FileEventInstanceIdentity instanceIdentity) { + this(objectMapper, redisProperties, fileEventService, instanceIdentity.getInstanceId()); + } + + RedisFileEventPubSubListener(ObjectMapper objectMapper, + AppRedisProperties redisProperties, + FileEventService fileEventService, + String instanceId) { + this.objectMapper = objectMapper; + this.redisProperties = redisProperties; + this.fileEventService = fileEventService; + this.instanceId = instanceId; + } + + @Override + public void onMessage(Message message, byte[] pattern) { + String payload = new String(message.getBody(), StandardCharsets.UTF_8); + if (!StringUtils.hasText(payload)) { + return; + } + FileEventPubSubMessage pubSubMessage; + try { + pubSubMessage = parsePayload(payload); + } catch (IllegalStateException ex) { + return; + } + if (instanceId.equals(pubSubMessage.originInstanceId())) { + return; + } + fileEventService.broadcastReplicatedEvent(toEvent(pubSubMessage)); + } + + String buildTopic() { + return redisProperties.getKeyPrefix() + + ":" + + redisProperties.getNamespaces().getFileEvents() + + ":pubsub"; + } + + private FileEventPubSubMessage parsePayload(String payload) { + try { + return objectMapper.readValue(payload, FileEventPubSubMessage.class); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("Failed to parse file event pub/sub payload", ex); + } + } + + private FileEvent toEvent(FileEventPubSubMessage message) { + FileEvent event = new FileEvent(); + event.setId(message.eventId()); + event.setUserId(message.userId()); + event.setEventType(message.eventType()); + event.setFileId(message.fileId()); + event.setFromPath(message.fromPath()); + event.setToPath(message.toPath()); + event.setClientId(message.clientId()); + event.setPayloadJson(message.payloadJson()); + event.setCreatedAt(message.createdAt()); + return event; + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/events/RedisFileEventPubSubPublisher.java b/backend/src/main/java/com/yoyuzh/files/events/RedisFileEventPubSubPublisher.java new file mode 100644 index 0000000..e02425d --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/events/RedisFileEventPubSubPublisher.java @@ -0,0 +1,71 @@ +package com.yoyuzh.files.events; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.config.AppRedisProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true") +public class RedisFileEventPubSubPublisher implements FileEventCrossInstancePublisher { + + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper; + private final AppRedisProperties redisProperties; + private final String instanceId; + + @Autowired + public RedisFileEventPubSubPublisher(StringRedisTemplate stringRedisTemplate, + ObjectMapper objectMapper, + AppRedisProperties redisProperties, + FileEventInstanceIdentity instanceIdentity) { + this(stringRedisTemplate, objectMapper, redisProperties, instanceIdentity.getInstanceId()); + } + + RedisFileEventPubSubPublisher(StringRedisTemplate stringRedisTemplate, + ObjectMapper objectMapper, + AppRedisProperties redisProperties, + String instanceId) { + this.stringRedisTemplate = stringRedisTemplate; + this.objectMapper = objectMapper; + this.redisProperties = redisProperties; + this.instanceId = instanceId; + } + + @Override + public void publish(FileEvent event) { + try { + stringRedisTemplate.convertAndSend( + buildTopic(), + objectMapper.writeValueAsString(toMessage(event)) + ); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("Failed to serialize file event pub/sub payload", ex); + } + } + + String buildTopic() { + return redisProperties.getKeyPrefix() + + ":" + + redisProperties.getNamespaces().getFileEvents() + + ":pubsub"; + } + + private FileEventPubSubMessage toMessage(FileEvent event) { + return new FileEventPubSubMessage( + instanceId, + event.getId(), + event.getUserId(), + event.getEventType(), + event.getFileId(), + event.getFromPath(), + event.getToPath(), + event.getClientId(), + event.getPayloadJson(), + event.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/ArchiveBackgroundTaskHandler.java b/backend/src/main/java/com/yoyuzh/files/tasks/ArchiveBackgroundTaskHandler.java index 1ba11c7..747f4cc 100644 --- a/backend/src/main/java/com/yoyuzh/files/tasks/ArchiveBackgroundTaskHandler.java +++ b/backend/src/main/java/com/yoyuzh/files/tasks/ArchiveBackgroundTaskHandler.java @@ -1,8 +1,5 @@ package com.yoyuzh.files.tasks; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import com.yoyuzh.auth.User; import com.yoyuzh.auth.UserRepository; import com.yoyuzh.files.core.FileMetadataResponse; @@ -23,16 +20,16 @@ public class ArchiveBackgroundTaskHandler implements BackgroundTaskHandler { private final StoredFileRepository storedFileRepository; private final UserRepository userRepository; private final FileService fileService; - private final ObjectMapper objectMapper; + private final BackgroundTaskStateManager stateManager; public ArchiveBackgroundTaskHandler(StoredFileRepository storedFileRepository, UserRepository userRepository, FileService fileService, - ObjectMapper objectMapper) { + BackgroundTaskStateManager stateManager) { this.storedFileRepository = storedFileRepository; this.userRepository = userRepository; this.fileService = fileService; - this.objectMapper = objectMapper; + this.stateManager = stateManager; } @Override @@ -48,10 +45,14 @@ public class ArchiveBackgroundTaskHandler implements BackgroundTaskHandler { @Override public BackgroundTaskHandlerResult handle(BackgroundTask task, BackgroundTaskProgressReporter progressReporter) { - Map state = parseState(task.getPrivateStateJson(), task.getPublicStateJson()); - Long fileId = extractLong(state.get("fileId")); - String outputPath = extractText(state.get("outputPath")); - String outputFilename = extractText(state.get("outputFilename")); + Map state = stateManager.mergeJsonObjects( + task.getPublicStateJson(), + task.getPrivateStateJson(), + "archive task state is invalid" + ); + Long fileId = stateManager.readLong(state.get("fileId")); + String outputPath = stateManager.readText(state.get("outputPath")); + String outputFilename = stateManager.readText(state.get("outputFilename")); if (fileId == null) { throw new IllegalStateException("archive task missing fileId"); } @@ -127,38 +128,4 @@ public class ArchiveBackgroundTaskHandler implements BackgroundTaskHandler { return Math.min(100, (int) Math.floor((processed * 100.0d) / total)); } - private Map parseState(String privateStateJson, String publicStateJson) { - Map state = new LinkedHashMap<>(parseJsonObject(publicStateJson)); - state.putAll(parseJsonObject(privateStateJson)); - return state; - } - - private Map parseJsonObject(String json) { - if (!StringUtils.hasText(json)) { - return Map.of(); - } - try { - return objectMapper.readValue(json, new TypeReference>() { - }); - } catch (JsonProcessingException ex) { - throw new IllegalStateException("archive task state is invalid", ex); - } - } - - private Long extractLong(Object value) { - if (value instanceof Number number) { - return number.longValue(); - } - if (value instanceof String text && StringUtils.hasText(text)) { - return Long.parseLong(text.trim()); - } - return null; - } - - private String extractText(Object value) { - if (value instanceof String text && StringUtils.hasText(text)) { - return text.trim(); - } - return null; - } } diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTask.java b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTask.java index 369e290..abfa94d 100644 --- a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTask.java +++ b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTask.java @@ -11,6 +11,7 @@ import jakarta.persistence.Index; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import java.time.LocalDateTime; @@ -18,8 +19,9 @@ import java.time.LocalDateTime; @Table(name = "portal_background_task", indexes = { @Index(name = "idx_background_task_user_created_at", columnList = "user_id,created_at"), @Index(name = "idx_background_task_status_created_at", columnList = "status,created_at"), - @Index(name = "idx_background_task_status_lease_expires_at", columnList = "status,lease_expires_at"), - @Index(name = "idx_background_task_correlation_id", columnList = "correlation_id") + @Index(name = "idx_background_task_status_lease_expires_at", columnList = "status,lease_expires_at") +}, uniqueConstraints = { + @UniqueConstraint(name = "uk_background_task_correlation_id", columnNames = "correlation_id") }) public class BackgroundTask { diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskCommandService.java b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskCommandService.java new file mode 100644 index 0000000..928ded2 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskCommandService.java @@ -0,0 +1,55 @@ +package com.yoyuzh.files.tasks; + +import com.yoyuzh.auth.User; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class BackgroundTaskCommandService { + + private final BackgroundTaskService backgroundTaskService; + + public BackgroundTask createQueuedFileTask(User user, + BackgroundTaskType type, + Long fileId, + String requestedPath, + String correlationId) { + return backgroundTaskService.createQueuedFileTask(user, type, fileId, requestedPath, correlationId); + } + + public Optional createQueuedAutoMediaMetadataTask(Long userId, + Long fileId, + String correlationId) { + return backgroundTaskService.createQueuedAutoMediaMetadataTask(userId, fileId, correlationId); + } + + public BackgroundTask createQueuedTask(User user, + BackgroundTaskType type, + Map publicState, + Map privateState, + String correlationId) { + return backgroundTaskService.createQueuedTask(user, type, publicState, privateState, correlationId); + } + + public Page listOwnedTasks(User user, Pageable pageable) { + return backgroundTaskService.listOwnedTasks(user, pageable); + } + + public BackgroundTask getOwnedTask(User user, Long id) { + return backgroundTaskService.getOwnedTask(user, id); + } + + public BackgroundTask cancelOwnedTask(User user, Long id) { + return backgroundTaskService.cancelOwnedTask(user, id); + } + + public BackgroundTask retryOwnedTask(User user, Long id) { + return backgroundTaskService.retryOwnedTask(user, id); + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskExecutionService.java b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskExecutionService.java new file mode 100644 index 0000000..d4bcb01 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskExecutionService.java @@ -0,0 +1,243 @@ +package com.yoyuzh.files.tasks; + +import com.yoyuzh.api.v2.ApiV2ErrorCode; +import com.yoyuzh.api.v2.ApiV2Exception; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class BackgroundTaskExecutionService { + + private static final List RETRY_TRANSIENT_STATE_KEYS = List.of( + BackgroundTaskStateKeys.RETRY_SCHEDULED, + BackgroundTaskStateKeys.NEXT_RETRY_AT, + BackgroundTaskStateKeys.RETRY_DELAY_SECONDS, + BackgroundTaskStateKeys.LAST_FAILURE_MESSAGE, + BackgroundTaskStateKeys.LAST_FAILURE_AT, + BackgroundTaskStateKeys.FAILURE_CATEGORY + ); + private static final List RUNNING_TRANSIENT_STATE_KEYS = List.of( + BackgroundTaskStateKeys.WORKER_OWNER, + BackgroundTaskStateKeys.LEASE_EXPIRES_AT + ); + private static final int EXPIRED_RUNNING_TASK_BATCH_SIZE = 100; + + private final BackgroundTaskRepository backgroundTaskRepository; + private final BackgroundTaskRetryPolicy retryPolicy; + private final BackgroundTaskStateManager stateManager; + + @Transactional + public int requeueExpiredRunningTasks() { + LocalDateTime now = LocalDateTime.now(); + int recovered = 0; + for (Long taskId : backgroundTaskRepository.findExpiredRunningTaskIds( + BackgroundTaskStatus.RUNNING, + now, + PageRequest.of(0, EXPIRED_RUNNING_TASK_BATCH_SIZE) + )) { + int requeued = backgroundTaskRepository.requeueExpiredRunningTask( + taskId, + BackgroundTaskStatus.RUNNING, + BackgroundTaskStatus.QUEUED, + now, + now + ); + if (requeued != 1) { + continue; + } + BackgroundTask task = backgroundTaskRepository.findById(taskId) + .orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found")); + resetTaskToQueued(task); + backgroundTaskRepository.save(task); + recovered += 1; + } + return recovered; + } + + public List findQueuedTaskIds(int limit) { + if (limit <= 0) { + return List.of(); + } + return backgroundTaskRepository.findReadyTaskIdsByStatusOrder( + BackgroundTaskStatus.QUEUED, + LocalDateTime.now(), + PageRequest.of(0, limit) + ); + } + + @Transactional + public Optional claimQueuedTask(Long id, String workerOwner, long leaseDurationSeconds) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime leaseExpiresAt = now.plusSeconds(Math.max(30L, leaseDurationSeconds)); + int claimed = backgroundTaskRepository.claimQueuedTask( + id, + BackgroundTaskStatus.QUEUED, + BackgroundTaskStatus.RUNNING, + workerOwner, + leaseExpiresAt, + now, + now + ); + if (claimed != 1) { + return Optional.empty(); + } + Optional task = backgroundTaskRepository.findById(id); + task.ifPresent(claimedTask -> { + claimedTask.setLeaseOwner(workerOwner); + claimedTask.setLeaseExpiresAt(leaseExpiresAt); + claimedTask.setHeartbeatAt(now); + claimedTask.setPublicStateJson(stateManager.merge( + claimedTask.getPublicStateJson(), + stateManager.runningStatePatch(claimedTask, workerOwner, now, leaseExpiresAt, true), + RETRY_TRANSIENT_STATE_KEYS + )); + }); + task.ifPresent(backgroundTaskRepository::save); + return task; + } + + @Transactional + public BackgroundTask markWorkerTaskProgress(Long id, + String workerOwner, + Map publicStatePatch, + long leaseDurationSeconds) { + LeaseTouch leaseTouch = refreshLease(id, workerOwner, leaseDurationSeconds); + BackgroundTask task = backgroundTaskRepository.findById(id) + .orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found")); + task.setLeaseOwner(workerOwner); + task.setLeaseExpiresAt(leaseTouch.leaseExpiresAt()); + task.setHeartbeatAt(leaseTouch.now()); + Map nextPatch = new LinkedHashMap<>(stateManager.runningStatePatch( + task, + workerOwner, + leaseTouch.now(), + leaseTouch.leaseExpiresAt(), + false + )); + if (publicStatePatch != null) { + nextPatch.putAll(publicStatePatch); + } + task.setPublicStateJson(stateManager.merge(task.getPublicStateJson(), nextPatch)); + return backgroundTaskRepository.save(task); + } + + @Transactional + public BackgroundTask markWorkerTaskCompleted(Long id, + String workerOwner, + Map publicStatePatch, + long leaseDurationSeconds) { + LeaseTouch leaseTouch = refreshLease(id, workerOwner, leaseDurationSeconds); + BackgroundTask task = backgroundTaskRepository.findById(id) + .orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found")); + task.setPublicStateJson(stateManager.merge( + task.getPublicStateJson(), + stateManager.completedStatePatch(task, leaseTouch.now(), publicStatePatch), + stateManager.removableKeys(RETRY_TRANSIENT_STATE_KEYS, RUNNING_TRANSIENT_STATE_KEYS) + )); + task.setStatus(BackgroundTaskStatus.COMPLETED); + task.setNextRunAt(null); + clearLease(task); + task.setFinishedAt(LocalDateTime.now()); + task.setErrorMessage(null); + return backgroundTaskRepository.save(task); + } + + @Transactional + public BackgroundTask markWorkerTaskFailed(Long id, + String workerOwner, + String errorMessage, + BackgroundTaskFailureCategory failureCategory, + long leaseDurationSeconds) { + LeaseTouch leaseTouch = refreshLease(id, workerOwner, leaseDurationSeconds); + BackgroundTask task = backgroundTaskRepository.findById(id) + .orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found")); + String normalizedErrorMessage = StringUtils.hasText(errorMessage) ? errorMessage.trim() : "task failed"; + LocalDateTime now = leaseTouch.now(); + if (failureCategory.isRetryable() && retryPolicy.hasRemainingAttempts(task)) { + long retryDelaySeconds = retryPolicy.resolveRetryDelaySeconds(task.getType(), failureCategory, task.getAttemptCount()); + LocalDateTime nextRunAt = now.plusSeconds(retryDelaySeconds); + task.setStatus(BackgroundTaskStatus.QUEUED); + task.setNextRunAt(nextRunAt); + clearLease(task); + task.setFinishedAt(null); + task.setErrorMessage(null); + task.setPublicStateJson(stateManager.merge( + task.getPublicStateJson(), + stateManager.retryQueuedStatePatch( + task, + normalizedErrorMessage, + failureCategory, + nextRunAt, + retryDelaySeconds, + now + ), + RUNNING_TRANSIENT_STATE_KEYS + )); + return backgroundTaskRepository.save(task); + } + + task.setNextRunAt(null); + clearLease(task); + task.setPublicStateJson(stateManager.merge( + task.getPublicStateJson(), + stateManager.failedStatePatch(task, normalizedErrorMessage, failureCategory, now), + stateManager.removableKeys( + List.of( + BackgroundTaskStateKeys.RETRY_SCHEDULED, + BackgroundTaskStateKeys.NEXT_RETRY_AT, + BackgroundTaskStateKeys.RETRY_DELAY_SECONDS + ), + RUNNING_TRANSIENT_STATE_KEYS + ) + )); + task.setStatus(BackgroundTaskStatus.FAILED); + task.setFinishedAt(now); + task.setErrorMessage(normalizedErrorMessage); + return backgroundTaskRepository.save(task); + } + + private void resetTaskToQueued(BackgroundTask task) { + task.setNextRunAt(null); + clearLease(task); + task.setPublicStateJson(stateManager.resetPublicStateForRetry(task.getPrivateStateJson(), task.getAttemptCount(), task.getMaxAttempts())); + task.setStatus(BackgroundTaskStatus.QUEUED); + task.setFinishedAt(null); + task.setErrorMessage(null); + } + + private LeaseTouch refreshLease(Long id, String workerOwner, long leaseDurationSeconds) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime leaseExpiresAt = now.plusSeconds(Math.max(30L, leaseDurationSeconds)); + int refreshed = backgroundTaskRepository.refreshRunningTaskLease( + id, + BackgroundTaskStatus.RUNNING, + workerOwner, + leaseExpiresAt, + now, + now + ); + if (refreshed != 1) { + throw new BackgroundTaskLeaseLostException(id, workerOwner); + } + return new LeaseTouch(now, leaseExpiresAt); + } + + private void clearLease(BackgroundTask task) { + task.setLeaseOwner(null); + task.setLeaseExpiresAt(null); + task.setHeartbeatAt(null); + } + + private record LeaseTouch(LocalDateTime now, LocalDateTime leaseExpiresAt) { + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskRetryPolicy.java b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskRetryPolicy.java new file mode 100644 index 0000000..a154a61 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskRetryPolicy.java @@ -0,0 +1,41 @@ +package com.yoyuzh.files.tasks; + +import org.springframework.stereotype.Component; + +@Component +public class BackgroundTaskRetryPolicy { + + public int resolveMaxAttempts(BackgroundTaskType type) { + return switch (type) { + case ARCHIVE -> 4; + case EXTRACT -> 3; + case MEDIA_META -> 2; + default -> 1; + }; + } + + public boolean hasRemainingAttempts(BackgroundTask task) { + return task.getAttemptCount() != null + && task.getMaxAttempts() != null + && task.getAttemptCount() < task.getMaxAttempts(); + } + + public long resolveRetryDelaySeconds(BackgroundTaskType type, + BackgroundTaskFailureCategory failureCategory, + Integer attemptCount) { + int safeAttemptCount = attemptCount == null ? 1 : Math.max(1, attemptCount); + long baseDelaySeconds = switch (type) { + case ARCHIVE -> 30L; + case EXTRACT -> 45L; + case MEDIA_META -> 15L; + default -> 30L; + }; + if (failureCategory == BackgroundTaskFailureCategory.RATE_LIMITED) { + baseDelaySeconds *= 4L; + } else if (failureCategory == BackgroundTaskFailureCategory.UNKNOWN) { + baseDelaySeconds *= 2L; + } + long delay = baseDelaySeconds * (1L << Math.min(safeAttemptCount - 1, 2)); + return Math.min(delay, baseDelaySeconds * 4L); + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskService.java b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskService.java index 9da562d..66ee64e 100644 --- a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskService.java +++ b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskService.java @@ -1,21 +1,20 @@ package com.yoyuzh.files.tasks; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import com.yoyuzh.api.v2.ApiV2ErrorCode; import com.yoyuzh.api.v2.ApiV2Exception; import com.yoyuzh.auth.User; +import com.yoyuzh.common.lock.DistributedLockService; import com.yoyuzh.files.core.StoredFile; import com.yoyuzh.files.core.StoredFileRepository; import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; +import java.time.Duration; import java.time.LocalDateTime; import java.util.LinkedHashMap; import java.util.List; @@ -25,29 +24,23 @@ import java.util.Optional; import java.util.UUID; @Service -@RequiredArgsConstructor public class BackgroundTaskService { - static final String STATE_PHASE_KEY = "phase"; - static final String STATE_ATTEMPT_COUNT_KEY = "attemptCount"; - static final String STATE_MAX_ATTEMPTS_KEY = "maxAttempts"; - static final String STATE_RETRY_SCHEDULED_KEY = "retryScheduled"; - static final String STATE_NEXT_RETRY_AT_KEY = "nextRetryAt"; - static final String STATE_RETRY_DELAY_SECONDS_KEY = "retryDelaySeconds"; - static final String STATE_LAST_FAILURE_MESSAGE_KEY = "lastFailureMessage"; - static final String STATE_LAST_FAILURE_AT_KEY = "lastFailureAt"; - static final String STATE_FAILURE_CATEGORY_KEY = "failureCategory"; - static final String STATE_WORKER_OWNER_KEY = "workerOwner"; - static final String STATE_HEARTBEAT_AT_KEY = "heartbeatAt"; - static final String STATE_LEASE_EXPIRES_AT_KEY = "leaseExpiresAt"; - static final String STATE_STARTED_AT_KEY = "startedAt"; + static final String STATE_PHASE_KEY = BackgroundTaskStateKeys.PHASE; + static final String STATE_ATTEMPT_COUNT_KEY = BackgroundTaskStateKeys.ATTEMPT_COUNT; + static final String STATE_MAX_ATTEMPTS_KEY = BackgroundTaskStateKeys.MAX_ATTEMPTS; + static final String STATE_RETRY_SCHEDULED_KEY = BackgroundTaskStateKeys.RETRY_SCHEDULED; + static final String STATE_NEXT_RETRY_AT_KEY = BackgroundTaskStateKeys.NEXT_RETRY_AT; + static final String STATE_RETRY_DELAY_SECONDS_KEY = BackgroundTaskStateKeys.RETRY_DELAY_SECONDS; + static final String STATE_LAST_FAILURE_MESSAGE_KEY = BackgroundTaskStateKeys.LAST_FAILURE_MESSAGE; + static final String STATE_LAST_FAILURE_AT_KEY = BackgroundTaskStateKeys.LAST_FAILURE_AT; + static final String STATE_FAILURE_CATEGORY_KEY = BackgroundTaskStateKeys.FAILURE_CATEGORY; + static final String STATE_WORKER_OWNER_KEY = BackgroundTaskStateKeys.WORKER_OWNER; + static final String STATE_HEARTBEAT_AT_KEY = BackgroundTaskStateKeys.HEARTBEAT_AT; + static final String STATE_LEASE_EXPIRES_AT_KEY = BackgroundTaskStateKeys.LEASE_EXPIRES_AT; + static final String STATE_STARTED_AT_KEY = BackgroundTaskStateKeys.STARTED_AT; private static final List ZIP_COMPATIBLE_EXTENSIONS = List.of(".zip", ".jar", ".war"); - private static final List MEDIA_EXTENSIONS = List.of( - ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg", - ".mp4", ".mov", ".mkv", ".webm", ".avi", - ".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a" - ); private static final List RETRY_TRANSIENT_STATE_KEYS = List.of( STATE_RETRY_SCHEDULED_KEY, STATE_NEXT_RETRY_AT_KEY, @@ -60,11 +53,43 @@ public class BackgroundTaskService { STATE_WORKER_OWNER_KEY, STATE_LEASE_EXPIRES_AT_KEY ); - private static final int EXPIRED_RUNNING_TASK_BATCH_SIZE = 100; + private static final Duration CORRELATION_LOCK_TTL = Duration.ofSeconds(5); private final BackgroundTaskRepository backgroundTaskRepository; private final StoredFileRepository storedFileRepository; - private final ObjectMapper objectMapper; + private final DistributedLockService distributedLockService; + private final BackgroundTaskRetryPolicy retryPolicy; + private final BackgroundTaskStateManager stateManager; + + @Autowired + public BackgroundTaskService(BackgroundTaskRepository backgroundTaskRepository, + StoredFileRepository storedFileRepository, + com.fasterxml.jackson.databind.ObjectMapper objectMapper, + DistributedLockService distributedLockService, + BackgroundTaskRetryPolicy retryPolicy, + BackgroundTaskStateManager stateManager) { + this.backgroundTaskRepository = backgroundTaskRepository; + this.storedFileRepository = storedFileRepository; + this.distributedLockService = distributedLockService == null + ? DistributedLockService.noOp() + : distributedLockService; + this.retryPolicy = retryPolicy == null ? new BackgroundTaskRetryPolicy() : retryPolicy; + this.stateManager = stateManager == null ? new BackgroundTaskStateManager(objectMapper) : stateManager; + } + + BackgroundTaskService(BackgroundTaskRepository backgroundTaskRepository, + StoredFileRepository storedFileRepository, + com.fasterxml.jackson.databind.ObjectMapper objectMapper, + DistributedLockService distributedLockService) { + this( + backgroundTaskRepository, + storedFileRepository, + objectMapper, + distributedLockService, + new BackgroundTaskRetryPolicy(), + new BackgroundTaskStateManager(objectMapper) + ); + } @Transactional public BackgroundTask createQueuedFileTask(User user, @@ -78,27 +103,40 @@ public class BackgroundTaskService { if (!logicalPath.equals(normalizeLogicalPath(requestedPath))) { throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "task path does not match file path"); } - validateTaskTarget(type, file); + return createQueuedFileTaskInternal(user.getId(), type, file, correlationId, false); + } - Map publicState = fileState(file, logicalPath); - Map privateState = new LinkedHashMap<>(publicState); - privateState.put("taskType", type.name()); - if (type == BackgroundTaskType.ARCHIVE) { - String outputPath = file.getPath(); - String outputFilename = file.getFilename() + ".zip"; - publicState.put("outputPath", outputPath); - publicState.put("outputFilename", outputFilename); - privateState.put("outputPath", outputPath); - privateState.put("outputFilename", outputFilename); - } else if (type == BackgroundTaskType.EXTRACT) { - String outputPath = file.getPath(); - String outputDirectoryName = deriveExtractOutputDirectoryName(file.getFilename()); - publicState.put("outputPath", outputPath); - publicState.put("outputDirectoryName", outputDirectoryName); - privateState.put("outputPath", outputPath); - privateState.put("outputDirectoryName", outputDirectoryName); + @Transactional + public Optional createQueuedAutoMediaMetadataTask(Long userId, + Long fileId, + String correlationId) { + String normalizedCorrelationId = StringUtils.hasText(correlationId) + ? correlationId.trim() + : "media-meta:auto:file:" + fileId; + try { + return distributedLockService.executeWithLock( + correlationLockName(normalizedCorrelationId), + CORRELATION_LOCK_TTL, + () -> { + if (backgroundTaskRepository.existsByCorrelationId(normalizedCorrelationId)) { + return Optional.empty(); + } + + return storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(fileId, userId) + .filter(file -> !file.isDirectory()) + .filter(file -> MediaTaskSupport.isMediaLike(file.getFilename(), file.getContentType())) + .map(file -> createQueuedFileTaskInternal( + userId, + BackgroundTaskType.MEDIA_META, + file, + normalizedCorrelationId, + true + )); + } + ); + } catch (DataIntegrityViolationException ex) { + return Optional.empty(); } - return createQueuedTask(user, type, publicState, privateState, correlationId); } @Transactional @@ -107,20 +145,36 @@ public class BackgroundTaskService { Map publicState, Map privateState, String correlationId) { + return createQueuedTask(user.getId(), type, publicState, privateState, correlationId); + } + + private BackgroundTask createQueuedTask(Long userId, + BackgroundTaskType type, + Map publicState, + Map privateState, + String correlationId) { + return createQueuedTask(userId, type, publicState, privateState, correlationId, false); + } + + private BackgroundTask createQueuedTask(Long userId, + BackgroundTaskType type, + Map publicState, + Map privateState, + String correlationId, + boolean flushOnSave) { BackgroundTask task = new BackgroundTask(); - task.setUserId(user.getId()); + task.setUserId(userId); task.setType(type); task.setStatus(BackgroundTaskStatus.QUEUED); task.setAttemptCount(0); - task.setMaxAttempts(resolveMaxAttempts(type)); + task.setMaxAttempts(retryPolicy.resolveMaxAttempts(type)); task.setNextRunAt(null); - Map nextPublicState = new LinkedHashMap<>(publicState == null ? Map.of() : publicState); - nextPublicState.put(STATE_PHASE_KEY, "queued"); - nextPublicState.putAll(retryStatePatch(task.getAttemptCount(), task.getMaxAttempts())); - task.setPublicStateJson(toJson(nextPublicState)); - task.setPrivateStateJson(toJson(privateState)); + task.setPublicStateJson(stateManager.createInitialPublicState(publicState, task.getAttemptCount(), task.getMaxAttempts())); + task.setPrivateStateJson(stateManager.toJson(privateState)); task.setCorrelationId(normalizeCorrelationId(correlationId)); - return backgroundTaskRepository.save(task); + return flushOnSave + ? backgroundTaskRepository.saveAndFlush(task) + : backgroundTaskRepository.save(task); } public Page listOwnedTasks(User user, Pageable pageable) { @@ -143,15 +197,10 @@ public class BackgroundTaskService { task.setStatus(BackgroundTaskStatus.CANCELLED); task.setNextRunAt(null); clearLease(task); - task.setPublicStateJson(mergePublicStateJson( + task.setPublicStateJson(stateManager.merge( task.getPublicStateJson(), - Map.of( - STATE_PHASE_KEY, "cancelled", - STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(), - STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts(), - STATE_HEARTBEAT_AT_KEY, LocalDateTime.now().toString() - ), - removableStateKeys(RETRY_TRANSIENT_STATE_KEYS, RUNNING_TRANSIENT_STATE_KEYS) + stateManager.cancelledStatePatch(task, LocalDateTime.now()), + stateManager.removableKeys(RETRY_TRANSIENT_STATE_KEYS, RUNNING_TRANSIENT_STATE_KEYS) )); task.setFinishedAt(LocalDateTime.now()); task.setErrorMessage(null); @@ -171,7 +220,7 @@ public class BackgroundTaskService { task.setAttemptCount(0); task.setNextRunAt(null); clearLease(task); - task.setPublicStateJson(resetPublicStateForRetry(task.getPrivateStateJson(), task.getAttemptCount(), task.getMaxAttempts())); + task.setPublicStateJson(stateManager.resetPublicStateForRetry(task.getPrivateStateJson(), task.getAttemptCount(), task.getMaxAttempts())); task.setStatus(BackgroundTaskStatus.QUEUED); task.setFinishedAt(null); task.setErrorMessage(null); @@ -185,7 +234,7 @@ public class BackgroundTaskService { return task; } task.setStatus(BackgroundTaskStatus.RUNNING); - task.setPublicStateJson(mergePublicStateJson( + task.setPublicStateJson(stateManager.merge( task.getPublicStateJson(), Map.of( STATE_PHASE_KEY, "running", @@ -206,16 +255,11 @@ public class BackgroundTaskService { task.setStatus(BackgroundTaskStatus.COMPLETED); task.setNextRunAt(null); clearLease(task); - task.setPublicStateJson(mergePublicStateJson( + task.setPublicStateJson(stateManager.merge( task.getPublicStateJson(), - Map.of( - STATE_PHASE_KEY, "completed", - STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(), - STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts(), - STATE_HEARTBEAT_AT_KEY, LocalDateTime.now().toString() - ), - removableStateKeys(RETRY_TRANSIENT_STATE_KEYS, RUNNING_TRANSIENT_STATE_KEYS) - )); + stateManager.completedStatePatch(task, LocalDateTime.now(), null), + stateManager.removableKeys(RETRY_TRANSIENT_STATE_KEYS, RUNNING_TRANSIENT_STATE_KEYS) + )); task.setFinishedAt(LocalDateTime.now()); task.setErrorMessage(null); return backgroundTaskRepository.save(task); @@ -230,201 +274,18 @@ public class BackgroundTaskService { task.setStatus(BackgroundTaskStatus.FAILED); task.setNextRunAt(null); clearLease(task); - task.setPublicStateJson(mergePublicStateJson( - task.getPublicStateJson(), - Map.of( - STATE_PHASE_KEY, "failed", - STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(), - STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts(), - STATE_LAST_FAILURE_MESSAGE_KEY, StringUtils.hasText(errorMessage) ? errorMessage.trim() : "task failed", - STATE_LAST_FAILURE_AT_KEY, LocalDateTime.now().toString(), - STATE_FAILURE_CATEGORY_KEY, BackgroundTaskFailureCategory.UNKNOWN.name(), - STATE_HEARTBEAT_AT_KEY, LocalDateTime.now().toString() - ), - removableStateKeys(List.of(STATE_RETRY_SCHEDULED_KEY, STATE_NEXT_RETRY_AT_KEY), RUNNING_TRANSIENT_STATE_KEYS) - )); - task.setFinishedAt(LocalDateTime.now()); - task.setErrorMessage(StringUtils.hasText(errorMessage) ? errorMessage.trim() : "task failed"); - return backgroundTaskRepository.save(task); - } - - @Transactional - public int requeueExpiredRunningTasks() { - LocalDateTime now = LocalDateTime.now(); - int recovered = 0; - for (Long taskId : backgroundTaskRepository.findExpiredRunningTaskIds( - BackgroundTaskStatus.RUNNING, - now, - PageRequest.of(0, EXPIRED_RUNNING_TASK_BATCH_SIZE) - )) { - int requeued = backgroundTaskRepository.requeueExpiredRunningTask( - taskId, - BackgroundTaskStatus.RUNNING, - BackgroundTaskStatus.QUEUED, - now, - now - ); - if (requeued != 1) { - continue; - } - BackgroundTask task = backgroundTaskRepository.findById(taskId) - .orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found")); - resetTaskToQueued(task); - backgroundTaskRepository.save(task); - recovered += 1; - } - return recovered; - } - - public List findQueuedTaskIds(int limit) { - if (limit <= 0) { - return List.of(); - } - - return backgroundTaskRepository.findReadyTaskIdsByStatusOrder( - BackgroundTaskStatus.QUEUED, - LocalDateTime.now(), - PageRequest.of(0, limit) - ); - } - - @Transactional - public Optional claimQueuedTask(Long id, String workerOwner, long leaseDurationSeconds) { - LocalDateTime now = LocalDateTime.now(); - LocalDateTime leaseExpiresAt = now.plusSeconds(Math.max(30L, leaseDurationSeconds)); - int claimed = backgroundTaskRepository.claimQueuedTask( - id, - BackgroundTaskStatus.QUEUED, - BackgroundTaskStatus.RUNNING, - workerOwner, - leaseExpiresAt, - now, - now - ); - if (claimed != 1) { - return Optional.empty(); - } - Optional task = backgroundTaskRepository.findById(id); - task.ifPresent(claimedTask -> { - claimedTask.setLeaseOwner(workerOwner); - claimedTask.setLeaseExpiresAt(leaseExpiresAt); - claimedTask.setHeartbeatAt(now); - claimedTask.setPublicStateJson(mergePublicStateJson( - claimedTask.getPublicStateJson(), - runningStatePatch(claimedTask, workerOwner, now, leaseExpiresAt, true), - RETRY_TRANSIENT_STATE_KEYS - )); - }); - task.ifPresent(backgroundTaskRepository::save); - return task; - } - - @Transactional - public BackgroundTask markWorkerTaskProgress(Long id, - String workerOwner, - Map publicStatePatch, - long leaseDurationSeconds) { - LeaseTouch leaseTouch = refreshLease(id, workerOwner, leaseDurationSeconds); - BackgroundTask task = backgroundTaskRepository.findById(id) - .orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found")); - task.setLeaseOwner(workerOwner); - task.setLeaseExpiresAt(leaseTouch.leaseExpiresAt()); - task.setHeartbeatAt(leaseTouch.now()); - Map nextPatch = new LinkedHashMap<>(runningStatePatch( - task, - workerOwner, - leaseTouch.now(), - leaseTouch.leaseExpiresAt(), - false - )); - if (publicStatePatch != null) { - nextPatch.putAll(publicStatePatch); - } - task.setPublicStateJson(mergePublicStateJson(task.getPublicStateJson(), nextPatch)); - return backgroundTaskRepository.save(task); - } - - @Transactional - public BackgroundTask markWorkerTaskCompleted(Long id, - String workerOwner, - Map publicStatePatch, - long leaseDurationSeconds) { - LeaseTouch leaseTouch = refreshLease(id, workerOwner, leaseDurationSeconds); - BackgroundTask task = backgroundTaskRepository.findById(id) - .orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found")); - Map nextPatch = new LinkedHashMap<>(publicStatePatch == null ? Map.of() : publicStatePatch); - nextPatch.put(STATE_PHASE_KEY, "completed"); - nextPatch.put(STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount()); - nextPatch.put(STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts()); - nextPatch.put(STATE_HEARTBEAT_AT_KEY, leaseTouch.now().toString()); - task.setPublicStateJson(mergePublicStateJson( - task.getPublicStateJson(), - nextPatch, - removableStateKeys(RETRY_TRANSIENT_STATE_KEYS, RUNNING_TRANSIENT_STATE_KEYS) - )); - task.setStatus(BackgroundTaskStatus.COMPLETED); - task.setNextRunAt(null); - clearLease(task); - task.setFinishedAt(LocalDateTime.now()); - task.setErrorMessage(null); - return backgroundTaskRepository.save(task); - } - - @Transactional - public BackgroundTask markWorkerTaskFailed(Long id, - String workerOwner, - String errorMessage, - BackgroundTaskFailureCategory failureCategory, - long leaseDurationSeconds) { - LeaseTouch leaseTouch = refreshLease(id, workerOwner, leaseDurationSeconds); - BackgroundTask task = backgroundTaskRepository.findById(id) - .orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found")); String normalizedErrorMessage = StringUtils.hasText(errorMessage) ? errorMessage.trim() : "task failed"; - LocalDateTime now = leaseTouch.now(); - if (failureCategory.isRetryable() && hasRemainingAttempts(task)) { - long retryDelaySeconds = resolveRetryDelaySeconds(task.getType(), failureCategory, task.getAttemptCount()); - LocalDateTime nextRunAt = now.plusSeconds(retryDelaySeconds); - task.setStatus(BackgroundTaskStatus.QUEUED); - task.setNextRunAt(nextRunAt); - clearLease(task); - task.setFinishedAt(null); - task.setErrorMessage(null); - task.setPublicStateJson(mergePublicStateJson( - task.getPublicStateJson(), - Map.of( - STATE_PHASE_KEY, "queued", - STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(), - STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts(), - STATE_RETRY_SCHEDULED_KEY, true, - STATE_NEXT_RETRY_AT_KEY, nextRunAt.toString(), - STATE_RETRY_DELAY_SECONDS_KEY, retryDelaySeconds, - STATE_LAST_FAILURE_MESSAGE_KEY, normalizedErrorMessage, - STATE_LAST_FAILURE_AT_KEY, now.toString(), - STATE_FAILURE_CATEGORY_KEY, failureCategory.name(), - STATE_HEARTBEAT_AT_KEY, now.toString() - ), - RUNNING_TRANSIENT_STATE_KEYS - )); - return backgroundTaskRepository.save(task); - } - - task.setNextRunAt(null); - clearLease(task); - task.setPublicStateJson(mergePublicStateJson( + task.setPublicStateJson(stateManager.merge( task.getPublicStateJson(), - Map.of( - STATE_PHASE_KEY, "failed", - STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(), - STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts(), - STATE_LAST_FAILURE_MESSAGE_KEY, normalizedErrorMessage, - STATE_LAST_FAILURE_AT_KEY, now.toString(), - STATE_FAILURE_CATEGORY_KEY, failureCategory.name(), - STATE_HEARTBEAT_AT_KEY, now.toString() + stateManager.failedStatePatch( + task, + normalizedErrorMessage, + BackgroundTaskFailureCategory.UNKNOWN, + LocalDateTime.now() ), - removableStateKeys(List.of(STATE_RETRY_SCHEDULED_KEY, STATE_NEXT_RETRY_AT_KEY, STATE_RETRY_DELAY_SECONDS_KEY), RUNNING_TRANSIENT_STATE_KEYS) + stateManager.removableKeys(List.of(STATE_RETRY_SCHEDULED_KEY, STATE_NEXT_RETRY_AT_KEY), RUNNING_TRANSIENT_STATE_KEYS) )); - task.setStatus(BackgroundTaskStatus.FAILED); - task.setFinishedAt(now); + task.setFinishedAt(LocalDateTime.now()); task.setErrorMessage(normalizedErrorMessage); return backgroundTaskRepository.save(task); } @@ -436,6 +297,10 @@ public class BackgroundTaskService { return UUID.randomUUID().toString().replace("-", ""); } + private String correlationLockName(String correlationId) { + return "background-task-correlation:" + correlationId; + } + private void validateTaskTarget(BackgroundTaskType type, StoredFile file) { if (type == BackgroundTaskType.ARCHIVE) { return; @@ -446,7 +311,8 @@ public class BackgroundTaskService { if (type == BackgroundTaskType.EXTRACT && !isZipCompatibleArchive(file)) { throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "extract task only supports zip-compatible archives"); } - if (type == BackgroundTaskType.MEDIA_META && !isMediaLike(file)) { + if (type == BackgroundTaskType.MEDIA_META + && !MediaTaskSupport.isMediaLike(file.getFilename(), file.getContentType())) { throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "media metadata task only supports media files"); } } @@ -470,14 +336,6 @@ public class BackgroundTaskService { return hasExtension(file.getFilename(), ZIP_COMPATIBLE_EXTENSIONS); } - private boolean isMediaLike(StoredFile file) { - String contentType = normalizeContentType(file.getContentType()); - if (contentType.startsWith("image/") || contentType.startsWith("video/") || contentType.startsWith("audio/")) { - return true; - } - return hasExtension(file.getFilename(), MEDIA_EXTENSIONS); - } - private String deriveExtractOutputDirectoryName(String filename) { if (!StringUtils.hasText(filename)) { return "extracted"; @@ -511,6 +369,42 @@ public class BackgroundTaskService { return contentType.trim().toLowerCase(Locale.ROOT); } + private BackgroundTask createQueuedFileTaskInternal(Long userId, + BackgroundTaskType type, + StoredFile file, + String correlationId) { + return createQueuedFileTaskInternal(userId, type, file, correlationId, false); + } + + private BackgroundTask createQueuedFileTaskInternal(Long userId, + BackgroundTaskType type, + StoredFile file, + String correlationId, + boolean flushOnSave) { + String logicalPath = buildLogicalPath(file); + validateTaskTarget(type, file); + + Map publicState = fileState(file, logicalPath); + Map privateState = new LinkedHashMap<>(publicState); + privateState.put("taskType", type.name()); + if (type == BackgroundTaskType.ARCHIVE) { + String outputPath = file.getPath(); + String outputFilename = file.getFilename() + ".zip"; + publicState.put("outputPath", outputPath); + publicState.put("outputFilename", outputFilename); + privateState.put("outputPath", outputPath); + privateState.put("outputFilename", outputFilename); + } else if (type == BackgroundTaskType.EXTRACT) { + String outputPath = file.getPath(); + String outputDirectoryName = deriveExtractOutputDirectoryName(file.getFilename()); + publicState.put("outputPath", outputPath); + publicState.put("outputDirectoryName", outputDirectoryName); + privateState.put("outputPath", outputPath); + privateState.put("outputDirectoryName", outputDirectoryName); + } + return createQueuedTask(userId, type, publicState, privateState, correlationId, flushOnSave); + } + private String buildLogicalPath(StoredFile file) { String parent = normalizeLogicalPath(file.getPath()); if ("/".equals(parent)) { @@ -536,148 +430,9 @@ public class BackgroundTaskService { return normalized; } - private String toJson(Map value) { - Map safeValue = value == null ? new LinkedHashMap<>() : new LinkedHashMap<>(value); - try { - return objectMapper.writeValueAsString(safeValue); - } catch (JsonProcessingException ex) { - throw new IllegalStateException("Failed to serialize background task state", ex); - } - } - - private Map parseJsonObject(String value) { - if (!StringUtils.hasText(value)) { - return new LinkedHashMap<>(); - } - - try { - return objectMapper.readValue(value, new TypeReference>() { - }); - } catch (JsonProcessingException ex) { - throw new IllegalStateException("Failed to parse background task state", ex); - } - } - - private String mergePublicStateJson(String currentValue, Map patch) { - return mergePublicStateJson(currentValue, patch, List.of()); - } - - private String mergePublicStateJson(String currentValue, Map patch, List keysToRemove) { - Map nextPublicState = parseJsonObject(currentValue); - if (keysToRemove != null) { - keysToRemove.forEach(nextPublicState::remove); - } - if (patch != null) { - nextPublicState.putAll(patch); - } - return toJson(nextPublicState); - } - - private String resetPublicStateForRetry(String privateStateJson, int attemptCount, int maxAttempts) { - Map nextPublicState = parseJsonObject(privateStateJson); - nextPublicState.remove("taskType"); - nextPublicState.put(STATE_PHASE_KEY, "queued"); - nextPublicState.putAll(retryStatePatch(attemptCount, maxAttempts)); - return toJson(nextPublicState); - } - - private void resetTaskToQueued(BackgroundTask task) { - task.setNextRunAt(null); - clearLease(task); - task.setPublicStateJson(resetPublicStateForRetry(task.getPrivateStateJson(), task.getAttemptCount(), task.getMaxAttempts())); - task.setStatus(BackgroundTaskStatus.QUEUED); - task.setFinishedAt(null); - task.setErrorMessage(null); - } - - private int resolveMaxAttempts(BackgroundTaskType type) { - return switch (type) { - case ARCHIVE -> 4; - case EXTRACT -> 3; - case MEDIA_META -> 2; - default -> 1; - }; - } - - private Map retryStatePatch(int attemptCount, int maxAttempts) { - Map patch = new LinkedHashMap<>(); - patch.put(STATE_ATTEMPT_COUNT_KEY, attemptCount); - patch.put(STATE_MAX_ATTEMPTS_KEY, maxAttempts); - return patch; - } - - private boolean hasRemainingAttempts(BackgroundTask task) { - return task.getAttemptCount() != null - && task.getMaxAttempts() != null - && task.getAttemptCount() < task.getMaxAttempts(); - } - - private long resolveRetryDelaySeconds(BackgroundTaskType type, - BackgroundTaskFailureCategory failureCategory, - Integer attemptCount) { - int safeAttemptCount = attemptCount == null ? 1 : Math.max(1, attemptCount); - long baseDelaySeconds = switch (type) { - case ARCHIVE -> 30L; - case EXTRACT -> 45L; - case MEDIA_META -> 15L; - default -> 30L; - }; - if (failureCategory == BackgroundTaskFailureCategory.RATE_LIMITED) { - baseDelaySeconds *= 4L; - } else if (failureCategory == BackgroundTaskFailureCategory.UNKNOWN) { - baseDelaySeconds *= 2L; - } - long delay = baseDelaySeconds * (1L << Math.min(safeAttemptCount - 1, 2)); - return Math.min(delay, baseDelaySeconds * 4L); - } - - private LeaseTouch refreshLease(Long id, String workerOwner, long leaseDurationSeconds) { - LocalDateTime now = LocalDateTime.now(); - LocalDateTime leaseExpiresAt = now.plusSeconds(Math.max(30L, leaseDurationSeconds)); - int refreshed = backgroundTaskRepository.refreshRunningTaskLease( - id, - BackgroundTaskStatus.RUNNING, - workerOwner, - leaseExpiresAt, - now, - now - ); - if (refreshed != 1) { - throw new BackgroundTaskLeaseLostException(id, workerOwner); - } - return new LeaseTouch(now, leaseExpiresAt); - } - - private Map runningStatePatch(BackgroundTask task, - String workerOwner, - LocalDateTime heartbeatAt, - LocalDateTime leaseExpiresAt, - boolean includeStartedAt) { - Map patch = new LinkedHashMap<>(); - patch.put(STATE_PHASE_KEY, "running"); - patch.put(STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount()); - patch.put(STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts()); - patch.put(STATE_WORKER_OWNER_KEY, workerOwner); - patch.put(STATE_HEARTBEAT_AT_KEY, heartbeatAt.toString()); - patch.put(STATE_LEASE_EXPIRES_AT_KEY, leaseExpiresAt.toString()); - if (includeStartedAt) { - patch.put(STATE_STARTED_AT_KEY, heartbeatAt.toString()); - } - return patch; - } - - private List removableStateKeys(List primary, List secondary) { - List keys = new java.util.ArrayList<>(primary); - keys.addAll(secondary); - return keys; - } - private void clearLease(BackgroundTask task) { task.setLeaseOwner(null); task.setLeaseExpiresAt(null); task.setHeartbeatAt(null); } - - private record LeaseTouch(LocalDateTime now, LocalDateTime leaseExpiresAt) { - } } diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskStartupRecovery.java b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskStartupRecovery.java index dbeb9e6..4178ca5 100644 --- a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskStartupRecovery.java +++ b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskStartupRecovery.java @@ -11,11 +11,11 @@ import org.springframework.stereotype.Component; @Slf4j public class BackgroundTaskStartupRecovery { - private final BackgroundTaskService backgroundTaskService; + private final BackgroundTaskExecutionService backgroundTaskExecutionService; @EventListener(ApplicationReadyEvent.class) public void recoverOnStartup() { - int recovered = backgroundTaskService.requeueExpiredRunningTasks(); + int recovered = backgroundTaskExecutionService.requeueExpiredRunningTasks(); if (recovered > 0) { log.warn("Recovered {} expired RUNNING background task leases back to QUEUED on startup", recovered); } diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskStateKeys.java b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskStateKeys.java new file mode 100644 index 0000000..902bfad --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskStateKeys.java @@ -0,0 +1,21 @@ +package com.yoyuzh.files.tasks; + +public final class BackgroundTaskStateKeys { + + public static final String PHASE = "phase"; + public static final String ATTEMPT_COUNT = "attemptCount"; + public static final String MAX_ATTEMPTS = "maxAttempts"; + public static final String RETRY_SCHEDULED = "retryScheduled"; + public static final String NEXT_RETRY_AT = "nextRetryAt"; + public static final String RETRY_DELAY_SECONDS = "retryDelaySeconds"; + public static final String LAST_FAILURE_MESSAGE = "lastFailureMessage"; + public static final String LAST_FAILURE_AT = "lastFailureAt"; + public static final String FAILURE_CATEGORY = "failureCategory"; + public static final String WORKER_OWNER = "workerOwner"; + public static final String HEARTBEAT_AT = "heartbeatAt"; + public static final String LEASE_EXPIRES_AT = "leaseExpiresAt"; + public static final String STARTED_AT = "startedAt"; + + private BackgroundTaskStateKeys() { + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskStateManager.java b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskStateManager.java new file mode 100644 index 0000000..4a42d09 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskStateManager.java @@ -0,0 +1,185 @@ +package com.yoyuzh.files.tasks; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +public class BackgroundTaskStateManager { + + private static final TypeReference> JSON_OBJECT_TYPE = new TypeReference<>() { + }; + + private final ObjectMapper objectMapper; + + public BackgroundTaskStateManager(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public String toJson(Map value) { + Map safeValue = value == null ? new LinkedHashMap<>() : new LinkedHashMap<>(value); + try { + return objectMapper.writeValueAsString(safeValue); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("Failed to serialize background task state", ex); + } + } + + public Map retryStatePatch(int attemptCount, int maxAttempts) { + Map patch = new LinkedHashMap<>(); + patch.put(BackgroundTaskStateKeys.ATTEMPT_COUNT, attemptCount); + patch.put(BackgroundTaskStateKeys.MAX_ATTEMPTS, maxAttempts); + return patch; + } + + public Map runningStatePatch(BackgroundTask task, + String workerOwner, + LocalDateTime heartbeatAt, + LocalDateTime leaseExpiresAt, + boolean includeStartedAt) { + Map patch = new LinkedHashMap<>(); + patch.put(BackgroundTaskStateKeys.PHASE, "running"); + patch.put(BackgroundTaskStateKeys.ATTEMPT_COUNT, task.getAttemptCount()); + patch.put(BackgroundTaskStateKeys.MAX_ATTEMPTS, task.getMaxAttempts()); + patch.put(BackgroundTaskStateKeys.WORKER_OWNER, workerOwner); + patch.put(BackgroundTaskStateKeys.HEARTBEAT_AT, heartbeatAt.toString()); + patch.put(BackgroundTaskStateKeys.LEASE_EXPIRES_AT, leaseExpiresAt.toString()); + if (includeStartedAt) { + patch.put(BackgroundTaskStateKeys.STARTED_AT, heartbeatAt.toString()); + } + return patch; + } + + public Map cancelledStatePatch(BackgroundTask task, LocalDateTime heartbeatAt) { + return terminalStatePatch("cancelled", task, heartbeatAt); + } + + public Map completedStatePatch(BackgroundTask task, + LocalDateTime heartbeatAt, + Map additionalPatch) { + Map patch = new LinkedHashMap<>(additionalPatch == null ? Map.of() : additionalPatch); + patch.putAll(terminalStatePatch("completed", task, heartbeatAt)); + return patch; + } + + public Map failedStatePatch(BackgroundTask task, + String errorMessage, + BackgroundTaskFailureCategory failureCategory, + LocalDateTime heartbeatAt) { + Map patch = new LinkedHashMap<>(terminalStatePatch("failed", task, heartbeatAt)); + patch.put(BackgroundTaskStateKeys.LAST_FAILURE_MESSAGE, errorMessage); + patch.put(BackgroundTaskStateKeys.LAST_FAILURE_AT, heartbeatAt.toString()); + patch.put(BackgroundTaskStateKeys.FAILURE_CATEGORY, failureCategory.name()); + return patch; + } + + public Map retryQueuedStatePatch(BackgroundTask task, + String errorMessage, + BackgroundTaskFailureCategory failureCategory, + LocalDateTime nextRetryAt, + long retryDelaySeconds, + LocalDateTime heartbeatAt) { + Map patch = new LinkedHashMap<>(retryStatePatch(task.getAttemptCount(), task.getMaxAttempts())); + patch.put(BackgroundTaskStateKeys.PHASE, "queued"); + patch.put(BackgroundTaskStateKeys.RETRY_SCHEDULED, true); + patch.put(BackgroundTaskStateKeys.NEXT_RETRY_AT, nextRetryAt.toString()); + patch.put(BackgroundTaskStateKeys.RETRY_DELAY_SECONDS, retryDelaySeconds); + patch.put(BackgroundTaskStateKeys.LAST_FAILURE_MESSAGE, errorMessage); + patch.put(BackgroundTaskStateKeys.LAST_FAILURE_AT, heartbeatAt.toString()); + patch.put(BackgroundTaskStateKeys.FAILURE_CATEGORY, failureCategory.name()); + patch.put(BackgroundTaskStateKeys.HEARTBEAT_AT, heartbeatAt.toString()); + return patch; + } + + public String createInitialPublicState(Map baseState, int attemptCount, int maxAttempts) { + Map nextPublicState = new LinkedHashMap<>(baseState == null ? Map.of() : baseState); + nextPublicState.put(BackgroundTaskStateKeys.PHASE, "queued"); + nextPublicState.putAll(retryStatePatch(attemptCount, maxAttempts)); + return toJson(nextPublicState); + } + + public String merge(String currentValue, Map patch) { + return merge(currentValue, patch, List.of()); + } + + public String merge(String currentValue, Map patch, List keysToRemove) { + Map nextPublicState = parse(currentValue); + if (keysToRemove != null) { + keysToRemove.forEach(nextPublicState::remove); + } + if (patch != null) { + nextPublicState.putAll(patch); + } + return toJson(nextPublicState); + } + + public String resetPublicStateForRetry(String privateStateJson, int attemptCount, int maxAttempts) { + Map nextPublicState = parse(privateStateJson); + nextPublicState.remove("taskType"); + nextPublicState.put(BackgroundTaskStateKeys.PHASE, "queued"); + nextPublicState.putAll(retryStatePatch(attemptCount, maxAttempts)); + return toJson(nextPublicState); + } + + public List removableKeys(List primary, List secondary) { + List keys = new java.util.ArrayList<>(primary); + keys.addAll(secondary); + return keys; + } + + public Map parseJsonObject(String value, String invalidStateMessage) { + if (!StringUtils.hasText(value)) { + return Map.of(); + } + try { + return objectMapper.readValue(value, JSON_OBJECT_TYPE); + } catch (JsonProcessingException ex) { + throw new IllegalStateException(invalidStateMessage, ex); + } + } + + public Map mergeJsonObjects(String primaryJson, + String overlayJson, + String invalidStateMessage) { + Map state = new LinkedHashMap<>(parseJsonObject(primaryJson, invalidStateMessage)); + state.putAll(parseJsonObject(overlayJson, invalidStateMessage)); + return state; + } + + public Long readLong(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + if (value instanceof String text && StringUtils.hasText(text)) { + return Long.parseLong(text.trim()); + } + return null; + } + + public String readText(Object value) { + if (value instanceof String text && StringUtils.hasText(text)) { + return text.trim(); + } + return null; + } + + private Map terminalStatePatch(String phase, + BackgroundTask task, + LocalDateTime heartbeatAt) { + Map patch = new LinkedHashMap<>(retryStatePatch(task.getAttemptCount(), task.getMaxAttempts())); + patch.put(BackgroundTaskStateKeys.PHASE, phase); + patch.put(BackgroundTaskStateKeys.HEARTBEAT_AT, heartbeatAt.toString()); + return patch; + } + + private Map parse(String value) { + return new LinkedHashMap<>(parseJsonObject(value, "Failed to parse background task state")); + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskWorker.java b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskWorker.java index 5736ae0..4ee0f41 100644 --- a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskWorker.java +++ b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskWorker.java @@ -19,13 +19,13 @@ public class BackgroundTaskWorker { private static final int DEFAULT_BATCH_SIZE = 5; private static final long DEFAULT_LEASE_DURATION_SECONDS = 120L; - private final BackgroundTaskService backgroundTaskService; + private final BackgroundTaskExecutionService backgroundTaskExecutionService; private final List handlers; private final String workerOwner; - public BackgroundTaskWorker(BackgroundTaskService backgroundTaskService, + public BackgroundTaskWorker(BackgroundTaskExecutionService backgroundTaskExecutionService, List handlers) { - this.backgroundTaskService = backgroundTaskService; + this.backgroundTaskExecutionService = backgroundTaskExecutionService; this.handlers = List.copyOf(handlers); this.workerOwner = UUID.randomUUID().toString().replace("-", ""); } @@ -39,10 +39,10 @@ public class BackgroundTaskWorker { } public int processQueuedTasks(int maxTasks) { - backgroundTaskService.requeueExpiredRunningTasks(); + backgroundTaskExecutionService.requeueExpiredRunningTasks(); int processedCount = 0; - for (Long taskId : backgroundTaskService.findQueuedTaskIds(maxTasks)) { - var claimedTask = backgroundTaskService.claimQueuedTask(taskId, workerOwner, DEFAULT_LEASE_DURATION_SECONDS); + for (Long taskId : backgroundTaskExecutionService.findQueuedTaskIds(maxTasks)) { + var claimedTask = backgroundTaskExecutionService.claimQueuedTask(taskId, workerOwner, DEFAULT_LEASE_DURATION_SECONDS); if (claimedTask.isEmpty()) { continue; } @@ -55,21 +55,21 @@ public class BackgroundTaskWorker { private void execute(BackgroundTask task) { try { - backgroundTaskService.markWorkerTaskProgress( + backgroundTaskExecutionService.markWorkerTaskProgress( task.getId(), workerOwner, - Map.of(BackgroundTaskService.STATE_PHASE_KEY, resolveRunningPhase(task.getType())), + Map.of(BackgroundTaskStateKeys.PHASE, resolveRunningPhase(task.getType())), DEFAULT_LEASE_DURATION_SECONDS ); BackgroundTaskHandler handler = findHandler(task); BackgroundTaskHandlerResult result = handler.handle(task, publicStatePatch -> - backgroundTaskService.markWorkerTaskProgress( + backgroundTaskExecutionService.markWorkerTaskProgress( task.getId(), workerOwner, publicStatePatch, DEFAULT_LEASE_DURATION_SECONDS )); - backgroundTaskService.markWorkerTaskCompleted( + backgroundTaskExecutionService.markWorkerTaskCompleted( task.getId(), workerOwner, result.publicStatePatch(), @@ -80,7 +80,7 @@ public class BackgroundTaskWorker { } catch (Exception ex) { String message = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage(); try { - backgroundTaskService.markWorkerTaskFailed( + backgroundTaskExecutionService.markWorkerTaskFailed( task.getId(), workerOwner, message, diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/ExtractBackgroundTaskHandler.java b/backend/src/main/java/com/yoyuzh/files/tasks/ExtractBackgroundTaskHandler.java index 1100d30..6768ea0 100644 --- a/backend/src/main/java/com/yoyuzh/files/tasks/ExtractBackgroundTaskHandler.java +++ b/backend/src/main/java/com/yoyuzh/files/tasks/ExtractBackgroundTaskHandler.java @@ -1,8 +1,5 @@ package com.yoyuzh.files.tasks; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import com.yoyuzh.auth.User; import com.yoyuzh.auth.UserRepository; import com.yoyuzh.common.BusinessException; @@ -28,16 +25,16 @@ public class ExtractBackgroundTaskHandler implements BackgroundTaskHandler { private final StoredFileRepository storedFileRepository; private final UserRepository userRepository; private final FileService fileService; - private final ObjectMapper objectMapper; + private final BackgroundTaskStateManager stateManager; public ExtractBackgroundTaskHandler(StoredFileRepository storedFileRepository, UserRepository userRepository, FileService fileService, - ObjectMapper objectMapper) { + BackgroundTaskStateManager stateManager) { this.storedFileRepository = storedFileRepository; this.userRepository = userRepository; this.fileService = fileService; - this.objectMapper = objectMapper; + this.stateManager = stateManager; } @Override @@ -53,10 +50,14 @@ public class ExtractBackgroundTaskHandler implements BackgroundTaskHandler { @Override public BackgroundTaskHandlerResult handle(BackgroundTask task, BackgroundTaskProgressReporter progressReporter) { - Map state = parseState(task.getPrivateStateJson(), task.getPublicStateJson()); - Long fileId = extractLong(state.get("fileId")); - String outputPath = extractText(state.get("outputPath")); - String outputDirectoryName = extractText(state.get("outputDirectoryName")); + Map state = stateManager.mergeJsonObjects( + task.getPublicStateJson(), + task.getPrivateStateJson(), + "extract task state is invalid" + ); + Long fileId = stateManager.readLong(state.get("fileId")); + String outputPath = stateManager.readText(state.get("outputPath")); + String outputDirectoryName = stateManager.readText(state.get("outputDirectoryName")); if (fileId == null) { throw new IllegalStateException("extract task missing fileId"); } @@ -235,41 +236,6 @@ public class ExtractBackgroundTaskHandler implements BackgroundTaskHandler { return relativePath; } - private Map parseState(String privateStateJson, String publicStateJson) { - Map state = new LinkedHashMap<>(parseJsonObject(publicStateJson)); - state.putAll(parseJsonObject(privateStateJson)); - return state; - } - - private Map parseJsonObject(String json) { - if (!StringUtils.hasText(json)) { - return Map.of(); - } - try { - return objectMapper.readValue(json, new TypeReference>() { - }); - } catch (JsonProcessingException ex) { - throw new IllegalStateException("extract task state is invalid", ex); - } - } - - private Long extractLong(Object value) { - if (value instanceof Number number) { - return number.longValue(); - } - if (value instanceof String text && StringUtils.hasText(text)) { - return Long.parseLong(text.trim()); - } - return null; - } - - private String extractText(Object value) { - if (value instanceof String text && StringUtils.hasText(text)) { - return text.trim(); - } - return null; - } - private String normalizeDirectoryPath(String path) { if (!StringUtils.hasText(path)) { return "/"; diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/MediaMetadataBackgroundTaskHandler.java b/backend/src/main/java/com/yoyuzh/files/tasks/MediaMetadataBackgroundTaskHandler.java index e31c1e9..78ba1ec 100644 --- a/backend/src/main/java/com/yoyuzh/files/tasks/MediaMetadataBackgroundTaskHandler.java +++ b/backend/src/main/java/com/yoyuzh/files/tasks/MediaMetadataBackgroundTaskHandler.java @@ -1,8 +1,5 @@ package com.yoyuzh.files.tasks; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import com.yoyuzh.files.core.FileBlob; import com.yoyuzh.files.core.StoredFile; import com.yoyuzh.files.core.StoredFileRepository; @@ -18,7 +15,6 @@ import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import java.util.Optional; @@ -34,16 +30,16 @@ public class MediaMetadataBackgroundTaskHandler implements BackgroundTaskHandler private final StoredFileRepository storedFileRepository; private final FileMetadataRepository fileMetadataRepository; private final FileContentStorage fileContentStorage; - private final ObjectMapper objectMapper; + private final BackgroundTaskStateManager stateManager; public MediaMetadataBackgroundTaskHandler(StoredFileRepository storedFileRepository, FileMetadataRepository fileMetadataRepository, FileContentStorage fileContentStorage, - ObjectMapper objectMapper) { + BackgroundTaskStateManager stateManager) { this.storedFileRepository = storedFileRepository; this.fileMetadataRepository = fileMetadataRepository; this.fileContentStorage = fileContentStorage; - this.objectMapper = objectMapper; + this.stateManager = stateManager; } @Override @@ -114,39 +110,21 @@ public class MediaMetadataBackgroundTaskHandler implements BackgroundTaskHandler } private Long readFileId(BackgroundTask task) { - Long fileId = extractLong(parseState(task.getPrivateStateJson()).get("fileId")); + Long fileId = stateManager.readLong( + stateManager.parseJsonObject(task.getPrivateStateJson(), "media metadata task state is invalid").get("fileId") + ); if (fileId != null) { return fileId; } - fileId = extractLong(parseState(task.getPublicStateJson()).get("fileId")); + fileId = stateManager.readLong( + stateManager.parseJsonObject(task.getPublicStateJson(), "media metadata task state is invalid").get("fileId") + ); if (fileId != null) { return fileId; } throw new IllegalStateException("media metadata task missing fileId"); } - private Map parseState(String json) { - if (!StringUtils.hasText(json)) { - return Map.of(); - } - try { - return objectMapper.readValue(json, new TypeReference>() { - }); - } catch (JsonProcessingException ex) { - throw new IllegalStateException("media metadata task state is invalid", ex); - } - } - - private Long extractLong(Object value) { - if (value instanceof Number number) { - return number.longValue(); - } - if (value instanceof String text && StringUtils.hasText(text)) { - return Long.parseLong(text.trim()); - } - return null; - } - private String firstText(String primary, String fallback) { if (StringUtils.hasText(primary)) { return primary.trim(); diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/MediaMetadataTaskBrokerConsumer.java b/backend/src/main/java/com/yoyuzh/files/tasks/MediaMetadataTaskBrokerConsumer.java new file mode 100644 index 0000000..2c16b39 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/tasks/MediaMetadataTaskBrokerConsumer.java @@ -0,0 +1,83 @@ +package com.yoyuzh.files.tasks; + +import com.yoyuzh.common.broker.LightweightBrokerService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.Map; + +@Component +public class MediaMetadataTaskBrokerConsumer { + + private static final int DEFAULT_BATCH_SIZE = 10; + + private final LightweightBrokerService lightweightBrokerService; + private final BackgroundTaskCommandService backgroundTaskCommandService; + + public MediaMetadataTaskBrokerConsumer(LightweightBrokerService lightweightBrokerService, + BackgroundTaskCommandService backgroundTaskCommandService) { + this.lightweightBrokerService = lightweightBrokerService; + this.backgroundTaskCommandService = backgroundTaskCommandService; + } + + @Scheduled( + fixedDelayString = "${app.redis.broker.media-meta.fixed-delay-ms:3000}", + initialDelayString = "${app.redis.broker.media-meta.initial-delay-ms:15000}" + ) + public void runScheduledBatch() { + drainQueuedMessages(DEFAULT_BATCH_SIZE); + } + + public int drainQueuedMessages(int maxMessages) { + int safeLimit = Math.max(0, maxMessages); + int processed = 0; + for (int i = 0; i < safeLimit; i++) { + var payload = lightweightBrokerService.poll(MediaMetadataTaskBrokerPublisher.TOPIC); + if (payload.isEmpty()) { + break; + } + try { + if (handlePayload(payload.get())) { + processed += 1; + } + } catch (RuntimeException ex) { + lightweightBrokerService.requeue(MediaMetadataTaskBrokerPublisher.TOPIC, payload.get()); + break; + } + } + return processed; + } + + private boolean handlePayload(Map payload) { + Long userId = readLong(payload.get("userId")); + Long fileId = readLong(payload.get("fileId")); + String correlationId = readString(payload.get("correlationId")); + if (userId == null || fileId == null) { + return false; + } + backgroundTaskCommandService.createQueuedAutoMediaMetadataTask(userId, fileId, correlationId); + return true; + } + + private Long readLong(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + if (value instanceof String text && StringUtils.hasText(text)) { + try { + return Long.parseLong(text.trim()); + } catch (NumberFormatException ex) { + return null; + } + } + return null; + } + + private String readString(Object value) { + if (!(value instanceof String text) || !StringUtils.hasText(text)) { + return null; + } + return text.trim(); + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/MediaMetadataTaskBrokerPublisher.java b/backend/src/main/java/com/yoyuzh/files/tasks/MediaMetadataTaskBrokerPublisher.java new file mode 100644 index 0000000..08c2718 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/tasks/MediaMetadataTaskBrokerPublisher.java @@ -0,0 +1,58 @@ +package com.yoyuzh.files.tasks; + +import com.yoyuzh.common.broker.LightweightBrokerService; +import com.yoyuzh.files.core.StoredFile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.Map; + +@Component +public class MediaMetadataTaskBrokerPublisher { + + public static final String TOPIC = "media-metadata-trigger"; + + private final LightweightBrokerService lightweightBrokerService; + + public MediaMetadataTaskBrokerPublisher(LightweightBrokerService lightweightBrokerService) { + this.lightweightBrokerService = lightweightBrokerService; + } + + public void publishAfterCommit(StoredFile storedFile) { + if (!shouldPublish(storedFile)) { + return; + } + + Runnable publishTask = () -> lightweightBrokerService.publish(TOPIC, Map.of( + "userId", storedFile.getUser().getId(), + "fileId", storedFile.getId(), + "correlationId", buildCorrelationId(storedFile) + )); + + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + publishTask.run(); + } + }); + return; + } + + publishTask.run(); + } + + private boolean shouldPublish(StoredFile storedFile) { + return storedFile != null + && storedFile.getId() != null + && storedFile.getUser() != null + && storedFile.getUser().getId() != null + && !storedFile.isDirectory() + && MediaTaskSupport.isMediaLike(storedFile.getFilename(), storedFile.getContentType()); + } + + private String buildCorrelationId(StoredFile storedFile) { + return "media-meta:auto:file:" + storedFile.getId(); + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/MediaTaskSupport.java b/backend/src/main/java/com/yoyuzh/files/tasks/MediaTaskSupport.java new file mode 100644 index 0000000..c0a9b16 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/tasks/MediaTaskSupport.java @@ -0,0 +1,43 @@ +package com.yoyuzh.files.tasks; + +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Locale; + +final class MediaTaskSupport { + + private static final List MEDIA_EXTENSIONS = List.of( + ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg", + ".mp4", ".mov", ".mkv", ".webm", ".avi", + ".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a" + ); + + private MediaTaskSupport() { + } + + static boolean isMediaLike(String filename, String contentType) { + String normalizedContentType = normalizeContentType(contentType); + if (normalizedContentType.startsWith("image/") + || normalizedContentType.startsWith("video/") + || normalizedContentType.startsWith("audio/")) { + return true; + } + return hasExtension(filename); + } + + private static boolean hasExtension(String filename) { + if (!StringUtils.hasText(filename)) { + return false; + } + String normalized = filename.toLowerCase(Locale.ROOT); + return MEDIA_EXTENSIONS.stream().anyMatch(normalized::endsWith); + } + + private static String normalizeContentType(String contentType) { + if (!StringUtils.hasText(contentType)) { + return ""; + } + return contentType.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/StoragePolicyMigrationBackgroundTaskHandler.java b/backend/src/main/java/com/yoyuzh/files/tasks/StoragePolicyMigrationBackgroundTaskHandler.java index a4fff0d..2b9e9c3 100644 --- a/backend/src/main/java/com/yoyuzh/files/tasks/StoragePolicyMigrationBackgroundTaskHandler.java +++ b/backend/src/main/java/com/yoyuzh/files/tasks/StoragePolicyMigrationBackgroundTaskHandler.java @@ -1,8 +1,5 @@ package com.yoyuzh.files.tasks; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.ErrorCode; import com.yoyuzh.files.core.FileBlob; @@ -40,20 +37,20 @@ public class StoragePolicyMigrationBackgroundTaskHandler implements BackgroundTa private final FileBlobRepository fileBlobRepository; private final StoredFileRepository storedFileRepository; private final FileContentStorage fileContentStorage; - private final ObjectMapper objectMapper; + private final BackgroundTaskStateManager stateManager; public StoragePolicyMigrationBackgroundTaskHandler(StoragePolicyRepository storagePolicyRepository, FileEntityRepository fileEntityRepository, FileBlobRepository fileBlobRepository, StoredFileRepository storedFileRepository, FileContentStorage fileContentStorage, - ObjectMapper objectMapper) { + BackgroundTaskStateManager stateManager) { this.storagePolicyRepository = storagePolicyRepository; this.fileEntityRepository = fileEntityRepository; this.fileBlobRepository = fileBlobRepository; this.storedFileRepository = storedFileRepository; this.fileContentStorage = fileContentStorage; - this.objectMapper = objectMapper; + this.stateManager = stateManager; } @Override @@ -69,7 +66,10 @@ public class StoragePolicyMigrationBackgroundTaskHandler implements BackgroundTa @Override public BackgroundTaskHandlerResult handle(BackgroundTask task, BackgroundTaskProgressReporter progressReporter) { - Map state = parseState(task.getPrivateStateJson()); + Map state = stateManager.parseJsonObject( + task.getPrivateStateJson(), + "storage policy migration task state is invalid" + ); Long sourcePolicyId = readLong(state.get("sourcePolicyId"), "sourcePolicyId"); Long targetPolicyId = readLong(state.get("targetPolicyId"), "targetPolicyId"); @@ -210,7 +210,7 @@ public class StoragePolicyMigrationBackgroundTaskHandler implements BackgroundTa String migrationStage, boolean migrationPerformed) { Map patch = new LinkedHashMap<>(); - patch.put(BackgroundTaskService.STATE_PHASE_KEY, "migrating-storage-policy"); + patch.put(BackgroundTaskStateKeys.PHASE, "migrating-storage-policy"); patch.put("worker", "storage-policy-migration"); patch.put("migrationStage", migrationStage); patch.put("migrationMode", migrationPerformed ? "executed" : "executing"); @@ -281,24 +281,10 @@ public class StoragePolicyMigrationBackgroundTaskHandler implements BackgroundTa } } - private Map parseState(String json) { - if (!StringUtils.hasText(json)) { - return Map.of(); - } - try { - return objectMapper.readValue(json, new TypeReference>() { - }); - } catch (JsonProcessingException ex) { - throw new IllegalStateException("storage policy migration task state is invalid", ex); - } - } - private Long readLong(Object value, String key) { - if (value instanceof Number number) { - return number.longValue(); - } - if (value instanceof String text && StringUtils.hasText(text)) { - return Long.parseLong(text.trim()); + Long parsed = stateManager.readLong(value); + if (parsed != null) { + return parsed; } throw new IllegalStateException("storage policy migration task missing " + key); } diff --git a/backend/src/main/java/com/yoyuzh/files/upload/NoOpUploadSessionRuntimeStateService.java b/backend/src/main/java/com/yoyuzh/files/upload/NoOpUploadSessionRuntimeStateService.java new file mode 100644 index 0000000..d03a5a4 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/upload/NoOpUploadSessionRuntimeStateService.java @@ -0,0 +1,41 @@ +package com.yoyuzh.files.upload; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "false", matchIfMissing = true) +public class NoOpUploadSessionRuntimeStateService implements UploadSessionRuntimeStateService { + + @Override + public Optional getState(String sessionId) { + return Optional.empty(); + } + + @Override + public void markCreated(UploadSession session) { + } + + @Override + public void markUploading(UploadSession session, long uploadedBytes, int uploadedPartCount, LocalDateTime updatedAt) { + } + + @Override + public void markCompleted(UploadSession session, LocalDateTime updatedAt) { + } + + @Override + public void markCancelled(UploadSession session, LocalDateTime updatedAt) { + } + + @Override + public void markFailed(UploadSession session, LocalDateTime updatedAt) { + } + + @Override + public void markExpired(UploadSession session, LocalDateTime updatedAt) { + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/upload/RedisUploadSessionRuntimeStateService.java b/backend/src/main/java/com/yoyuzh/files/upload/RedisUploadSessionRuntimeStateService.java new file mode 100644 index 0000000..04ddc34 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/upload/RedisUploadSessionRuntimeStateService.java @@ -0,0 +1,160 @@ +package com.yoyuzh.files.upload; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.config.AppRedisProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Optional; + +@Service +@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true") +public class RedisUploadSessionRuntimeStateService implements UploadSessionRuntimeStateService { + + private final StringRedisTemplate stringRedisTemplate; + private final AppRedisProperties redisProperties; + private final ObjectMapper objectMapper; + + public RedisUploadSessionRuntimeStateService(StringRedisTemplate stringRedisTemplate, + AppRedisProperties redisProperties, + ObjectMapper objectMapper) { + this.stringRedisTemplate = stringRedisTemplate; + this.redisProperties = redisProperties; + this.objectMapper = objectMapper; + } + + @Override + public Optional getState(String sessionId) { + String value = stringRedisTemplate.opsForValue().get(buildKey(sessionId)); + if (!StringUtils.hasText(value)) { + return Optional.empty(); + } + try { + return Optional.of(objectMapper.readValue(value, UploadSessionRuntimeState.class)); + } catch (JsonProcessingException ex) { + return Optional.empty(); + } + } + + @Override + public void markCreated(UploadSession session) { + LocalDateTime updatedAt = safeUpdatedAt(session); + writeState(session, new UploadSessionRuntimeState( + "created", + 0L, + 0, + 0, + updatedAt, + session.getExpiresAt() + )); + } + + @Override + public void markUploading(UploadSession session, long uploadedBytes, int uploadedPartCount, LocalDateTime updatedAt) { + writeState(session, new UploadSessionRuntimeState( + "uploading", + Math.max(0L, uploadedBytes), + Math.max(0, uploadedPartCount), + toProgressPercent(uploadedBytes, session.getSize()), + updatedAt, + session.getExpiresAt() + )); + } + + @Override + public void markCompleted(UploadSession session, LocalDateTime updatedAt) { + writeState(session, new UploadSessionRuntimeState( + "completed", + session.getSize() == null ? 0L : session.getSize(), + Math.max(1, session.getChunkCount() == null ? 1 : session.getChunkCount()), + 100, + updatedAt, + session.getExpiresAt() + )); + } + + @Override + public void markCancelled(UploadSession session, LocalDateTime updatedAt) { + rewritePhase(session, "cancelled", updatedAt); + } + + @Override + public void markFailed(UploadSession session, LocalDateTime updatedAt) { + rewritePhase(session, "failed", updatedAt); + } + + @Override + public void markExpired(UploadSession session, LocalDateTime updatedAt) { + rewritePhase(session, "expired", updatedAt); + } + + private void rewritePhase(UploadSession session, String phase, LocalDateTime updatedAt) { + UploadSessionRuntimeState current = getState(session.getSessionId()).orElse(new UploadSessionRuntimeState( + phase, + 0L, + 0, + 0, + updatedAt, + session.getExpiresAt() + )); + writeState(session, new UploadSessionRuntimeState( + phase, + current.uploadedBytes(), + current.uploadedPartCount(), + current.progressPercent(), + updatedAt, + session.getExpiresAt() + )); + } + + private void writeState(UploadSession session, UploadSessionRuntimeState state) { + if (session == null || !StringUtils.hasText(session.getSessionId())) { + return; + } + try { + stringRedisTemplate.opsForValue().set( + buildKey(session.getSessionId()), + objectMapper.writeValueAsString(state), + resolveTtl(session.getExpiresAt(), state.phase()) + ); + } catch (JsonProcessingException ignored) { + } + } + + private Duration resolveTtl(LocalDateTime expiresAt, String phase) { + Duration base = Duration.ofSeconds(Math.max(redisProperties.getTtlBufferSeconds(), 60L)); + if (expiresAt == null) { + return base; + } + long seconds = Math.max(1L, expiresAt.toEpochSecond(ZoneOffset.UTC) - LocalDateTime.now(ZoneOffset.UTC).toEpochSecond(ZoneOffset.UTC)); + Duration sessionWindow = Duration.ofSeconds(seconds + redisProperties.getTtlBufferSeconds()); + if ("completed".equals(phase) || "cancelled".equals(phase) || "failed".equals(phase) || "expired".equals(phase)) { + return sessionWindow.compareTo(Duration.ofHours(1)) < 0 ? sessionWindow : Duration.ofHours(1); + } + return sessionWindow; + } + + private Integer toProgressPercent(long uploadedBytes, Long totalBytes) { + if (totalBytes == null || totalBytes <= 0) { + return 0; + } + double ratio = Math.min(1.0d, Math.max(0.0d, (double) uploadedBytes / totalBytes)); + return (int) Math.round(ratio * 100); + } + + private LocalDateTime safeUpdatedAt(UploadSession session) { + return session.getUpdatedAt() == null ? LocalDateTime.now(ZoneOffset.UTC) : session.getUpdatedAt(); + } + + private String buildKey(String sessionId) { + return redisProperties.getKeyPrefix() + + ":" + redisProperties.getNamespaces().getUploadState() + + ":session:" + sessionId.trim(); + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/upload/UploadPolicyResolver.java b/backend/src/main/java/com/yoyuzh/files/upload/UploadPolicyResolver.java new file mode 100644 index 0000000..c3bbaeb --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/upload/UploadPolicyResolver.java @@ -0,0 +1,54 @@ +package com.yoyuzh.files.upload; + +import com.yoyuzh.auth.User; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import com.yoyuzh.files.policy.StoragePolicy; +import com.yoyuzh.files.policy.StoragePolicyCapabilities; +import org.springframework.stereotype.Component; + +@Component +public class UploadPolicyResolver { + + public UploadSessionUploadMode resolveUploadMode(StoragePolicyCapabilities capabilities) { + if (!capabilities.directUpload()) { + return UploadSessionUploadMode.PROXY; + } + if (capabilities.multipartUpload()) { + return UploadSessionUploadMode.DIRECT_MULTIPART; + } + return UploadSessionUploadMode.DIRECT_SINGLE; + } + + public long resolveEffectiveMaxUploadSize(long systemMaxFileSize, + User user, + StoragePolicy policy, + StoragePolicyCapabilities capabilities) { + long effectiveMaxUploadSize = Math.min(systemMaxFileSize, user.getMaxUploadSizeBytes()); + if (policy.getMaxSizeBytes() > 0) { + effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, policy.getMaxSizeBytes()); + } + if (capabilities.maxObjectSize() > 0) { + effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, capabilities.maxObjectSize()); + } + return effectiveMaxUploadSize; + } + + public int calculateChunkCount(long size, long chunkSize) { + if (size <= 0) { + return 1; + } + return (int) Math.ceil((double) size / chunkSize); + } + + public long resolveChunkSize(UploadSession session, int partIndex) { + if (partIndex < 0 || partIndex >= session.getChunkCount()) { + throw new BusinessException(ErrorCode.UNKNOWN, "鍒嗙墖搴忓彿涓嶅悎娉?"); + } + if (partIndex < session.getChunkCount() - 1) { + return session.getChunkSize(); + } + long remaining = session.getSize() - session.getChunkSize() * (session.getChunkCount() - 1L); + return remaining > 0 ? remaining : session.getChunkSize(); + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionRuntimeState.java b/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionRuntimeState.java new file mode 100644 index 0000000..53251d0 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionRuntimeState.java @@ -0,0 +1,13 @@ +package com.yoyuzh.files.upload; + +import java.time.LocalDateTime; + +public record UploadSessionRuntimeState( + String phase, + long uploadedBytes, + int uploadedPartCount, + Integer progressPercent, + LocalDateTime lastUpdatedAt, + LocalDateTime expiresAt +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionRuntimeStateService.java b/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionRuntimeStateService.java new file mode 100644 index 0000000..a0d0ebd --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionRuntimeStateService.java @@ -0,0 +1,61 @@ +package com.yoyuzh.files.upload; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface UploadSessionRuntimeStateService { + + Optional getState(String sessionId); + + void markCreated(UploadSession session); + + void markUploading(UploadSession session, long uploadedBytes, int uploadedPartCount, LocalDateTime updatedAt); + + void markCompleted(UploadSession session, LocalDateTime updatedAt); + + void markCancelled(UploadSession session, LocalDateTime updatedAt); + + void markFailed(UploadSession session, LocalDateTime updatedAt); + + void markExpired(UploadSession session, LocalDateTime updatedAt); + + static UploadSessionRuntimeStateService noOp() { + return NoOpHolder.INSTANCE; + } + + final class NoOpHolder { + private static final UploadSessionRuntimeStateService INSTANCE = new UploadSessionRuntimeStateService() { + @Override + public Optional getState(String sessionId) { + return Optional.empty(); + } + + @Override + public void markCreated(UploadSession session) { + } + + @Override + public void markUploading(UploadSession session, long uploadedBytes, int uploadedPartCount, LocalDateTime updatedAt) { + } + + @Override + public void markCompleted(UploadSession session, LocalDateTime updatedAt) { + } + + @Override + public void markCancelled(UploadSession session, LocalDateTime updatedAt) { + } + + @Override + public void markFailed(UploadSession session, LocalDateTime updatedAt) { + } + + @Override + public void markExpired(UploadSession session, LocalDateTime updatedAt) { + } + }; + + private NoOpHolder() { + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionService.java b/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionService.java index 121d07e..6ea8cdd 100644 --- a/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionService.java +++ b/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionService.java @@ -6,8 +6,10 @@ import com.yoyuzh.auth.User; import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.ErrorCode; import com.yoyuzh.config.FileStorageProperties; +import com.yoyuzh.files.core.FileUploadRulesService; import com.yoyuzh.files.core.FileService; import com.yoyuzh.files.core.StoredFileRepository; +import com.yoyuzh.files.core.WorkspaceNodeRulesService; import com.yoyuzh.files.policy.StoragePolicy; import com.yoyuzh.files.policy.StoragePolicyCapabilities; import com.yoyuzh.files.policy.StoragePolicyService; @@ -26,6 +28,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Optional; import java.util.UUID; @Service @@ -42,13 +45,17 @@ public class UploadSessionService { }; private final UploadSessionRepository uploadSessionRepository; - private final StoredFileRepository storedFileRepository; private final FileService fileService; private final FileContentStorage fileContentStorage; private final StoragePolicyService storagePolicyService; private final ObjectMapper objectMapper = new ObjectMapper(); - private final long maxFileSize; + private final UploadPolicyResolver uploadPolicyResolver; + private final UploadSessionStateMachine uploadSessionStateMachine; + private final WorkspaceNodeRulesService workspaceNodeRulesService; + private final FileUploadRulesService fileUploadRulesService; private final Clock clock; + @Autowired(required = false) + private UploadSessionRuntimeStateService uploadSessionRuntimeStateService = UploadSessionRuntimeStateService.noOp(); @Autowired public UploadSessionService(UploadSessionRepository uploadSessionRepository, @@ -56,8 +63,20 @@ public class UploadSessionService { FileService fileService, FileContentStorage fileContentStorage, StoragePolicyService storagePolicyService, - FileStorageProperties properties) { - this(uploadSessionRepository, storedFileRepository, fileService, fileContentStorage, storagePolicyService, properties, Clock.systemUTC()); + FileStorageProperties properties, + UploadPolicyResolver uploadPolicyResolver, + UploadSessionStateMachine uploadSessionStateMachine) { + this( + uploadSessionRepository, + storedFileRepository, + fileService, + fileContentStorage, + storagePolicyService, + properties, + Clock.systemUTC(), + uploadPolicyResolver, + uploadSessionStateMachine + ); } UploadSessionService(UploadSessionRepository uploadSessionRepository, @@ -67,23 +86,52 @@ public class UploadSessionService { StoragePolicyService storagePolicyService, FileStorageProperties properties, Clock clock) { + this( + uploadSessionRepository, + storedFileRepository, + fileService, + fileContentStorage, + storagePolicyService, + properties, + clock, + new UploadPolicyResolver(), + new UploadSessionStateMachine() + ); + } + + UploadSessionService(UploadSessionRepository uploadSessionRepository, + StoredFileRepository storedFileRepository, + FileService fileService, + FileContentStorage fileContentStorage, + StoragePolicyService storagePolicyService, + FileStorageProperties properties, + Clock clock, + UploadPolicyResolver uploadPolicyResolver, + UploadSessionStateMachine uploadSessionStateMachine) { this.uploadSessionRepository = uploadSessionRepository; - this.storedFileRepository = storedFileRepository; this.fileService = fileService; this.fileContentStorage = fileContentStorage; this.storagePolicyService = storagePolicyService; - this.maxFileSize = properties.getMaxFileSize(); this.clock = clock; + this.uploadPolicyResolver = uploadPolicyResolver; + this.uploadSessionStateMachine = uploadSessionStateMachine; + this.workspaceNodeRulesService = new WorkspaceNodeRulesService(storedFileRepository, fileContentStorage); + this.fileUploadRulesService = new FileUploadRulesService( + storedFileRepository, + storagePolicyService, + workspaceNodeRulesService, + properties.getMaxFileSize() + ); } @Transactional public UploadSession createSession(User user, UploadSessionCreateCommand command) { - String normalizedPath = normalizeDirectoryPath(command.path()); - String filename = normalizeLeafName(command.filename()); + String normalizedPath = workspaceNodeRulesService.normalizeDirectoryPath(command.path()); + String filename = workspaceNodeRulesService.normalizeLeafName(command.filename()); StoragePolicy policy = storagePolicyService.ensureDefaultPolicy(); StoragePolicyCapabilities capabilities = storagePolicyService.readCapabilities(policy); - validateTarget(user, normalizedPath, filename, command.size(), policy, capabilities); - UploadSessionUploadMode uploadMode = resolveUploadMode(capabilities); + validateTarget(user, normalizedPath, filename, command.size()); + UploadSessionUploadMode uploadMode = uploadPolicyResolver.resolveUploadMode(capabilities); UploadSession session = new UploadSession(); session.setSessionId(UUID.randomUUID().toString()); @@ -96,44 +144,51 @@ public class UploadSessionService { session.setStoragePolicyId(policy.getId()); session.setChunkSize(DEFAULT_CHUNK_SIZE); session.setChunkCount(uploadMode == UploadSessionUploadMode.DIRECT_MULTIPART - ? calculateChunkCount(command.size(), DEFAULT_CHUNK_SIZE) + ? uploadPolicyResolver.calculateChunkCount(command.size(), DEFAULT_CHUNK_SIZE) : 1); session.setUploadedPartsJson("[]"); session.setStatus(UploadSessionStatus.CREATED); - LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone()); + LocalDateTime now = now(); session.setCreatedAt(now); session.setUpdatedAt(now); session.setExpiresAt(now.plusHours(SESSION_TTL_HOURS)); if (uploadMode == UploadSessionUploadMode.DIRECT_MULTIPART) { session.setMultipartUploadId(fileContentStorage.createMultipartUpload(session.getObjectKey(), session.getContentType())); } - return uploadSessionRepository.save(session); + UploadSession savedSession = uploadSessionRepository.save(session); + uploadSessionRuntimeStateService.markCreated(savedSession); + return savedSession; } @Transactional(readOnly = true) public UploadSession getOwnedSession(User user, String sessionId) { return uploadSessionRepository.findBySessionIdAndUserId(sessionId, user.getId()) - .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "上传会话不存在")); + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "upload session not found")); + } + + @Transactional(readOnly = true) + public Optional getRuntimeState(String sessionId) { + return uploadSessionRuntimeStateService.getState(sessionId); } @Transactional public UploadSession cancelOwnedSession(User user, String sessionId) { UploadSession session = getOwnedSession(user, sessionId); if (session.getStatus() == UploadSessionStatus.COMPLETED) { - throw new BusinessException(ErrorCode.UNKNOWN, "已完成的上传会话不能取消"); + throw new BusinessException(ErrorCode.UNKNOWN, "completed upload session cannot be cancelled"); } - session.setStatus(UploadSessionStatus.CANCELLED); - session.setUpdatedAt(LocalDateTime.ofInstant(clock.instant(), clock.getZone())); - return uploadSessionRepository.save(session); + uploadSessionStateMachine.markCancelled(session, now()); + UploadSession savedSession = uploadSessionRepository.save(session); + uploadSessionRuntimeStateService.markCancelled(savedSession, savedSession.getUpdatedAt()); + return savedSession; } @Transactional(readOnly = true) public PreparedUpload prepareOwnedUpload(User user, String sessionId) { UploadSession session = getOwnedSession(user, sessionId); - LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone()); - ensureSessionCanReceiveContent(session, now); + ensureSessionCanReceiveContent(session, now()); if (resolveUploadMode(session) != UploadSessionUploadMode.DIRECT_SINGLE) { - throw new BusinessException(ErrorCode.UNKNOWN, "上传会话未启用单请求直传"); + throw new BusinessException(ErrorCode.UNKNOWN, "upload session does not support direct single upload"); } return fileContentStorage.prepareBlobUpload( session.getTargetPath(), @@ -147,21 +202,20 @@ public class UploadSessionService { @Transactional(readOnly = true) public PreparedUpload prepareOwnedPartUpload(User user, String sessionId, int partIndex) { UploadSession session = getOwnedSession(user, sessionId); - LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone()); - ensureSessionCanReceivePart(session, now); + ensureSessionCanReceivePart(session, now()); if (resolveUploadMode(session) != UploadSessionUploadMode.DIRECT_MULTIPART || !StringUtils.hasText(session.getMultipartUploadId())) { - throw new BusinessException(ErrorCode.UNKNOWN, "上传会话未启用 multipart"); + throw new BusinessException(ErrorCode.UNKNOWN, "upload session does not support multipart upload"); } if (partIndex < 0 || partIndex >= session.getChunkCount()) { - throw new BusinessException(ErrorCode.UNKNOWN, "分片序号不合法"); + throw new BusinessException(ErrorCode.UNKNOWN, "invalid part index"); } return fileContentStorage.prepareMultipartPartUpload( session.getObjectKey(), session.getMultipartUploadId(), partIndex + 1, session.getContentType(), - resolveChunkSize(session, partIndex) + uploadPolicyResolver.resolveChunkSize(session, partIndex) ); } @@ -171,19 +225,19 @@ public class UploadSessionService { int partIndex, UploadSessionPartCommand command) { UploadSession session = getOwnedSession(user, sessionId); - LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone()); + LocalDateTime now = now(); ensureSessionCanReceivePart(session, now); if (resolveUploadMode(session) != UploadSessionUploadMode.DIRECT_MULTIPART) { - throw new BusinessException(ErrorCode.UNKNOWN, "上传会话未启用 multipart"); + throw new BusinessException(ErrorCode.UNKNOWN, "upload session does not support multipart upload"); } if (partIndex < 0 || partIndex >= session.getChunkCount()) { - throw new BusinessException(ErrorCode.UNKNOWN, "分片序号不合法"); + throw new BusinessException(ErrorCode.UNKNOWN, "invalid part index"); } if (!StringUtils.hasText(command.etag())) { - throw new BusinessException(ErrorCode.UNKNOWN, "分片标识不能为空"); + throw new BusinessException(ErrorCode.UNKNOWN, "part etag is required"); } if (command.size() < 0) { - throw new BusinessException(ErrorCode.UNKNOWN, "分片大小不合法"); + throw new BusinessException(ErrorCode.UNKNOWN, "invalid part size"); } List uploadedParts = new ArrayList<>(readUploadedParts(session)); @@ -192,33 +246,42 @@ public class UploadSessionService { uploadedParts.sort(Comparator.comparingInt(UploadedPart::partIndex)); session.setUploadedPartsJson(writeUploadedParts(uploadedParts)); - if (session.getStatus() == UploadSessionStatus.CREATED) { - session.setStatus(UploadSessionStatus.UPLOADING); - } - session.setUpdatedAt(now); - return uploadSessionRepository.save(session); + uploadSessionStateMachine.markUploading(session, now); + UploadSession savedSession = uploadSessionRepository.save(session); + long uploadedBytes = uploadedParts.stream().mapToLong(UploadedPart::size).sum(); + uploadSessionRuntimeStateService.markUploading( + savedSession, + uploadedBytes, + uploadedParts.size(), + savedSession.getUpdatedAt() + ); + return savedSession; } @Transactional public UploadSession uploadOwnedContent(User user, String sessionId, MultipartFile file) { UploadSession session = getOwnedSession(user, sessionId); - LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone()); + LocalDateTime now = now(); ensureSessionCanReceiveContent(session, now); if (resolveUploadMode(session) != UploadSessionUploadMode.PROXY) { - throw new BusinessException(ErrorCode.UNKNOWN, "上传会话未启用代理上传"); + throw new BusinessException(ErrorCode.UNKNOWN, "upload session does not support proxy upload"); } if (file == null || file.isEmpty()) { - throw new BusinessException(ErrorCode.UNKNOWN, "上传内容不能为空"); + throw new BusinessException(ErrorCode.UNKNOWN, "upload content is required"); } if (file.getSize() != session.getSize()) { - throw new BusinessException(ErrorCode.UNKNOWN, "上传内容大小与会话不一致"); + throw new BusinessException(ErrorCode.UNKNOWN, "upload size does not match session"); } fileContentStorage.uploadBlob(session.getObjectKey(), file); - if (session.getStatus() == UploadSessionStatus.CREATED) { - session.setStatus(UploadSessionStatus.UPLOADING); - } - session.setUpdatedAt(now); - return uploadSessionRepository.save(session); + uploadSessionStateMachine.markUploading(session, now); + UploadSession savedSession = uploadSessionRepository.save(session); + uploadSessionRuntimeStateService.markUploading( + savedSession, + savedSession.getSize(), + 1, + savedSession.getUpdatedAt() + ); + return savedSession; } @Transactional @@ -228,19 +291,24 @@ public class UploadSessionService { return session; } if (session.getStatus() == UploadSessionStatus.CANCELLED || session.getStatus() == UploadSessionStatus.FAILED) { - throw new BusinessException(ErrorCode.UNKNOWN, "上传会话不能完成"); + throw new BusinessException(ErrorCode.UNKNOWN, "upload session cannot be completed"); } - LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone()); + LocalDateTime now = now(); if (session.getExpiresAt().isBefore(now)) { - session.setStatus(UploadSessionStatus.EXPIRED); - session.setUpdatedAt(now); - uploadSessionRepository.save(session); - throw new BusinessException(ErrorCode.UNKNOWN, "上传会话已过期"); + uploadSessionStateMachine.markExpired(session, now); + UploadSession expiredSession = uploadSessionRepository.save(session); + uploadSessionRuntimeStateService.markExpired(expiredSession, expiredSession.getUpdatedAt()); + throw new BusinessException(ErrorCode.UNKNOWN, "upload session has expired"); } - session.setStatus(UploadSessionStatus.COMPLETING); - session.setUpdatedAt(now); - uploadSessionRepository.save(session); + uploadSessionStateMachine.markCompleting(session, now); + UploadSession completingSession = uploadSessionRepository.save(session); + uploadSessionRuntimeStateService.markUploading( + completingSession, + completingSession.getSize() == null ? 0L : completingSession.getSize(), + Math.max(1, completingSession.getChunkCount() == null ? 1 : completingSession.getChunkCount()), + completingSession.getUpdatedAt() + ); try { if (resolveUploadMode(session) == UploadSessionUploadMode.DIRECT_MULTIPART @@ -258,13 +326,14 @@ public class UploadSessionService { session.getContentType(), session.getSize() )); - session.setStatus(UploadSessionStatus.COMPLETED); - session.setUpdatedAt(now); - return uploadSessionRepository.save(session); + uploadSessionStateMachine.markCompleted(session, now); + UploadSession completedSession = uploadSessionRepository.save(session); + uploadSessionRuntimeStateService.markCompleted(completedSession, completedSession.getUpdatedAt()); + return completedSession; } catch (RuntimeException ex) { - session.setStatus(UploadSessionStatus.FAILED); - session.setUpdatedAt(now); - uploadSessionRepository.save(session); + uploadSessionStateMachine.markFailed(session, now); + UploadSession failedSession = uploadSessionRepository.save(session); + uploadSessionRuntimeStateService.markFailed(failedSession, failedSession.getUpdatedAt()); throw ex; } } @@ -272,7 +341,7 @@ public class UploadSessionService { @Scheduled(fixedDelay = 60 * 60 * 1000L) @Transactional public int pruneExpiredSessions() { - LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone()); + LocalDateTime now = now(); List expiredSessions = uploadSessionRepository.findByStatusInAndExpiresAtBefore( EXPIRABLE_STATUSES, now @@ -287,8 +356,8 @@ public class UploadSessionService { } catch (RuntimeException ignored) { // Expiration is authoritative in the database even if remote object cleanup fails. } - session.setStatus(UploadSessionStatus.EXPIRED); - session.setUpdatedAt(now); + uploadSessionStateMachine.markExpired(session, now); + uploadSessionRuntimeStateService.markExpired(session, session.getUpdatedAt()); } if (!expiredSessions.isEmpty()) { uploadSessionRepository.saveAll(expiredSessions); @@ -304,64 +373,44 @@ public class UploadSessionService { return UploadSessionUploadMode.PROXY; } StoragePolicy policy = storagePolicyService.getRequiredPolicy(session.getStoragePolicyId()); - return resolveUploadMode(storagePolicyService.readCapabilities(policy)); - } - - private UploadSessionUploadMode resolveUploadMode(StoragePolicyCapabilities capabilities) { - if (!capabilities.directUpload()) { - return UploadSessionUploadMode.PROXY; - } - if (capabilities.multipartUpload()) { - return UploadSessionUploadMode.DIRECT_MULTIPART; - } - return UploadSessionUploadMode.DIRECT_SINGLE; + return uploadPolicyResolver.resolveUploadMode(storagePolicyService.readCapabilities(policy)); } private void validateTarget(User user, String normalizedPath, String filename, - long size, - StoragePolicy policy, - StoragePolicyCapabilities capabilities) { - long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes()); - if (policy.getMaxSizeBytes() > 0) { - effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, policy.getMaxSizeBytes()); - } - if (capabilities.maxObjectSize() > 0) { - effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, capabilities.maxObjectSize()); - } - if (size > effectiveMaxUploadSize) { - throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制"); - } - if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedPath, filename)) { - throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在"); - } - long usedBytes = storedFileRepository.sumFileSizeByUserId(user.getId()); - if (user.getStorageQuotaBytes() >= 0 && usedBytes + size > user.getStorageQuotaBytes()) { - throw new BusinessException(ErrorCode.UNKNOWN, "存储空间不足"); - } + long size) { + fileUploadRulesService.validateUpload(user, normalizedPath, filename, size); } private void ensureSessionCanReceiveContent(UploadSession session, LocalDateTime now) { - ensureSessionCanReceivePart(session, now); - if (session.getStatus() == UploadSessionStatus.UPLOADING && StringUtils.hasText(session.getMultipartUploadId())) { - throw new BusinessException(ErrorCode.UNKNOWN, "multipart 上传会话不能走整体内容上传"); + try { + uploadSessionStateMachine.ensureCanReceiveContent( + session, + now, + StringUtils.hasText(session.getMultipartUploadId()) + ); + } catch (BusinessException ex) { + markRuntimeExpiredIfNeeded(session); + throw ex; } } private void ensureSessionCanReceivePart(UploadSession session, LocalDateTime now) { - if (session.getStatus() == UploadSessionStatus.CANCELLED - || session.getStatus() == UploadSessionStatus.FAILED - || session.getStatus() == UploadSessionStatus.COMPLETING - || session.getStatus() == UploadSessionStatus.COMPLETED) { - throw new BusinessException(ErrorCode.UNKNOWN, "上传会话不能继续上传分片"); + try { + uploadSessionStateMachine.ensureCanReceivePart(session, now); + } catch (BusinessException ex) { + markRuntimeExpiredIfNeeded(session); + throw ex; } - if (session.getExpiresAt().isBefore(now)) { - session.setStatus(UploadSessionStatus.EXPIRED); - session.setUpdatedAt(now); - uploadSessionRepository.save(session); - throw new BusinessException(ErrorCode.UNKNOWN, "上传会话已过期"); + } + + private void markRuntimeExpiredIfNeeded(UploadSession session) { + if (session.getStatus() != UploadSessionStatus.EXPIRED) { + return; } + UploadSession expiredSession = uploadSessionRepository.save(session); + uploadSessionRuntimeStateService.markExpired(expiredSession, expiredSession.getUpdatedAt()); } private List readUploadedParts(UploadSession session) { @@ -371,7 +420,7 @@ public class UploadSessionService { try { return objectMapper.readValue(session.getUploadedPartsJson(), UPLOADED_PARTS_TYPE); } catch (Exception ex) { - throw new BusinessException(ErrorCode.UNKNOWN, "上传会话分片状态不合法"); + throw new BusinessException(ErrorCode.UNKNOWN, "invalid uploaded part state"); } } @@ -379,7 +428,7 @@ public class UploadSessionService { try { return objectMapper.writeValueAsString(uploadedParts); } catch (Exception ex) { - throw new BusinessException(ErrorCode.UNKNOWN, "上传会话分片状态写入失败"); + throw new BusinessException(ErrorCode.UNKNOWN, "failed to write uploaded part state"); } } @@ -388,18 +437,18 @@ public class UploadSessionService { .sorted(Comparator.comparingInt(UploadedPart::partIndex)) .toList(); if (uploadedParts.size() != session.getChunkCount()) { - throw new BusinessException(ErrorCode.UNKNOWN, "上传分片不完整"); + throw new BusinessException(ErrorCode.UNKNOWN, "multipart upload is incomplete"); } for (int expectedIndex = 0; expectedIndex < session.getChunkCount(); expectedIndex++) { UploadedPart part = uploadedParts.get(expectedIndex); if (part.partIndex() != expectedIndex) { - throw new BusinessException(ErrorCode.UNKNOWN, "上传分片不完整"); + throw new BusinessException(ErrorCode.UNKNOWN, "multipart upload is incomplete"); } if (!StringUtils.hasText(part.etag())) { - throw new BusinessException(ErrorCode.UNKNOWN, "上传分片标识缺失"); + throw new BusinessException(ErrorCode.UNKNOWN, "missing part etag"); } - if (part.size() <= 0 || part.size() > resolveChunkSize(session, expectedIndex)) { - throw new BusinessException(ErrorCode.UNKNOWN, "上传分片大小不合法"); + if (part.size() <= 0 || part.size() > uploadPolicyResolver.resolveChunkSize(session, expectedIndex)) { + throw new BusinessException(ErrorCode.UNKNOWN, "invalid part size"); } } return uploadedParts.stream() @@ -407,53 +456,15 @@ public class UploadSessionService { .toList(); } - private long resolveChunkSize(UploadSession session, int partIndex) { - if (partIndex < 0 || partIndex >= session.getChunkCount()) { - throw new BusinessException(ErrorCode.UNKNOWN, "分片序号不合法"); - } - if (partIndex < session.getChunkCount() - 1) { - return session.getChunkSize(); - } - long remaining = session.getSize() - session.getChunkSize() * (session.getChunkCount() - 1L); - return remaining > 0 ? remaining : session.getChunkSize(); - } - private record UploadedPart(int partIndex, String etag, long size, String uploadedAt) { } - private int calculateChunkCount(long size, long chunkSize) { - if (size <= 0) { - return 1; - } - return (int) Math.ceil((double) size / chunkSize); - } - private String createBlobObjectKey() { return "blobs/" + UUID.randomUUID(); } - private String normalizeDirectoryPath(String path) { - String cleaned = StringUtils.cleanPath(path == null ? "/" : path.trim().replace("\\", "/")); - if (!cleaned.startsWith("/")) { - cleaned = "/" + cleaned; - } - while (cleaned.length() > 1 && cleaned.endsWith("/")) { - cleaned = cleaned.substring(0, cleaned.length() - 1); - } - if (!StringUtils.hasText(cleaned) || cleaned.contains("..")) { - throw new BusinessException(ErrorCode.UNKNOWN, "路径不合法"); - } - return cleaned; + private LocalDateTime now() { + return LocalDateTime.ofInstant(clock.instant(), clock.getZone()); } - private String normalizeLeafName(String filename) { - String cleaned = StringUtils.cleanPath(filename == null ? "" : filename).trim(); - if (!StringUtils.hasText(cleaned)) { - throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空"); - } - if (cleaned.contains("/") || cleaned.contains("\\") || cleaned.contains("..")) { - throw new BusinessException(ErrorCode.UNKNOWN, "文件名不合法"); - } - return cleaned; - } } diff --git a/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionStateMachine.java b/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionStateMachine.java new file mode 100644 index 0000000..e6c40be --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionStateMachine.java @@ -0,0 +1,63 @@ +package com.yoyuzh.files.upload; + +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +public class UploadSessionStateMachine { + + public void ensureCanReceivePart(UploadSession session, LocalDateTime now) { + if (session.getStatus() == UploadSessionStatus.CANCELLED + || session.getStatus() == UploadSessionStatus.FAILED + || session.getStatus() == UploadSessionStatus.COMPLETING + || session.getStatus() == UploadSessionStatus.COMPLETED) { + throw new BusinessException(ErrorCode.UNKNOWN, "涓婁紶浼氳瘽涓嶈兘缁х画涓婁紶鍒嗙墖"); + } + if (session.getExpiresAt().isBefore(now)) { + markExpired(session, now); + throw new BusinessException(ErrorCode.UNKNOWN, "涓婁紶浼氳瘽宸茶繃鏈?"); + } + } + + public void ensureCanReceiveContent(UploadSession session, LocalDateTime now, boolean multipartUpload) { + ensureCanReceivePart(session, now); + if (session.getStatus() == UploadSessionStatus.UPLOADING && multipartUpload) { + throw new BusinessException(ErrorCode.UNKNOWN, "multipart 涓婁紶浼氳瘽涓嶈兘璧版暣浣撳唴瀹逛笂浼?"); + } + } + + public void markUploading(UploadSession session, LocalDateTime now) { + if (session.getStatus() == UploadSessionStatus.CREATED) { + session.setStatus(UploadSessionStatus.UPLOADING); + } + session.setUpdatedAt(now); + } + + public void markCompleting(UploadSession session, LocalDateTime now) { + session.setStatus(UploadSessionStatus.COMPLETING); + session.setUpdatedAt(now); + } + + public void markCompleted(UploadSession session, LocalDateTime now) { + session.setStatus(UploadSessionStatus.COMPLETED); + session.setUpdatedAt(now); + } + + public void markFailed(UploadSession session, LocalDateTime now) { + session.setStatus(UploadSessionStatus.FAILED); + session.setUpdatedAt(now); + } + + public void markCancelled(UploadSession session, LocalDateTime now) { + session.setStatus(UploadSessionStatus.CANCELLED); + session.setUpdatedAt(now); + } + + public void markExpired(UploadSession session, LocalDateTime now) { + session.setStatus(UploadSessionStatus.EXPIRED); + session.setUpdatedAt(now); + } +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferQuotaService.java b/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferQuotaService.java new file mode 100644 index 0000000..cc0fd52 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferQuotaService.java @@ -0,0 +1,35 @@ +package com.yoyuzh.transfer; + +import com.yoyuzh.admin.AdminMetricsService; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Instant; + +@Service +@RequiredArgsConstructor +public class OfflineTransferQuotaService { + + private final OfflineTransferSessionRepository offlineTransferSessionRepository; + private final AdminMetricsService adminMetricsService; + + public void ensureUploadAllowed(OfflineTransferFile targetFile, long uploadSize, long maxFileSize) { + if (uploadSize <= 0) { + throw new BusinessException(ErrorCode.UNKNOWN, "offline file cannot be empty"); + } + if (uploadSize > maxFileSize) { + throw new BusinessException(ErrorCode.UNKNOWN, "offline file size exceeds limit"); + } + if (uploadSize != targetFile.getSize()) { + throw new BusinessException(ErrorCode.UNKNOWN, "offline file size does not match session manifest"); + } + + long currentOfflineStorageBytes = offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now()); + long additionalBytes = targetFile.isUploaded() ? 0L : targetFile.getSize(); + if (currentOfflineStorageBytes + additionalBytes > adminMetricsService.getOfflineTransferStorageLimitBytes()) { + throw new BusinessException(ErrorCode.UNKNOWN, "offline transfer storage limit exceeded"); + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferService.java b/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferService.java new file mode 100644 index 0000000..0d98189 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferService.java @@ -0,0 +1,318 @@ +package com.yoyuzh.transfer; + +import com.yoyuzh.auth.User; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import com.yoyuzh.config.FileStorageProperties; +import com.yoyuzh.files.storage.FileContentStorage; +import jakarta.transaction.Transactional; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +@Service +public class OfflineTransferService { + + private static final Duration OFFLINE_SESSION_TTL = Duration.ofDays(7); + + private final TransferSessionStore sessionStore; + private final OfflineTransferSessionRepository offlineTransferSessionRepository; + private final FileContentStorage fileContentStorage; + private final OfflineTransferQuotaService offlineTransferQuotaService; + private final long maxFileSize; + + public OfflineTransferService(TransferSessionStore sessionStore, + OfflineTransferSessionRepository offlineTransferSessionRepository, + FileContentStorage fileContentStorage, + OfflineTransferQuotaService offlineTransferQuotaService, + FileStorageProperties properties) { + this.sessionStore = sessionStore; + this.offlineTransferSessionRepository = offlineTransferSessionRepository; + this.fileContentStorage = fileContentStorage; + this.offlineTransferQuotaService = offlineTransferQuotaService; + this.maxFileSize = properties.getMaxFileSize(); + } + + @Transactional + public TransferSessionResponse createSession(User sender, CreateTransferSessionRequest request) { + OfflineTransferSession session = new OfflineTransferSession(); + session.setSessionId(UUID.randomUUID().toString()); + session.setPickupCode(nextPickupCode()); + session.setSenderUserId(sender.getId()); + session.setExpiresAt(Instant.now().plus(OFFLINE_SESSION_TTL)); + session.setReady(false); + + for (TransferFileItem requestFile : request.files()) { + OfflineTransferFile file = new OfflineTransferFile(); + String normalizedFilename = normalizeLeafName(requestFile.name()); + String normalizedRelativePath = normalizeRelativePath(requestFile.relativePath(), normalizedFilename); + String fileId = UUID.randomUUID().toString(); + + file.setId(fileId); + file.setFilename(normalizedFilename); + file.setRelativePath(normalizedRelativePath); + file.setSize(requestFile.size()); + file.setContentType(normalizeContentType(requestFile.contentType())); + file.setStorageName(buildTransferStorageName(fileId, normalizedFilename)); + file.setUploaded(false); + session.addFile(file); + } + + return toSessionResponse(offlineTransferSessionRepository.save(session)); + } + + public LookupTransferSessionResponse lookupReadySession(String pickupCode) { + String normalizedPickupCode = normalizePickupCode(pickupCode); + OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesByPickupCode(normalizedPickupCode) + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "pickup code not found or expired")); + validateOfflineReadySession(offlineSession, "pickup code not found or expired"); + return toLookupResponse(offlineSession); + } + + public TransferSessionResponse joinReadySession(String sessionId) { + OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId) + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "offline transfer session not found or expired")); + validateOfflineReadySession(offlineSession, "offline transfer session not found or expired"); + return toSessionResponse(offlineSession); + } + + public List listOfflineSessions(User sender) { + return offlineTransferSessionRepository.findActiveWithFilesBySenderUserId(sender.getId(), Instant.now()).stream() + .map(this::toSessionResponse) + .toList(); + } + + public boolean hasSession(String sessionId) { + return offlineTransferSessionRepository.findWithFilesBySessionId(sessionId).isPresent(); + } + + @Transactional + public void uploadOfflineFile(User sender, String sessionId, String fileId, MultipartFile multipartFile) { + OfflineTransferSession session = getRequiredOfflineEditableSession(sender, sessionId); + OfflineTransferFile targetFile = getRequiredOfflineFile(session, fileId); + offlineTransferQuotaService.ensureUploadAllowed(targetFile, multipartFile.getSize(), maxFileSize); + + try { + fileContentStorage.storeTransferFile( + session.getSessionId(), + targetFile.getStorageName(), + normalizeContentType(targetFile.getContentType()), + multipartFile.getBytes() + ); + } catch (java.io.IOException ex) { + throw new BusinessException(ErrorCode.UNKNOWN, "offline file upload failed"); + } + + targetFile.setUploaded(true); + session.setReady(session.getFiles().stream().allMatch(OfflineTransferFile::isUploaded)); + offlineTransferSessionRepository.save(session); + } + + public ResponseEntity downloadOfflineFile(String sessionId, String fileId) { + OfflineTransferSession session = getRequiredOfflineReadySession(sessionId); + OfflineTransferFile file = getRequiredOfflineFile(session, fileId); + ensureOfflineFileUploaded(file); + + if (fileContentStorage.supportsDirectDownload()) { + String downloadUrl = fileContentStorage.createTransferDownloadUrl(sessionId, file.getStorageName(), file.getFilename()); + return ResponseEntity.status(302).location(URI.create(downloadUrl)).build(); + } + + byte[] content = fileContentStorage.readTransferFile(sessionId, file.getStorageName()); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename*=UTF-8''" + URLEncoder.encode(file.getFilename(), StandardCharsets.UTF_8)) + .contentType(MediaType.parseMediaType(normalizeContentType(file.getContentType()))) + .body(content); + } + + public long getReadyFileSize(String sessionId, String fileId) { + OfflineTransferSession session = getRequiredOfflineReadySession(sessionId); + OfflineTransferFile file = getRequiredOfflineFile(session, fileId); + ensureOfflineFileUploaded(file); + return file.getSize(); + } + + public ReadyOfflineTransferFile readReadyFile(String sessionId, String fileId) { + OfflineTransferSession session = getRequiredOfflineReadySession(sessionId); + OfflineTransferFile file = getRequiredOfflineFile(session, fileId); + ensureOfflineFileUploaded(file); + byte[] content = fileContentStorage.readTransferFile(sessionId, file.getStorageName()); + return new ReadyOfflineTransferFile( + file.getFilename(), + normalizeContentType(file.getContentType()), + file.getSize(), + content + ); + } + + @Transactional + public void pruneExpiredSessions(Instant now) { + List expiredSessions = offlineTransferSessionRepository.findAllExpiredWithFiles(now); + if (expiredSessions.isEmpty()) { + return; + } + + for (OfflineTransferSession session : expiredSessions) { + for (OfflineTransferFile file : session.getFiles()) { + if (file.isUploaded()) { + fileContentStorage.deleteTransferFile(session.getSessionId(), file.getStorageName()); + } + } + } + offlineTransferSessionRepository.deleteAll(expiredSessions); + } + + private String nextPickupCode() { + String pickupCode; + do { + pickupCode = sessionStore.nextPickupCode(); + } while (offlineTransferSessionRepository.existsByPickupCode(pickupCode)); + return pickupCode; + } + + private TransferSessionResponse toSessionResponse(OfflineTransferSession session) { + return new TransferSessionResponse( + session.getSessionId(), + session.getPickupCode(), + TransferMode.OFFLINE, + session.getExpiresAt(), + session.getFiles().stream().map(this::toFileItem).toList() + ); + } + + private LookupTransferSessionResponse toLookupResponse(OfflineTransferSession session) { + return new LookupTransferSessionResponse( + session.getSessionId(), + session.getPickupCode(), + TransferMode.OFFLINE, + session.getExpiresAt() + ); + } + + private TransferFileItem toFileItem(OfflineTransferFile file) { + return new TransferFileItem( + file.getId(), + file.getFilename(), + file.getRelativePath(), + file.getSize(), + normalizeContentType(file.getContentType()), + file.isUploaded() + ); + } + + private OfflineTransferSession getRequiredOfflineEditableSession(User sender, String sessionId) { + OfflineTransferSession session = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId) + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "offline transfer session not found or expired")); + if (!Objects.equals(session.getSenderUserId(), sender.getId())) { + throw new BusinessException(ErrorCode.PERMISSION_DENIED, "no permission to upload this offline transfer file"); + } + if (session.isExpired(Instant.now())) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "offline transfer session not found or expired"); + } + return session; + } + + private OfflineTransferSession getRequiredOfflineReadySession(String sessionId) { + OfflineTransferSession session = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId) + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "offline transfer session not found or expired")); + validateOfflineReadySession(session, "offline transfer session not found or expired"); + return session; + } + + private OfflineTransferFile getRequiredOfflineFile(OfflineTransferSession session, String fileId) { + return session.getFiles().stream() + .filter(file -> file.getId().equals(fileId)) + .findFirst() + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "offline transfer file not found")); + } + + private void ensureOfflineFileUploaded(OfflineTransferFile file) { + if (!file.isUploaded()) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "offline transfer file not found"); + } + } + + private String normalizePickupCode(String pickupCode) { + String normalized = Objects.requireNonNullElse(pickupCode, "").replaceAll("\\D", ""); + if (normalized.length() != 6) { + throw new BusinessException(ErrorCode.UNKNOWN, "invalid pickup code"); + } + return normalized; + } + + private void validateOfflineReadySession(OfflineTransferSession session, String notFoundMessage) { + if (session.isExpired(Instant.now())) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND, notFoundMessage); + } + if (!session.isReady()) { + throw new BusinessException(ErrorCode.UNKNOWN, "offline transfer is still uploading"); + } + } + + private String normalizeContentType(String contentType) { + String normalized = Objects.requireNonNullElse(contentType, "").trim(); + return normalized.isEmpty() ? "application/octet-stream" : normalized; + } + + private String normalizeLeafName(String value) { + String normalized = Objects.requireNonNullElse(value, "").trim(); + if (normalized.isEmpty()) { + throw new BusinessException(ErrorCode.UNKNOWN, "file name cannot be empty"); + } + if (normalized.contains("/") || normalized.contains("\\") || ".".equals(normalized) || "..".equals(normalized)) { + throw new BusinessException(ErrorCode.UNKNOWN, "invalid file name"); + } + return normalized; + } + + private String normalizeRelativePath(String relativePath, String fallbackFilename) { + String rawPath = Objects.requireNonNullElse(relativePath, fallbackFilename).replace('\\', '/'); + List segments = new ArrayList<>(); + for (String segment : rawPath.split("/")) { + String trimmed = segment.trim(); + if (trimmed.isEmpty()) { + continue; + } + if (".".equals(trimmed) || "..".equals(trimmed)) { + throw new BusinessException(ErrorCode.UNKNOWN, "invalid file path"); + } + segments.add(trimmed); + } + + String normalizedFilename = normalizeLeafName(fallbackFilename); + if (segments.isEmpty()) { + return normalizedFilename; + } + + List normalizedSegments = new ArrayList<>(segments.subList(0, Math.max(0, segments.size() - 1))); + normalizedSegments.add(normalizedFilename); + return String.join("/", normalizedSegments); + } + + private String buildTransferStorageName(String fileId, String filename) { + int extensionIndex = filename.lastIndexOf('.'); + String extension = extensionIndex > 0 ? filename.substring(extensionIndex) : ""; + return fileId + extension; + } + + public record ReadyOfflineTransferFile( + String filename, + String contentType, + long size, + byte[] content + ) { + } +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/OnlineTransferService.java b/backend/src/main/java/com/yoyuzh/transfer/OnlineTransferService.java new file mode 100644 index 0000000..30f69ed --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/OnlineTransferService.java @@ -0,0 +1,135 @@ +package com.yoyuzh.transfer; + +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OnlineTransferService { + + private static final Duration ONLINE_SESSION_TTL = Duration.ofMinutes(15); + + private final TransferSessionStore sessionStore; + private final OfflineTransferSessionRepository offlineTransferSessionRepository; + + public TransferSessionResponse createSession(CreateTransferSessionRequest request) { + String sessionId = UUID.randomUUID().toString(); + String pickupCode = nextPickupCode(); + Instant expiresAt = Instant.now().plus(ONLINE_SESSION_TTL); + List files = request.files().stream() + .map(this::normalizeOnlineFileItem) + .toList(); + + TransferSession session = new TransferSession(sessionId, pickupCode, expiresAt, files); + sessionStore.save(session); + return session.toSessionResponse(); + } + + public LookupTransferSessionResponse lookupSession(String pickupCode) { + TransferSession onlineSession = sessionStore.findByPickupCode(pickupCode).orElse(null); + return onlineSession == null ? null : onlineSession.toLookupResponse(); + } + + public TransferSessionResponse joinSession(String sessionId) { + return sessionStore.withSession(sessionId, onlineSession -> { + try { + onlineSession.markReceiverJoined(); + } catch (IllegalStateException ex) { + throw new BusinessException(ErrorCode.UNKNOWN, "online transfer session can only be joined once"); + } + sessionStore.save(onlineSession); + return onlineSession.toSessionResponse(); + }); + } + + public boolean postSignal(String sessionId, String role, TransferSignalRequest request) { + Boolean handled = sessionStore.withSession(sessionId, session -> { + session.enqueue(TransferRole.from(role), request.type().trim(), request.payload().trim()); + sessionStore.save(session); + return true; + }); + return Boolean.TRUE.equals(handled); + } + + public PollTransferSignalsResponse pollSignals(String sessionId, String role, long after) { + TransferSession session = sessionStore.findById(sessionId).orElse(null); + if (session == null) { + return null; + } + return session.poll(TransferRole.from(role), Math.max(0, after)); + } + + public void pruneExpiredSessions(Instant now) { + sessionStore.pruneExpired(now); + } + + private String nextPickupCode() { + String pickupCode; + do { + pickupCode = sessionStore.nextPickupCode(); + } while (offlineTransferSessionRepository.existsByPickupCode(pickupCode)); + return pickupCode; + } + + private TransferFileItem normalizeOnlineFileItem(TransferFileItem file) { + String normalizedFilename = normalizeLeafName(file.name()); + String normalizedRelativePath = normalizeRelativePath(file.relativePath(), normalizedFilename); + return new TransferFileItem( + null, + normalizedFilename, + normalizedRelativePath, + file.size(), + normalizeContentType(file.contentType()), + null + ); + } + + private String normalizeContentType(String contentType) { + String normalized = Objects.requireNonNullElse(contentType, "").trim(); + return normalized.isEmpty() ? "application/octet-stream" : normalized; + } + + private String normalizeLeafName(String value) { + String normalized = Objects.requireNonNullElse(value, "").trim(); + if (normalized.isEmpty()) { + throw new BusinessException(ErrorCode.UNKNOWN, "file name cannot be empty"); + } + if (normalized.contains("/") || normalized.contains("\\") || ".".equals(normalized) || "..".equals(normalized)) { + throw new BusinessException(ErrorCode.UNKNOWN, "invalid file name"); + } + return normalized; + } + + private String normalizeRelativePath(String relativePath, String fallbackFilename) { + String rawPath = Objects.requireNonNullElse(relativePath, fallbackFilename).replace('\\', '/'); + List segments = new ArrayList<>(); + for (String segment : rawPath.split("/")) { + String trimmed = segment.trim(); + if (trimmed.isEmpty()) { + continue; + } + if (".".equals(trimmed) || "..".equals(trimmed)) { + throw new BusinessException(ErrorCode.UNKNOWN, "invalid file path"); + } + segments.add(trimmed); + } + + String normalizedFilename = normalizeLeafName(fallbackFilename); + if (segments.isEmpty()) { + return normalizedFilename; + } + + List normalizedSegments = new ArrayList<>(segments.subList(0, Math.max(0, segments.size() - 1))); + normalizedSegments.add(normalizedFilename); + return String.join("/", normalizedSegments); + } +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferImportService.java b/backend/src/main/java/com/yoyuzh/transfer/TransferImportService.java new file mode 100644 index 0000000..c8f7788 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferImportService.java @@ -0,0 +1,29 @@ +package com.yoyuzh.transfer; + +import com.yoyuzh.auth.User; +import com.yoyuzh.files.core.FileMetadataResponse; +import com.yoyuzh.files.core.FileService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TransferImportService { + + private final OfflineTransferService offlineTransferService; + private final FileService fileService; + + @Transactional + public FileMetadataResponse importOfflineFile(User recipient, String sessionId, String fileId, String path) { + OfflineTransferService.ReadyOfflineTransferFile readyFile = offlineTransferService.readReadyFile(sessionId, fileId); + return fileService.importExternalFile( + recipient, + path, + readyFile.filename(), + readyFile.contentType(), + readyFile.size(), + readyFile.content() + ); + } +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferService.java b/backend/src/main/java/com/yoyuzh/transfer/TransferService.java index 5b3f3e4..b145020 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/TransferService.java +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferService.java @@ -4,55 +4,26 @@ import com.yoyuzh.admin.AdminMetricsService; import com.yoyuzh.auth.User; import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.ErrorCode; -import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.files.core.FileMetadataResponse; -import com.yoyuzh.files.core.FileService; -import com.yoyuzh.files.storage.FileContentStorage; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; +import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.net.URI; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.UUID; -import java.util.concurrent.ThreadLocalRandom; @Service +@RequiredArgsConstructor public class TransferService { - private static final Duration ONLINE_SESSION_TTL = Duration.ofMinutes(15); - private static final Duration OFFLINE_SESSION_TTL = Duration.ofDays(7); - - private final TransferSessionStore sessionStore; - private final OfflineTransferSessionRepository offlineTransferSessionRepository; - private final FileContentStorage fileContentStorage; - private final FileService fileService; + private final OnlineTransferService onlineTransferService; + private final OfflineTransferService offlineTransferService; + private final TransferImportService transferImportService; private final AdminMetricsService adminMetricsService; - private final long maxFileSize; - - public TransferService(TransferSessionStore sessionStore, - OfflineTransferSessionRepository offlineTransferSessionRepository, - FileContentStorage fileContentStorage, - FileService fileService, - AdminMetricsService adminMetricsService, - FileStorageProperties properties) { - this.sessionStore = sessionStore; - this.offlineTransferSessionRepository = offlineTransferSessionRepository; - this.fileContentStorage = fileContentStorage; - this.fileService = fileService; - this.adminMetricsService = adminMetricsService; - this.maxFileSize = properties.getMaxFileSize(); - } @Transactional public TransferSessionResponse createSession(User sender, CreateTransferSessionRequest request) { @@ -60,150 +31,77 @@ public class TransferService { adminMetricsService.recordTransferUsage(request.files().stream().mapToLong(TransferFileItem::size).sum()); if (request.mode() == TransferMode.OFFLINE) { if (sender == null) { - throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "离线快传需要登录后使用"); + throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "offline transfer requires authenticated user"); } - return createOfflineSession(sender, request); + return offlineTransferService.createSession(sender, request); } - return createOnlineSession(request); + return onlineTransferService.createSession(request); } public LookupTransferSessionResponse lookupSession(String pickupCode) { pruneExpiredSessions(); String normalizedPickupCode = normalizePickupCode(pickupCode); - TransferSession onlineSession = sessionStore.findByPickupCode(normalizedPickupCode).orElse(null); - if (onlineSession != null) { - return onlineSession.toLookupResponse(); + LookupTransferSessionResponse online = onlineTransferService.lookupSession(normalizedPickupCode); + if (online != null) { + return online; } - - OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesByPickupCode(normalizedPickupCode) - .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效")); - validateOfflineReadySession(offlineSession, "取件码不存在或已失效"); - return toLookupResponse(offlineSession); + return offlineTransferService.lookupReadySession(normalizedPickupCode); } public TransferSessionResponse joinSession(String sessionId) { pruneExpiredSessions(); - - TransferSession onlineSession = sessionStore.findById(sessionId).orElse(null); - if (onlineSession != null) { - try { - onlineSession.markReceiverJoined(); - } catch (IllegalStateException ex) { - throw new BusinessException(ErrorCode.UNKNOWN, "在线快传不能被多次接收,请让发送方重新发起"); - } - return onlineSession.toSessionResponse(); + TransferSessionResponse online = onlineTransferService.joinSession(sessionId); + if (online != null) { + return online; } - - OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId) - .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "快传会话不存在或已失效")); - validateOfflineReadySession(offlineSession, "离线快传会话不存在或已失效"); - return toSessionResponse(offlineSession); + return offlineTransferService.joinReadySession(sessionId); } public List listOfflineSessions(User sender) { pruneExpiredSessions(); - return offlineTransferSessionRepository.findActiveWithFilesBySenderUserId(sender.getId(), Instant.now()).stream() - .map(this::toSessionResponse) - .toList(); + return offlineTransferService.listOfflineSessions(sender); } @Transactional public void uploadOfflineFile(User sender, String sessionId, String fileId, MultipartFile multipartFile) { pruneExpiredSessions(); - OfflineTransferSession session = getRequiredOfflineEditableSession(sender, sessionId); - OfflineTransferFile targetFile = getRequiredOfflineFile(session, fileId); - - if (multipartFile.getSize() <= 0) { - throw new BusinessException(ErrorCode.UNKNOWN, "离线文件不能为空"); - } - if (multipartFile.getSize() > maxFileSize) { - throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制"); - } - if (multipartFile.getSize() != targetFile.getSize()) { - throw new BusinessException(ErrorCode.UNKNOWN, "离线文件大小与会话清单不一致"); - } - long currentOfflineStorageBytes = offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now()); - long additionalBytes = targetFile.isUploaded() ? 0L : targetFile.getSize(); - if (currentOfflineStorageBytes + additionalBytes > adminMetricsService.getOfflineTransferStorageLimitBytes()) { - throw new BusinessException(ErrorCode.UNKNOWN, "离线快传存储空间不足,请联系管理员调整上限"); - } - - try { - fileContentStorage.storeTransferFile( - session.getSessionId(), - targetFile.getStorageName(), - normalizeContentType(targetFile.getContentType()), - multipartFile.getBytes() - ); - } catch (java.io.IOException ex) { - throw new BusinessException(ErrorCode.UNKNOWN, "离线文件上传失败"); - } - - targetFile.setUploaded(true); - session.setReady(session.getFiles().stream().allMatch(OfflineTransferFile::isUploaded)); - offlineTransferSessionRepository.save(session); + offlineTransferService.uploadOfflineFile(sender, sessionId, fileId, multipartFile); } public void postSignal(String sessionId, String role, TransferSignalRequest request) { pruneExpiredSessions(); - TransferSession session = sessionStore.findById(sessionId).orElse(null); - if (session == null) { - if (offlineTransferSessionRepository.findWithFilesBySessionId(sessionId).isPresent()) { - throw new BusinessException(ErrorCode.UNKNOWN, "离线快传无需建立在线连接"); - } - throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "快传会话不存在或已失效"); + if (onlineTransferService.postSignal(sessionId, role, request)) { + return; } - session.enqueue(TransferRole.from(role), request.type().trim(), request.payload().trim()); + if (offlineTransferService.hasSession(sessionId)) { + throw new BusinessException(ErrorCode.UNKNOWN, "offline transfer does not need realtime signals"); + } + throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "transfer session not found or expired"); } public PollTransferSignalsResponse pollSignals(String sessionId, String role, long after) { pruneExpiredSessions(); - TransferSession session = sessionStore.findById(sessionId).orElse(null); - if (session == null) { - if (offlineTransferSessionRepository.findWithFilesBySessionId(sessionId).isPresent()) { - throw new BusinessException(ErrorCode.UNKNOWN, "离线快传无需轮询信令"); - } - throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "快传会话不存在或已失效"); + PollTransferSignalsResponse online = onlineTransferService.pollSignals(sessionId, role, after); + if (online != null) { + return online; } - return session.poll(TransferRole.from(role), Math.max(0, after)); + if (offlineTransferService.hasSession(sessionId)) { + throw new BusinessException(ErrorCode.UNKNOWN, "offline transfer does not need signal polling"); + } + throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "transfer session not found or expired"); } public ResponseEntity downloadOfflineFile(String sessionId, String fileId) { pruneExpiredSessions(); - OfflineTransferSession session = getRequiredOfflineReadySession(sessionId); - OfflineTransferFile file = getRequiredOfflineFile(session, fileId); - ensureOfflineFileUploaded(file); - adminMetricsService.recordDownloadTraffic(file.getSize()); - - if (fileContentStorage.supportsDirectDownload()) { - String downloadUrl = fileContentStorage.createTransferDownloadUrl(sessionId, file.getStorageName(), file.getFilename()); - return ResponseEntity.status(302).location(URI.create(downloadUrl)).build(); - } - - byte[] content = fileContentStorage.readTransferFile(sessionId, file.getStorageName()); - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename*=UTF-8''" + URLEncoder.encode(file.getFilename(), StandardCharsets.UTF_8)) - .contentType(MediaType.parseMediaType(normalizeContentType(file.getContentType()))) - .body(content); + adminMetricsService.recordDownloadTraffic(offlineTransferService.getReadyFileSize(sessionId, fileId)); + return offlineTransferService.downloadOfflineFile(sessionId, fileId); } @Transactional public FileMetadataResponse importOfflineFile(User recipient, String sessionId, String fileId, String path) { pruneExpiredSessions(); - OfflineTransferSession session = getRequiredOfflineReadySession(sessionId); - OfflineTransferFile file = getRequiredOfflineFile(session, fileId); - ensureOfflineFileUploaded(file); - byte[] content = fileContentStorage.readTransferFile(sessionId, file.getStorageName()); - return fileService.importExternalFile( - recipient, - path, - file.getFilename(), - normalizeContentType(file.getContentType()), - file.getSize(), - content - ); + return transferImportService.importOfflineFile(recipient, sessionId, fileId, path); } @Scheduled(fixedDelay = 60 * 60 * 1000L) @@ -212,215 +110,17 @@ public class TransferService { pruneExpiredSessions(); } - private TransferSessionResponse createOnlineSession(CreateTransferSessionRequest request) { - String sessionId = UUID.randomUUID().toString(); - String pickupCode = nextPickupCode(); - Instant expiresAt = Instant.now().plus(ONLINE_SESSION_TTL); - List files = request.files().stream() - .map(this::normalizeOnlineFileItem) - .toList(); - - TransferSession session = new TransferSession(sessionId, pickupCode, expiresAt, files); - sessionStore.save(session); - return session.toSessionResponse(); - } - - private TransferSessionResponse createOfflineSession(User sender, CreateTransferSessionRequest request) { - OfflineTransferSession session = new OfflineTransferSession(); - session.setSessionId(UUID.randomUUID().toString()); - session.setPickupCode(nextPickupCode()); - session.setSenderUserId(sender.getId()); - session.setExpiresAt(Instant.now().plus(OFFLINE_SESSION_TTL)); - session.setReady(false); - - for (TransferFileItem requestFile : request.files()) { - OfflineTransferFile file = new OfflineTransferFile(); - String normalizedFilename = normalizeLeafName(requestFile.name()); - String normalizedRelativePath = normalizeRelativePath(requestFile.relativePath(), normalizedFilename); - String fileId = UUID.randomUUID().toString(); - - file.setId(fileId); - file.setFilename(normalizedFilename); - file.setRelativePath(normalizedRelativePath); - file.setSize(requestFile.size()); - file.setContentType(normalizeContentType(requestFile.contentType())); - file.setStorageName(buildTransferStorageName(fileId, normalizedFilename)); - file.setUploaded(false); - session.addFile(file); - } - - return toSessionResponse(offlineTransferSessionRepository.save(session)); - } - - private TransferFileItem normalizeOnlineFileItem(TransferFileItem file) { - String normalizedFilename = normalizeLeafName(file.name()); - String normalizedRelativePath = normalizeRelativePath(file.relativePath(), normalizedFilename); - return new TransferFileItem( - null, - normalizedFilename, - normalizedRelativePath, - file.size(), - normalizeContentType(file.contentType()), - null - ); - } - - private TransferSessionResponse toSessionResponse(OfflineTransferSession session) { - return new TransferSessionResponse( - session.getSessionId(), - session.getPickupCode(), - TransferMode.OFFLINE, - session.getExpiresAt(), - session.getFiles().stream().map(this::toFileItem).toList() - ); - } - - private LookupTransferSessionResponse toLookupResponse(OfflineTransferSession session) { - return new LookupTransferSessionResponse( - session.getSessionId(), - session.getPickupCode(), - TransferMode.OFFLINE, - session.getExpiresAt() - ); - } - - private TransferFileItem toFileItem(OfflineTransferFile file) { - return new TransferFileItem( - file.getId(), - file.getFilename(), - file.getRelativePath(), - file.getSize(), - normalizeContentType(file.getContentType()), - file.isUploaded() - ); - } - private void pruneExpiredSessions() { Instant now = Instant.now(); - sessionStore.pruneExpired(now); - List expiredSessions = offlineTransferSessionRepository.findAllExpiredWithFiles(now); - if (expiredSessions.isEmpty()) { - return; - } - - for (OfflineTransferSession session : expiredSessions) { - for (OfflineTransferFile file : session.getFiles()) { - if (file.isUploaded()) { - fileContentStorage.deleteTransferFile(session.getSessionId(), file.getStorageName()); - } - } - } - offlineTransferSessionRepository.deleteAll(expiredSessions); - } - - private OfflineTransferSession getRequiredOfflineEditableSession(User sender, String sessionId) { - OfflineTransferSession session = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId) - .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线快传会话不存在或已失效")); - if (!Objects.equals(session.getSenderUserId(), sender.getId())) { - throw new BusinessException(ErrorCode.PERMISSION_DENIED, "没有权限上传该离线快传文件"); - } - if (session.isExpired(Instant.now())) { - throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线快传会话不存在或已失效"); - } - return session; - } - - private OfflineTransferSession getRequiredOfflineReadySession(String sessionId) { - OfflineTransferSession session = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId) - .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线快传会话不存在或已失效")); - validateOfflineReadySession(session, "离线快传会话不存在或已失效"); - return session; - } - - private OfflineTransferSession getRequiredOfflineReadySessionByPickupCode(String pickupCode) { - OfflineTransferSession session = offlineTransferSessionRepository.findWithFilesByPickupCode(pickupCode) - .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效")); - validateOfflineReadySession(session, "取件码不存在或已失效"); - return session; - } - - private OfflineTransferFile getRequiredOfflineFile(OfflineTransferSession session, String fileId) { - return session.getFiles().stream() - .filter(file -> file.getId().equals(fileId)) - .findFirst() - .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线文件不存在")); - } - - private void ensureOfflineFileUploaded(OfflineTransferFile file) { - if (!file.isUploaded()) { - throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线文件不存在"); - } - } - - private String nextPickupCode() { - String pickupCode; - do { - pickupCode = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000)); - } while (sessionStore.findByPickupCode(pickupCode).isPresent() - || offlineTransferSessionRepository.existsByPickupCode(pickupCode)); - return pickupCode; + onlineTransferService.pruneExpiredSessions(now); + offlineTransferService.pruneExpiredSessions(now); } private String normalizePickupCode(String pickupCode) { String normalized = Objects.requireNonNullElse(pickupCode, "").replaceAll("\\D", ""); if (normalized.length() != 6) { - throw new BusinessException(ErrorCode.UNKNOWN, "取件码格式不正确"); + throw new BusinessException(ErrorCode.UNKNOWN, "invalid pickup code"); } return normalized; } - - private void validateOfflineReadySession(OfflineTransferSession session, String notFoundMessage) { - if (session.isExpired(Instant.now())) { - throw new BusinessException(ErrorCode.FILE_NOT_FOUND, notFoundMessage); - } - if (!session.isReady()) { - throw new BusinessException(ErrorCode.UNKNOWN, "离线快传仍在上传中,请稍后再试"); - } - } - - private String normalizeContentType(String contentType) { - String normalized = Objects.requireNonNullElse(contentType, "").trim(); - return normalized.isEmpty() ? "application/octet-stream" : normalized; - } - - private String normalizeLeafName(String value) { - String normalized = Objects.requireNonNullElse(value, "").trim(); - if (normalized.isEmpty()) { - throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空"); - } - if (normalized.contains("/") || normalized.contains("\\") || ".".equals(normalized) || "..".equals(normalized)) { - throw new BusinessException(ErrorCode.UNKNOWN, "文件名不合法"); - } - return normalized; - } - - private String normalizeRelativePath(String relativePath, String fallbackFilename) { - String rawPath = Objects.requireNonNullElse(relativePath, fallbackFilename).replace('\\', '/'); - List segments = new ArrayList<>(); - for (String segment : rawPath.split("/")) { - String trimmed = segment.trim(); - if (trimmed.isEmpty()) { - continue; - } - if (".".equals(trimmed) || "..".equals(trimmed)) { - throw new BusinessException(ErrorCode.UNKNOWN, "文件路径不合法"); - } - segments.add(trimmed); - } - - String normalizedFilename = normalizeLeafName(fallbackFilename); - if (segments.isEmpty()) { - return normalizedFilename; - } - - List normalizedSegments = new ArrayList<>(segments.subList(0, Math.max(0, segments.size() - 1))); - normalizedSegments.add(normalizedFilename); - return String.join("/", normalizedSegments); - } - - private String buildTransferStorageName(String fileId, String filename) { - int extensionIndex = filename.lastIndexOf('.'); - String extension = extensionIndex > 0 ? filename.substring(extensionIndex) : ""; - return fileId + extension; - } } diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferSession.java b/backend/src/main/java/com/yoyuzh/transfer/TransferSession.java index bdc1a7f..d495088 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/TransferSession.java +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferSession.java @@ -69,4 +69,35 @@ final class TransferSession { String pickupCode() { return pickupCode; } + + synchronized TransferSessionState toState() { + return new TransferSessionState( + sessionId, + pickupCode, + expiresAt, + files, + List.copyOf(senderQueue), + List.copyOf(receiverQueue), + receiverJoined, + nextSenderCursor, + nextReceiverCursor + ); + } + + static TransferSession fromState(TransferSessionState state) { + TransferSession session = new TransferSession( + state.sessionId(), + state.pickupCode(), + state.expiresAt(), + state.files() + ); + synchronized (session) { + session.senderQueue.addAll(state.senderQueue()); + session.receiverQueue.addAll(state.receiverQueue()); + session.receiverJoined = state.receiverJoined(); + session.nextSenderCursor = state.nextSenderCursor(); + session.nextReceiverCursor = state.nextReceiverCursor(); + } + return session; + } } diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferSessionState.java b/backend/src/main/java/com/yoyuzh/transfer/TransferSessionState.java new file mode 100644 index 0000000..0b7b3ac --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferSessionState.java @@ -0,0 +1,17 @@ +package com.yoyuzh.transfer; + +import java.time.Instant; +import java.util.List; + +public record TransferSessionState( + String sessionId, + String pickupCode, + Instant expiresAt, + List files, + List senderQueue, + List receiverQueue, + boolean receiverJoined, + long nextSenderCursor, + long nextReceiverCursor +) { +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferSessionStore.java b/backend/src/main/java/com/yoyuzh/transfer/TransferSessionStore.java index b256b89..e389644 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/TransferSessionStore.java +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferSessionStore.java @@ -1,29 +1,131 @@ package com.yoyuzh.transfer; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.common.lock.DistributedLockService; +import com.yoyuzh.config.AppRedisProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import java.time.Duration; import java.time.Instant; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Function; +import java.util.function.Supplier; @Component public class TransferSessionStore { + private static final String RESERVED_PICKUP_CODE = "__reserved__"; + private static final Duration SESSION_LOCK_TTL = Duration.ofSeconds(5); + private final Map sessionsById = new ConcurrentHashMap<>(); private final Map sessionIdsByPickupCode = new ConcurrentHashMap<>(); + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper; + private final AppRedisProperties redisProperties; + private final DistributedLockService distributedLockService; + + @Autowired + public TransferSessionStore(ObjectProvider stringRedisTemplateProvider, + ObjectMapper objectMapper, + AppRedisProperties redisProperties, + ObjectProvider distributedLockServiceProvider) { + this( + stringRedisTemplateProvider.getIfAvailable(), + objectMapper, + redisProperties, + distributedLockServiceProvider.getIfAvailable(DistributedLockService::noOp) + ); + } + + TransferSessionStore(StringRedisTemplate stringRedisTemplate, + ObjectMapper objectMapper, + AppRedisProperties redisProperties, + DistributedLockService distributedLockService) { + this.stringRedisTemplate = stringRedisTemplate; + this.objectMapper = objectMapper; + this.redisProperties = redisProperties; + this.distributedLockService = distributedLockService == null ? DistributedLockService.noOp() : distributedLockService; + } public void save(TransferSession session) { + if (session == null) { + return; + } + if (redisEnabled()) { + Duration ttl = resolveTtl(session.toState().expiresAt()); + try { + stringRedisTemplate.opsForValue().set( + buildSessionKey(session.sessionId()), + objectMapper.writeValueAsString(session.toState()), + ttl + ); + stringRedisTemplate.opsForValue().set( + buildPickupCodeKey(session.pickupCode()), + session.sessionId(), + ttl + ); + } catch (JsonProcessingException ignored) { + } + return; + } sessionsById.put(session.sessionId(), session); sessionIdsByPickupCode.put(session.pickupCode(), session.sessionId()); } public Optional findById(String sessionId) { - return Optional.ofNullable(sessionsById.get(sessionId)); + if (!StringUtils.hasText(sessionId)) { + return Optional.empty(); + } + if (redisEnabled()) { + String rawValue = stringRedisTemplate.opsForValue().get(buildSessionKey(sessionId)); + if (!StringUtils.hasText(rawValue)) { + return Optional.empty(); + } + try { + TransferSession session = TransferSession.fromState(objectMapper.readValue(rawValue, TransferSessionState.class)); + if (session.isExpired(Instant.now())) { + remove(session); + return Optional.empty(); + } + return Optional.of(session); + } catch (JsonProcessingException ex) { + stringRedisTemplate.delete(buildSessionKey(sessionId)); + return Optional.empty(); + } + } + + TransferSession session = sessionsById.get(sessionId); + if (session != null && session.isExpired(Instant.now())) { + remove(session); + return Optional.empty(); + } + return Optional.ofNullable(session); } public Optional findByPickupCode(String pickupCode) { + if (!StringUtils.hasText(pickupCode)) { + return Optional.empty(); + } + if (redisEnabled()) { + String sessionId = stringRedisTemplate.opsForValue().get(buildPickupCodeKey(pickupCode)); + if (!StringUtils.hasText(sessionId) || RESERVED_PICKUP_CODE.equals(sessionId)) { + return Optional.empty(); + } + Optional session = findById(sessionId); + if (session.isEmpty()) { + stringRedisTemplate.delete(buildPickupCodeKey(pickupCode)); + } + return session; + } + String sessionId = sessionIdsByPickupCode.get(pickupCode); if (sessionId == null) { return Optional.empty(); @@ -33,11 +135,22 @@ public class TransferSessionStore { } public void remove(TransferSession session) { + if (session == null) { + return; + } + if (redisEnabled()) { + stringRedisTemplate.delete(buildSessionKey(session.sessionId())); + stringRedisTemplate.delete(buildPickupCodeKey(session.pickupCode())); + return; + } sessionsById.remove(session.sessionId(), session); sessionIdsByPickupCode.remove(session.pickupCode(), session.sessionId()); } public void pruneExpired(Instant now) { + if (redisEnabled()) { + return; + } for (TransferSession session : sessionsById.values()) { if (session.isExpired(now)) { remove(session); @@ -46,10 +159,69 @@ public class TransferSessionStore { } public String nextPickupCode() { + if (redisEnabled()) { + Duration reservationTtl = Duration.ofSeconds(Math.max( + redisProperties == null ? 60L : redisProperties.getTtlBufferSeconds(), + 60L + )); + String pickupCode; + do { + pickupCode = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000)); + } while (!Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent( + buildPickupCodeKey(pickupCode), + RESERVED_PICKUP_CODE, + reservationTtl + ))); + return pickupCode; + } + String pickupCode; do { pickupCode = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000)); } while (sessionIdsByPickupCode.containsKey(pickupCode)); return pickupCode; } + + public T executeWithSessionLock(String sessionId, Supplier action) { + if (!StringUtils.hasText(sessionId)) { + return action.get(); + } + return distributedLockService.executeWithLock("transfer-session:" + sessionId.trim(), SESSION_LOCK_TTL, action); + } + + public T withSession(String sessionId, Function action) { + return executeWithSessionLock(sessionId, () -> findById(sessionId) + .map(action) + .orElse(null)); + } + + private Duration resolveTtl(Instant expiresAt) { + long bufferSeconds = redisProperties == null ? 60L : redisProperties.getTtlBufferSeconds(); + Duration base = Duration.ofSeconds(Math.max(bufferSeconds, 60L)); + if (expiresAt == null) { + return base; + } + long seconds = Math.max(1L, Duration.between(Instant.now(), expiresAt).getSeconds()); + return Duration.ofSeconds(seconds + bufferSeconds); + } + + private String buildSessionKey(String sessionId) { + return buildPrefix() + ":session:" + sessionId.trim(); + } + + private String buildPickupCodeKey(String pickupCode) { + return buildPrefix() + ":pickup:" + pickupCode.trim(); + } + + private String buildPrefix() { + String keyPrefix = redisProperties == null ? "yoyuzh" : redisProperties.getKeyPrefix(); + String namespace = redisProperties == null + ? "transfer-sessions" + : redisProperties.getNamespaces().getTransferSessions(); + return keyPrefix + ":" + namespace; + } + + private boolean redisEnabled() { + return redisProperties != null && redisProperties.isEnabled() && stringRedisTemplate != null; + } } diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index 9f41050..9269138 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -1,4 +1,11 @@ spring: + data: + redis: + host: ${SPRING_DATA_REDIS_HOST:127.0.0.1} + port: ${SPRING_DATA_REDIS_PORT:6379} + password: ${SPRING_DATA_REDIS_PASSWORD:} + database: ${SPRING_DATA_REDIS_DATABASE:1} + timeout: ${SPRING_DATA_REDIS_TIMEOUT:2s} datasource: url: jdbc:h2:file:./data/yoyuzh_portal_dev;MODE=MySQL;AUTO_SERVER=TRUE;DB_CLOSE_DELAY=-1 username: sa @@ -7,6 +14,11 @@ spring: jpa: hibernate: ddl-auto: update + sql: + init: + mode: always + continue-on-error: true + schema-locations: classpath:dev-h2-preinit.sql h2: console: enabled: true @@ -16,6 +28,10 @@ app: jwt: secret: ${APP_JWT_SECRET:} admin: - usernames: ${APP_ADMIN_USERNAMES:} + usernames: ${APP_ADMIN_USERNAMES:admin} registration: invite-code: ${APP_AUTH_REGISTRATION_INVITE_CODE:dev-invite-code} + redis: + enabled: ${APP_REDIS_ENABLED:false} + cache: + directory-version-ttl-seconds: ${APP_REDIS_CACHE_DIRECTORY_VERSION_TTL_SECONDS:3600} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 7a5efe9..55f23f6 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -5,6 +5,15 @@ server: spring: application: name: yoyuzh-portal-backend + data: + redis: + host: ${SPRING_DATA_REDIS_HOST:127.0.0.1} + port: ${SPRING_DATA_REDIS_PORT:6379} + password: ${SPRING_DATA_REDIS_PASSWORD:} + database: ${SPRING_DATA_REDIS_DATABASE:0} + timeout: ${SPRING_DATA_REDIS_TIMEOUT:5s} + repositories: + enabled: false datasource: url: jdbc:mysql://localhost:3306/yoyuzh_portal?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 username: root @@ -61,6 +70,24 @@ app: - capacitor://localhost - https://yoyuzh.xyz - https://www.yoyuzh.xyz + redis: + enabled: ${APP_REDIS_ENABLED:false} + key-prefix: ${APP_REDIS_KEY_PREFIX:yoyuzh} + ttl-buffer-seconds: ${APP_REDIS_TTL_BUFFER_SECONDS:60} + cache: + files-list-ttl-seconds: ${APP_REDIS_CACHE_FILES_LIST_TTL_SECONDS:60} + directory-version-ttl-seconds: ${APP_REDIS_CACHE_DIRECTORY_VERSION_TTL_SECONDS:3600} + admin-summary-ttl-seconds: ${APP_REDIS_CACHE_ADMIN_SUMMARY_TTL_SECONDS:30} + storage-policies-ttl-seconds: ${APP_REDIS_CACHE_STORAGE_POLICIES_TTL_SECONDS:300} + android-release-ttl-seconds: ${APP_REDIS_CACHE_ANDROID_RELEASE_TTL_SECONDS:60} + namespaces: + cache: ${APP_REDIS_NAMESPACE_CACHE:cache} + auth: ${APP_REDIS_NAMESPACE_AUTH:auth} + transfer-sessions: ${APP_REDIS_NAMESPACE_TRANSFER_SESSIONS:transfer-sessions} + upload-state: ${APP_REDIS_NAMESPACE_UPLOAD_STATE:upload-state} + locks: ${APP_REDIS_NAMESPACE_LOCKS:locks} + file-events: ${APP_REDIS_NAMESPACE_FILE_EVENTS:file-events} + broker: ${APP_REDIS_NAMESPACE_BROKER:broker} springdoc: swagger-ui: diff --git a/backend/src/main/resources/dev-h2-preinit.sql b/backend/src/main/resources/dev-h2-preinit.sql new file mode 100644 index 0000000..d4149b0 --- /dev/null +++ b/backend/src/main/resources/dev-h2-preinit.sql @@ -0,0 +1,25 @@ +ALTER TABLE portal_user ADD COLUMN IF NOT EXISTS display_name VARCHAR(64); +ALTER TABLE portal_user ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(16); +ALTER TABLE portal_user ADD COLUMN IF NOT EXISTS role VARCHAR(32); +ALTER TABLE portal_user ADD COLUMN IF NOT EXISTS banned BOOLEAN; +ALTER TABLE portal_file ADD COLUMN IF NOT EXISTS is_recycle_root BOOLEAN; + +UPDATE portal_user +SET display_name = username +WHERE display_name IS NULL OR TRIM(display_name) = ''; + +UPDATE portal_user +SET preferred_language = 'zh-CN' +WHERE preferred_language IS NULL OR TRIM(preferred_language) = ''; + +UPDATE portal_user +SET role = 'USER' +WHERE role IS NULL OR TRIM(role) = ''; + +UPDATE portal_user +SET banned = FALSE +WHERE banned IS NULL; + +UPDATE portal_file +SET is_recycle_root = FALSE +WHERE is_recycle_root IS NULL; diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminAuditQueryServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminAuditQueryServiceTest.java new file mode 100644 index 0000000..35d9ac7 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/admin/AdminAuditQueryServiceTest.java @@ -0,0 +1,85 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.common.PageResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminAuditQueryServiceTest { + + @Mock + private AdminAuditLogRepository adminAuditLogRepository; + + private AdminAuditQueryService adminAuditQueryService; + + @BeforeEach + void setUp() { + adminAuditQueryService = new AdminAuditQueryService(adminAuditLogRepository); + } + + @Test + void shouldListAuditLogsWithPagination() { + AdminAuditLogEntity entity = new AdminAuditLogEntity(); + entity.setActorUserId(1L); + entity.setActorUsername("service-admin"); + entity.setActorAuthorities("ROLE_ADMIN"); + entity.setActionType("UPDATE_USER_ROLE"); + entity.setTargetType("USER"); + entity.setTargetId(2L); + entity.setSummary("Updated user role"); + entity.setDetailsJson("{\"role\":\"ADMIN\"}"); + when(adminAuditLogRepository.search(eq("service-admin"), eq("UPDATE_USER_ROLE"), eq("USER"), eq(2L), any())) + .thenReturn(new PageImpl<>(List.of(entity))); + + PageResponse response = adminAuditQueryService.listAuditLogs( + 0, + 10, + "service-admin", + "UPDATE_USER_ROLE", + "USER", + 2L + ); + + assertThat(response.total()).isEqualTo(1); + assertThat(response.items()).hasSize(1); + AdminAuditLogResponse item = response.items().get(0); + assertThat(item.actorUsername()).isEqualTo("service-admin"); + assertThat(item.actionType()).isEqualTo("UPDATE_USER_ROLE"); + assertThat(item.targetType()).isEqualTo("USER"); + assertThat(item.targetId()).isEqualTo(2L); + assertThat(item.summary()).isEqualTo("Updated user role"); + assertThat(item.detailsJson()).contains("\"role\":\"ADMIN\""); + verify(adminAuditLogRepository).search(eq("service-admin"), eq("UPDATE_USER_ROLE"), eq("USER"), eq(2L), any()); + } + + @Test + void shouldNormalizeNullFiltersToEmptyStrings() { + when(adminAuditLogRepository.search(eq(""), eq(""), eq(""), eq(null), any())) + .thenReturn(new PageImpl<>(List.of())); + + PageResponse response = adminAuditQueryService.listAuditLogs( + 0, + 10, + null, + null, + null, + null + ); + + assertThat(response.total()).isZero(); + assertThat(response.items()).isEmpty(); + verify(adminAuditLogRepository).search(eq(""), eq(""), eq(""), eq(null), any()); + } +} diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminAuditServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminAuditServiceTest.java new file mode 100644 index 0000000..ebea4a7 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/admin/AdminAuditServiceTest.java @@ -0,0 +1,96 @@ +package com.yoyuzh.admin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.auth.User; +import com.yoyuzh.auth.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminAuditServiceTest { + + @Mock + private AdminAuditLogRepository adminAuditLogRepository; + @Mock + private UserRepository userRepository; + + private AdminAuditService adminAuditService; + + @BeforeEach + void setUp() { + adminAuditService = new AdminAuditService(adminAuditLogRepository, userRepository, new ObjectMapper()); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void shouldRecordAuditLogWithAuthenticatedActorSnapshot() { + User adminUser = new User(); + adminUser.setId(99L); + adminUser.setUsername("service-admin"); + when(userRepository.findByUsername("service-admin")).thenReturn(Optional.of(adminUser)); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + "service-admin", + "N/A", + List.of(new SimpleGrantedAuthority("ROLE_ADMIN"), new SimpleGrantedAuthority("ROLE_MODERATOR")) + )); + + adminAuditService.record( + AdminAuditAction.UPDATE_USER_ROLE, + "USER", + 42L, + "Updated user role", + Map.of("role", "ADMIN") + ); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdminAuditLogEntity.class); + verify(adminAuditLogRepository).save(captor.capture()); + AdminAuditLogEntity entity = captor.getValue(); + assertThat(entity.getActorUserId()).isEqualTo(99L); + assertThat(entity.getActorUsername()).isEqualTo("service-admin"); + assertThat(entity.getActorAuthorities()).isEqualTo("ROLE_ADMIN,ROLE_MODERATOR"); + assertThat(entity.getActionType()).isEqualTo("UPDATE_USER_ROLE"); + assertThat(entity.getTargetType()).isEqualTo("USER"); + assertThat(entity.getTargetId()).isEqualTo(42L); + assertThat(entity.getSummary()).isEqualTo("Updated user role"); + assertThat(entity.getDetailsJson()).contains("\"role\":\"ADMIN\""); + } + + @Test + void shouldRecordAuditLogWithSystemActorWhenAuthenticationMissing() { + adminAuditService.record( + AdminAuditAction.DELETE_FILE, + "FILE", + 7L, + "Deleted file", + Map.of("filename", "report.pdf") + ); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdminAuditLogEntity.class); + verify(adminAuditLogRepository).save(captor.capture()); + AdminAuditLogEntity entity = captor.getValue(); + assertThat(entity.getActorUserId()).isNull(); + assertThat(entity.getActorUsername()).isEqualTo("system"); + assertThat(entity.getActorAuthorities()).isEmpty(); + assertThat(entity.getActionType()).isEqualTo("DELETE_FILE"); + } +} diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminConfigSnapshotServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminConfigSnapshotServiceTest.java new file mode 100644 index 0000000..4ff20d6 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/admin/AdminConfigSnapshotServiceTest.java @@ -0,0 +1,170 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.auth.RegistrationInviteService; +import com.yoyuzh.config.AppRedisProperties; +import com.yoyuzh.config.FileStorageProperties; +import com.yoyuzh.config.JwtProperties; +import com.yoyuzh.files.core.FileBlobRepository; +import com.yoyuzh.files.core.FileEntityRepository; +import com.yoyuzh.files.core.StoredFileRepository; +import com.yoyuzh.files.policy.StoragePolicy; +import com.yoyuzh.files.policy.StoragePolicyCapabilities; +import com.yoyuzh.files.policy.StoragePolicyCredentialMode; +import com.yoyuzh.files.policy.StoragePolicyService; +import com.yoyuzh.files.policy.StoragePolicyType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.env.MockEnvironment; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminConfigSnapshotServiceTest { + + @Mock + private RegistrationInviteService registrationInviteService; + @Mock + private AdminMetricsService adminMetricsService; + @Mock + private StoragePolicyService storagePolicyService; + @Mock + private StoredFileRepository storedFileRepository; + @Mock + private FileBlobRepository fileBlobRepository; + @Mock + private FileEntityRepository fileEntityRepository; + + private AppRedisProperties redisProperties; + private FileStorageProperties fileStorageProperties; + private JwtProperties jwtProperties; + private MockEnvironment environment; + private AdminConfigSnapshotService adminConfigSnapshotService; + + @BeforeEach + void setUp() { + redisProperties = new AppRedisProperties(); + fileStorageProperties = new FileStorageProperties(); + jwtProperties = new JwtProperties(); + environment = new MockEnvironment(); + adminConfigSnapshotService = new AdminConfigSnapshotService( + registrationInviteService, + adminMetricsService, + redisProperties, + fileStorageProperties, + jwtProperties, + environment, + storagePolicyService, + storedFileRepository, + fileBlobRepository, + fileEntityRepository + ); + } + + @Test + void shouldExposeAdminSettingsSnapshot() { + redisProperties.setEnabled(true); + redisProperties.setTtlBufferSeconds(120); + jwtProperties.setAccessExpirationSeconds(1800); + jwtProperties.setRefreshExpirationSeconds(604800); + fileStorageProperties.setProvider("s3"); + environment.setProperty("app.redis.broker.media-meta.fixed-delay-ms", "5000"); + environment.setProperty("app.redis.broker.media-meta.initial-delay-ms", "25000"); + when(registrationInviteService.getCurrentInviteCode()).thenReturn("INV-2026"); + when(adminMetricsService.getOfflineTransferStorageLimitBytes()).thenReturn(20L * 1024 * 1024 * 1024); + + AdminSettingsResponse response = adminConfigSnapshotService.getSettings(); + + assertThat(response.site().supported()).isFalse(); + assertThat(response.registration().inviteCodeRequired()).isTrue(); + assertThat(response.registration().currentInviteCode()).isEqualTo("INV-2026"); + assertThat(response.registration().managementRoles()).containsExactly("MODERATOR", "ADMIN"); + assertThat(response.registration().writeSupported()).isTrue(); + assertThat(response.userSession().accessExpirationSeconds()).isEqualTo(1800L); + assertThat(response.userSession().refreshExpirationSeconds()).isEqualTo(604800L); + assertThat(response.userSession().tokenBlacklistEnabled()).isTrue(); + assertThat(response.userSession().writeSupported()).isFalse(); + assertThat(response.transfer().offlineTransferStorageLimitBytes()).isGreaterThan(0L); + assertThat(response.transfer().writeSupported()).isTrue(); + assertThat(response.queue().backend()).isEqualTo("redis"); + assertThat(response.queue().writeSupported()).isFalse(); + assertThat(response.queue().mediaMetadataFixedDelayMs()).isEqualTo(5000L); + assertThat(response.queue().mediaMetadataInitialDelayMs()).isEqualTo(25000L); + assertThat(response.server().storageProvider()).isEqualTo("s3"); + assertThat(response.server().redisEnabled()).isTrue(); + assertThat(response.server().writeSupported()).isFalse(); + } + + @Test + void shouldExposeFilesystemOverviewFromDefaultPolicy() { + fileStorageProperties.setProvider("s3"); + fileStorageProperties.setMaxFileSize(500_000L); + redisProperties.setEnabled(true); + redisProperties.getCache().setFilesListTtlSeconds(45); + redisProperties.getCache().setDirectoryVersionTtlSeconds(3600); + + StoragePolicy policy = createStoragePolicy(7L, "Default S3 Storage"); + policy.setType(StoragePolicyType.S3_COMPATIBLE); + policy.setDefaultPolicy(true); + policy.setMaxSizeBytes(400_000L); + StoragePolicyCapabilities capabilities = new StoragePolicyCapabilities( + true, + true, + true, + true, + false, + true, + true, + false, + 300_000L + ); + when(storagePolicyService.ensureDefaultPolicy()).thenReturn(policy); + when(storagePolicyService.readCapabilities(policy)).thenReturn(capabilities); + when(storedFileRepository.count()).thenReturn(12L); + when(fileBlobRepository.count()).thenReturn(8L); + when(fileEntityRepository.count()).thenReturn(9L); + + AdminFilesystemResponse response = adminConfigSnapshotService.getFilesystem(); + + assertThat(response.overview().storageProvider()).isEqualTo("s3"); + assertThat(response.overview().totalFiles()).isEqualTo(12L); + assertThat(response.overview().totalBlobs()).isEqualTo(8L); + assertThat(response.overview().totalEntities()).isEqualTo(9L); + assertThat(response.defaultPolicy().id()).isEqualTo(7L); + assertThat(response.upload().proxyUpload()).isFalse(); + assertThat(response.upload().directSingleUpload()).isFalse(); + assertThat(response.upload().directMultipartUpload()).isTrue(); + assertThat(response.upload().effectiveMaxFileSizeBytes()).isEqualTo(300_000L); + assertThat(response.mediaProcessing().metadataExtractionEnabled()).isTrue(); + assertThat(response.mediaProcessing().nativeThumbnailSupport()).isFalse(); + assertThat(response.cache().backend()).isEqualTo("redis"); + assertThat(response.cache().filesListTtlSeconds()).isEqualTo(45L); + assertThat(response.cache().directoryVersionTtlSeconds()).isEqualTo(3600L); + assertThat(response.webdav().enabled()).isFalse(); + } + + private StoragePolicy createStoragePolicy(Long id, String name) { + StoragePolicy policy = new StoragePolicy(); + policy.setId(id); + policy.setName(name); + policy.setType(StoragePolicyType.S3_COMPATIBLE); + policy.setBucketName("bucket"); + policy.setEndpoint("https://s3.example.com"); + policy.setRegion("auto"); + policy.setPrivateBucket(true); + policy.setPrefix("files/"); + policy.setCredentialMode(StoragePolicyCredentialMode.STATIC); + policy.setMaxSizeBytes(10_240L); + policy.setCapabilitiesJson("{}"); + policy.setEnabled(true); + policy.setDefaultPolicy(false); + policy.setCreatedAt(LocalDateTime.now()); + policy.setUpdatedAt(LocalDateTime.now()); + return policy; + } +} diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java index 4c3897c..365b02b 100644 --- a/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java +++ b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java @@ -3,6 +3,7 @@ package com.yoyuzh.admin; import com.yoyuzh.PortalBackendApplication; import com.yoyuzh.admin.AdminMetricsStateRepository; import com.yoyuzh.auth.RefreshTokenRepository; +import com.yoyuzh.auth.RegistrationInviteStateRepository; import com.yoyuzh.auth.User; import com.yoyuzh.auth.UserRepository; import com.yoyuzh.files.core.FileBlob; @@ -58,7 +59,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. "spring.datasource.password=", "spring.jpa.hibernate.ddl-auto=create-drop", "app.jwt.secret=0123456789abcdef0123456789abcdef", - "app.admin.usernames=admin,alice", "app.storage.root-dir=./target/test-storage-admin" } ) @@ -92,6 +92,10 @@ class AdminControllerIntegrationTest { @Autowired private AdminMetricsStateRepository adminMetricsStateRepository; @Autowired + private RegistrationInviteStateRepository registrationInviteStateRepository; + @Autowired + private AdminAuditLogRepository adminAuditLogRepository; + @Autowired private AdminMetricsService adminMetricsService; @Autowired private StoragePolicyRepository storagePolicyRepository; @@ -113,6 +117,8 @@ class AdminControllerIntegrationTest { fileBlobRepository.deleteAll(); userRepository.deleteAll(); adminMetricsStateRepository.deleteAll(); + registrationInviteStateRepository.deleteAll(); + adminAuditLogRepository.deleteAll(); Long defaultPolicyId = storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc() .map(StoragePolicy::getId) @@ -222,8 +228,8 @@ class AdminControllerIntegrationTest { } @Test - @WithMockUser(username = "admin") - void shouldAllowConfiguredAdminToListUsersAndSummary() throws Exception { + @WithMockUser(username = "service-admin", roles = "ADMIN") + void shouldAllowAdminRoleToListUsersAndSummary() throws Exception { int currentHour = LocalTime.now().getHour(); LocalDate today = LocalDate.now(); adminMetricsService.recordUserOnline(portalUser.getId(), portalUser.getUsername()); @@ -263,7 +269,7 @@ class AdminControllerIntegrationTest { } @Test - @WithMockUser(username = "admin") + @WithMockUser(username = "service-admin", roles = "ADMIN") void shouldSupportUserSearchPasswordAndStatusManagement() throws Exception { mockMvc.perform(get("/api/admin/users?page=0&size=10&query=ali")) .andExpect(status().isOk()) @@ -332,7 +338,136 @@ class AdminControllerIntegrationTest { } @Test - @WithMockUser(username = "admin") + @WithMockUser(username = "service-admin", roles = "ADMIN") + void shouldRejectInvalidOfflineTransferStorageLimitValues() throws Exception { + mockMvc.perform(patch("/api/admin/settings/offline-transfer-storage-limit") + .contentType("application/json") + .content(""" + {"offlineTransferStorageLimitBytes":0} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1000)); + + mockMvc.perform(patch("/api/admin/settings/offline-transfer-storage-limit") + .contentType("application/json") + .content(""" + {"offlineTransferStorageLimitBytes":-1} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1000)); + + mockMvc.perform(patch("/api/admin/settings/offline-transfer-storage-limit") + .contentType("application/json") + .content(""" + {} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1000)); + } + + @Test + @WithMockUser(username = "service-admin", roles = "ADMIN") + void shouldExposeSettingsAndFilesystemOverview() throws Exception { + mockMvc.perform(get("/api/admin/settings")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.site.supported").value(false)) + .andExpect(jsonPath("$.data.registration.inviteCodeRequired").value(true)) + .andExpect(jsonPath("$.data.registration.currentInviteCode").isNotEmpty()) + .andExpect(jsonPath("$.data.registration.managementRoles[0]").value("MODERATOR")) + .andExpect(jsonPath("$.data.registration.managementRoles[1]").value("ADMIN")) + .andExpect(jsonPath("$.data.registration.writeSupported").value(true)) + .andExpect(jsonPath("$.data.userSession.accessExpirationSeconds").value(900)) + .andExpect(jsonPath("$.data.userSession.refreshExpirationSeconds").value(1209600)) + .andExpect(jsonPath("$.data.userSession.writeSupported").value(false)) + .andExpect(jsonPath("$.data.transfer.offlineTransferStorageLimitBytes").isNumber()) + .andExpect(jsonPath("$.data.transfer.writeSupported").value(true)) + .andExpect(jsonPath("$.data.queue.backend").value("in-memory")) + .andExpect(jsonPath("$.data.queue.writeSupported").value(false)) + .andExpect(jsonPath("$.data.server.storageProvider").value("local")) + .andExpect(jsonPath("$.data.server.redisEnabled").value(false)) + .andExpect(jsonPath("$.data.server.writeSupported").value(false)); + + mockMvc.perform(get("/api/admin/filesystem")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.overview.storageProvider").value("local")) + .andExpect(jsonPath("$.data.overview.totalFiles").value(2)) + .andExpect(jsonPath("$.data.overview.totalBlobs").value(2)) + .andExpect(jsonPath("$.data.overview.totalEntities").value(2)) + .andExpect(jsonPath("$.data.defaultPolicy.type").value("LOCAL")) + .andExpect(jsonPath("$.data.upload.proxyUpload").value(true)) + .andExpect(jsonPath("$.data.upload.directSingleUpload").value(false)) + .andExpect(jsonPath("$.data.upload.directMultipartUpload").value(false)) + .andExpect(jsonPath("$.data.mediaProcessing.metadataExtractionEnabled").value(true)) + .andExpect(jsonPath("$.data.cache.backend").value("disabled")) + .andExpect(jsonPath("$.data.webdav.enabled").value(false)); + } + + @Test + @WithMockUser(username = "service-admin", roles = "ADMIN") + void shouldAllowConfiguredAdminToUpdateAndRotateInviteCode() throws Exception { + mockMvc.perform(get("/api/admin/settings")) + .andExpect(status().isOk()); + String originalInviteCode = currentInviteCode(); + + mockMvc.perform(patch("/api/admin/settings/registration/invite-code") + .contentType("application/json") + .content(""" + { + "inviteCode": "INV-NEXT-2026" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentInviteCode").value("INV-NEXT-2026")); + + assertThat(currentInviteCode()).isEqualTo("INV-NEXT-2026"); + + mockMvc.perform(post("/api/admin/settings/registration/invite-code/rotate")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentInviteCode").isNotEmpty()) + .andExpect(jsonPath("$.data.currentInviteCode").value(org.hamcrest.Matchers.not("INV-NEXT-2026"))); + + assertThat(currentInviteCode()) + .isNotBlank() + .isNotEqualTo(originalInviteCode) + .isNotEqualTo("INV-NEXT-2026"); + } + + @Test + @WithMockUser(username = "service-admin", roles = "ADMIN") + void shouldRejectInvalidInviteCodeUpdatesAndKeepCurrentCode() throws Exception { + mockMvc.perform(get("/api/admin/settings")) + .andExpect(status().isOk()); + String originalInviteCode = currentInviteCode(); + + mockMvc.perform(patch("/api/admin/settings/registration/invite-code") + .contentType("application/json") + .content(""" + { + "inviteCode": " " + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").isNotEmpty()); + + mockMvc.perform(patch("/api/admin/settings/registration/invite-code") + .contentType("application/json") + .content(""" + { + "inviteCode": "%s" + } + """.formatted("A".repeat(65)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").isNotEmpty()); + + assertThat(currentInviteCode()).isEqualTo(originalInviteCode); + } + + @Test + @WithMockUser(username = "service-admin", roles = "ADMIN") void shouldInvalidateOldPasswordAfterAdminPasswordUpdate() throws Exception { mockMvc.perform(put("/api/admin/users/{userId}/password", portalUser.getId()) .contentType("application/json") @@ -368,12 +503,12 @@ class AdminControllerIntegrationTest { @Test void shouldExposeTrafficAndTransferMetricsInSummary() throws Exception { mockMvc.perform(get("/api/files/download/{fileId}/url", storedFile.getId()) - .with(user("alice"))) + .with(user("alice").roles("ADMIN"))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.url").value("/api/files/download/" + storedFile.getId())); mockMvc.perform(post("/api/transfer/sessions") - .with(user("alice")) + .with(user("alice").roles("ADMIN")) .contentType("application/json") .content(""" { @@ -386,7 +521,7 @@ class AdminControllerIntegrationTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.mode").value("OFFLINE")); - mockMvc.perform(get("/api/admin/summary").with(user("admin"))) + mockMvc.perform(get("/api/admin/summary").with(user("service-admin").roles("ADMIN"))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.downloadTrafficBytes").value(1024L)) .andExpect(jsonPath("$.data.transferUsageBytes").value(13L)) @@ -394,8 +529,8 @@ class AdminControllerIntegrationTest { } @Test - @WithMockUser(username = "admin") - void shouldAllowConfiguredAdminToListAndDeleteFiles() throws Exception { + @WithMockUser(username = "service-admin", roles = "ADMIN") + void shouldAllowAdminRoleToListAndDeleteFiles() throws Exception { mockMvc.perform(get("/api/admin/files?page=0&size=10")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.items[0].filename").value("report.pdf")) @@ -412,8 +547,8 @@ class AdminControllerIntegrationTest { } @Test - @WithMockUser(username = "admin") - void shouldAllowConfiguredAdminToListFileBlobsWithRiskSignals() throws Exception { + @WithMockUser(username = "service-admin", roles = "ADMIN") + void shouldAllowAdminRoleToListFileBlobsWithRiskSignals() throws Exception { Long defaultPolicyId = storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc() .map(StoragePolicy::getId) .orElse(null); @@ -443,8 +578,8 @@ class AdminControllerIntegrationTest { } @Test - @WithMockUser(username = "admin") - void shouldAllowConfiguredAdminToListAndDeleteShares() throws Exception { + @WithMockUser(username = "service-admin", roles = "ADMIN") + void shouldAllowAdminRoleToListAndDeleteShares() throws Exception { FileShareLink share = new FileShareLink(); share.setOwner(secondaryUser); share.setFile(secondaryFile); @@ -481,8 +616,8 @@ class AdminControllerIntegrationTest { } @Test - @WithMockUser(username = "admin") - void shouldAllowConfiguredAdminToListAndInspectTasks() throws Exception { + @WithMockUser(username = "service-admin", roles = "ADMIN") + void shouldAllowAdminRoleToListAndInspectTasks() throws Exception { BackgroundTask task = new BackgroundTask(); task.setType(BackgroundTaskType.MEDIA_META); task.setStatus(BackgroundTaskStatus.RUNNING); @@ -527,8 +662,66 @@ class AdminControllerIntegrationTest { } @Test - @WithMockUser(username = "admin") - void shouldAllowConfiguredAdminToListStoragePolicies() throws Exception { + @WithMockUser(username = "service-admin", roles = "ADMIN") + void shouldExposeExpiredAndMissingLeaseStatesForAdminTasks() throws Exception { + BackgroundTask expiredTask = new BackgroundTask(); + expiredTask.setType(BackgroundTaskType.EXTRACT); + expiredTask.setStatus(BackgroundTaskStatus.RUNNING); + expiredTask.setUserId(portalUser.getId()); + expiredTask.setPublicStateJson(""" + {"workerOwner":"extract-worker-1"} + """); + expiredTask.setPrivateStateJson("{}"); + expiredTask.setCorrelationId("task-expired-1"); + expiredTask.setAttemptCount(1); + expiredTask.setMaxAttempts(3); + expiredTask.setLeaseOwner("worker-expired"); + expiredTask.setLeaseExpiresAt(LocalDateTime.now().minusMinutes(2)); + expiredTask.setHeartbeatAt(LocalDateTime.now().minusMinutes(3)); + expiredTask.setCreatedAt(LocalDateTime.now().minusMinutes(5)); + expiredTask.setUpdatedAt(LocalDateTime.now().minusMinutes(2)); + expiredTask = backgroundTaskRepository.save(expiredTask); + + BackgroundTask noneTask = new BackgroundTask(); + noneTask.setType(BackgroundTaskType.ARCHIVE); + noneTask.setStatus(BackgroundTaskStatus.QUEUED); + noneTask.setUserId(secondaryUser.getId()); + noneTask.setPublicStateJson("{}"); + noneTask.setPrivateStateJson("{}"); + noneTask.setCorrelationId("task-none-1"); + noneTask.setAttemptCount(0); + noneTask.setMaxAttempts(4); + noneTask.setCreatedAt(LocalDateTime.now().minusMinutes(4)); + noneTask.setUpdatedAt(LocalDateTime.now().minusMinutes(4)); + noneTask = backgroundTaskRepository.save(noneTask); + + mockMvc.perform(get("/api/admin/tasks?page=0&size=10&leaseState=EXPIRED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.items[0].id").value(expiredTask.getId())) + .andExpect(jsonPath("$.data.items[0].leaseState").value("EXPIRED")) + .andExpect(jsonPath("$.data.items[0].workerOwner").value("extract-worker-1")); + + mockMvc.perform(get("/api/admin/tasks?page=0&size=10&leaseState=NONE")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.items[0].id").value(noneTask.getId())) + .andExpect(jsonPath("$.data.items[0].leaseState").value("NONE")); + + mockMvc.perform(get("/api/admin/tasks/{taskId}", expiredTask.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(expiredTask.getId())) + .andExpect(jsonPath("$.data.leaseState").value("EXPIRED")); + + mockMvc.perform(get("/api/admin/tasks/{taskId}", noneTask.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(noneTask.getId())) + .andExpect(jsonPath("$.data.leaseState").value("NONE")); + } + + @Test + @WithMockUser(username = "service-admin", roles = "ADMIN") + void shouldAllowAdminRoleToListStoragePolicies() throws Exception { mockMvc.perform(get("/api/admin/storage-policies")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) @@ -545,8 +738,8 @@ class AdminControllerIntegrationTest { } @Test - @WithMockUser(username = "admin") - void shouldAllowConfiguredAdminToCreateUpdateAndDisableNonDefaultStoragePolicy() throws Exception { + @WithMockUser(username = "service-admin", roles = "ADMIN") + void shouldAllowAdminRoleToCreateUpdateAndDisableNonDefaultStoragePolicy() throws Exception { mockMvc.perform(post("/api/admin/storage-policies") .contentType("application/json") .content(""" @@ -632,7 +825,7 @@ class AdminControllerIntegrationTest { } @Test - @WithMockUser(username = "admin") + @WithMockUser(username = "service-admin", roles = "ADMIN") void shouldRejectDisablingDefaultStoragePolicy() throws Exception { StoragePolicy defaultPolicy = storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc().orElseThrow(); @@ -673,7 +866,7 @@ class AdminControllerIntegrationTest { targetPolicy = storagePolicyRepository.save(targetPolicy); mockMvc.perform(post("/api/admin/storage-policies/migrations") - .with(user("alice")) + .with(user("alice").roles("ADMIN")) .contentType("application/json") .content(""" { @@ -692,7 +885,7 @@ class AdminControllerIntegrationTest { } @Test - @WithMockUser(username = "portal-user") + @WithMockUser(username = "portal-user", roles = "USER") void shouldRejectNonAdminUser() throws Exception { mockMvc.perform(get("/api/admin/users?page=0&size=10")) .andExpect(status().isForbidden()) @@ -702,4 +895,41 @@ class AdminControllerIntegrationTest { .andExpect(status().isForbidden()) .andExpect(jsonPath("$.msg").value("没有权限访问该资源")); } + + @Test + @WithMockUser(username = "ops-user", roles = "MODERATOR") + void shouldAllowModeratorRoleToAccessAdminEndpoints() throws Exception { + mockMvc.perform(get("/api/admin/users?page=0&size=10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + @WithMockUser(username = "service-admin", roles = "ADMIN") + void shouldExposeAdminAuditLogsForGovernanceWrites() throws Exception { + mockMvc.perform(patch("/api/admin/users/{userId}/role", portalUser.getId()) + .contentType("application/json") + .content(""" + {"role":"ADMIN"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.role").value("ADMIN")); + + mockMvc.perform(get("/api/admin/audits?page=0&size=10&actionType=UPDATE_USER_ROLE&targetType=USER") + .with(user("service-admin").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.items[0].actorUsername").value("service-admin")) + .andExpect(jsonPath("$.data.items[0].actionType").value("UPDATE_USER_ROLE")) + .andExpect(jsonPath("$.data.items[0].targetType").value("USER")) + .andExpect(jsonPath("$.data.items[0].targetId").value(portalUser.getId())) + .andExpect(jsonPath("$.data.items[0].summary").value("Updated user role")); + } + + private String currentInviteCode() { + return registrationInviteStateRepository.findById(1L) + .orElseThrow() + .getInviteCode(); + } } diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminInspectionQueryServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminInspectionQueryServiceTest.java new file mode 100644 index 0000000..63646c7 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/admin/AdminInspectionQueryServiceTest.java @@ -0,0 +1,148 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.auth.RegistrationInviteService; +import com.yoyuzh.auth.User; +import com.yoyuzh.auth.UserRepository; +import com.yoyuzh.auth.UserRole; +import com.yoyuzh.common.PageResponse; +import com.yoyuzh.files.core.FileBlobRepository; +import com.yoyuzh.files.core.FileEntityRepository; +import com.yoyuzh.files.core.StoredFile; +import com.yoyuzh.files.core.StoredFileEntityRepository; +import com.yoyuzh.files.core.StoredFileRepository; +import com.yoyuzh.files.share.FileShareLinkRepository; +import com.yoyuzh.transfer.OfflineTransferSessionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminInspectionQueryServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private StoredFileRepository storedFileRepository; + @Mock + private FileBlobRepository fileBlobRepository; + @Mock + private RegistrationInviteService registrationInviteService; + @Mock + private OfflineTransferSessionRepository offlineTransferSessionRepository; + @Mock + private AdminMetricsService adminMetricsService; + @Mock + private FileEntityRepository fileEntityRepository; + @Mock + private StoredFileEntityRepository storedFileEntityRepository; + @Mock + private FileShareLinkRepository fileShareLinkRepository; + + private AdminInspectionQueryService adminInspectionQueryService; + + @BeforeEach + void setUp() { + adminInspectionQueryService = new AdminInspectionQueryService( + userRepository, + storedFileRepository, + fileBlobRepository, + registrationInviteService, + offlineTransferSessionRepository, + adminMetricsService, + fileEntityRepository, + storedFileEntityRepository, + fileShareLinkRepository + ); + } + + @Test + void shouldReturnSummaryWithCountsAndInviteCode() { + when(userRepository.count()).thenReturn(5L); + when(storedFileRepository.count()).thenReturn(42L); + when(fileBlobRepository.sumAllBlobSize()).thenReturn(8192L); + when(adminMetricsService.getSnapshot()).thenReturn(new AdminMetricsSnapshot( + 0L, + 0L, + 0L, + 20L * 1024 * 1024 * 1024, + List.of( + new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate().minusDays(1), "yesterday", 1L, List.of("alice")), + new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate(), "today", 2L, List.of("alice", "bob")) + ), + List.of( + new AdminRequestTimelinePoint(0, "00:00", 0L), + new AdminRequestTimelinePoint(1, "01:00", 3L) + ) + )); + when(offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(any())).thenReturn(0L); + when(registrationInviteService.getCurrentInviteCode()).thenReturn("INV-001"); + + AdminSummaryResponse summary = adminInspectionQueryService.getSummary(); + + assertThat(summary.totalUsers()).isEqualTo(5L); + assertThat(summary.totalFiles()).isEqualTo(42L); + assertThat(summary.totalStorageBytes()).isEqualTo(8192L); + assertThat(summary.downloadTrafficBytes()).isZero(); + assertThat(summary.requestCount()).isZero(); + assertThat(summary.transferUsageBytes()).isZero(); + assertThat(summary.offlineTransferStorageBytes()).isZero(); + assertThat(summary.offlineTransferStorageLimitBytes()).isGreaterThan(0L); + assertThat(summary.dailyActiveUsers()).containsExactly( + new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate().minusDays(1), "yesterday", 1L, List.of("alice")), + new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate(), "today", 2L, List.of("alice", "bob")) + ); + assertThat(summary.requestTimeline()).containsExactly( + new AdminRequestTimelinePoint(0, "00:00", 0L), + new AdminRequestTimelinePoint(1, "01:00", 3L) + ); + assertThat(summary.inviteCode()).isEqualTo("INV-001"); + } + + @Test + void shouldListFilesWithPagination() { + User owner = createUser(1L, "alice", "alice@example.com"); + StoredFile file = createFile(10L, owner, "/docs", "report.pdf"); + when(storedFileRepository.searchAdminFiles(anyString(), anyString(), any())) + .thenReturn(new PageImpl<>(List.of(file))); + + PageResponse response = adminInspectionQueryService.listFiles(0, 10, "report", "alice"); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).filename()).isEqualTo("report.pdf"); + assertThat(response.items().get(0).ownerUsername()).isEqualTo("alice"); + } + + private User createUser(Long id, String username, String email) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setEmail(email); + user.setPasswordHash("hashed"); + user.setRole(UserRole.USER); + user.setCreatedAt(LocalDateTime.now()); + return user; + } + + private StoredFile createFile(Long id, User owner, String path, String filename) { + StoredFile file = new StoredFile(); + file.setId(id); + file.setUser(owner); + file.setPath(path); + file.setFilename(filename); + file.setSize(1024L); + file.setDirectory(false); + file.setCreatedAt(LocalDateTime.now()); + return file; + } +} diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminMutableSettingsServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminMutableSettingsServiceTest.java new file mode 100644 index 0000000..00827cc --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/admin/AdminMutableSettingsServiceTest.java @@ -0,0 +1,65 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.auth.RegistrationInviteService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminMutableSettingsServiceTest { + + @Mock + private RegistrationInviteService registrationInviteService; + @Mock + private AdminMetricsService adminMetricsService; + @Mock + private AdminAuditService adminAuditService; + + private AdminMutableSettingsService adminMutableSettingsService; + + @BeforeEach + void setUp() { + adminMutableSettingsService = new AdminMutableSettingsService( + registrationInviteService, + adminMetricsService, + adminAuditService + ); + } + + @Test + void shouldUpdateCurrentInviteCodeForAdminSettings() { + when(registrationInviteService.updateCurrentInviteCode("INV-NEXT-2026")).thenReturn("INV-NEXT-2026"); + + AdminRegistrationInviteCodeResponse response = adminMutableSettingsService.updateRegistrationInviteCode(" INV-NEXT-2026 "); + + assertThat(response.currentInviteCode()).isEqualTo("INV-NEXT-2026"); + verify(registrationInviteService).updateCurrentInviteCode("INV-NEXT-2026"); + } + + @Test + void shouldRotateCurrentInviteCodeForAdminSettings() { + when(registrationInviteService.rotateCurrentInviteCode()).thenReturn("INV-ROTATED-2026"); + + AdminRegistrationInviteCodeResponse response = adminMutableSettingsService.rotateRegistrationInviteCode(); + + assertThat(response.currentInviteCode()).isEqualTo("INV-ROTATED-2026"); + verify(registrationInviteService).rotateCurrentInviteCode(); + } + + @Test + void shouldUpdateOfflineTransferStorageLimit() { + AdminOfflineTransferStorageLimitResponse expected = new AdminOfflineTransferStorageLimitResponse(1024L); + when(adminMetricsService.updateOfflineTransferStorageLimit(1024L)).thenReturn(expected); + + AdminOfflineTransferStorageLimitResponse response = adminMutableSettingsService.updateOfflineTransferStorageLimit(1024L); + + assertThat(response).isSameAs(expected); + verify(adminMetricsService).updateOfflineTransferStorageLimit(1024L); + } +} diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminResourceGovernanceServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminResourceGovernanceServiceTest.java new file mode 100644 index 0000000..b18b64b --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/admin/AdminResourceGovernanceServiceTest.java @@ -0,0 +1,111 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.auth.User; +import com.yoyuzh.auth.UserRepository; +import com.yoyuzh.auth.UserRole; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.files.core.FileService; +import com.yoyuzh.files.core.StoredFile; +import com.yoyuzh.files.core.StoredFileRepository; +import com.yoyuzh.files.share.FileShareLink; +import com.yoyuzh.files.share.FileShareLinkRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminResourceGovernanceServiceTest { + + @Mock + private StoredFileRepository storedFileRepository; + @Mock + private FileService fileService; + @Mock + private FileShareLinkRepository fileShareLinkRepository; + @Mock + private AdminAuditService adminAuditService; + + private AdminResourceGovernanceService adminResourceGovernanceService; + + @BeforeEach + void setUp() { + adminResourceGovernanceService = new AdminResourceGovernanceService( + storedFileRepository, + fileService, + fileShareLinkRepository, + adminAuditService + ); + } + + @Test + void shouldDeleteShare() { + FileShareLink shareLink = new FileShareLink(); + shareLink.setId(5L); + when(fileShareLinkRepository.findById(5L)).thenReturn(Optional.of(shareLink)); + + adminResourceGovernanceService.deleteShare(5L); + + verify(fileShareLinkRepository).delete(shareLink); + } + + @Test + void shouldThrowWhenDeletingNonExistentShare() { + when(fileShareLinkRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminResourceGovernanceService.deleteShare(99L)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("share not found"); + } + + @Test + void shouldDeleteFileByDelegatingToFileService() { + User owner = createUser(1L, "alice", "alice@example.com"); + StoredFile file = createFile(10L, owner, "/docs", "report.pdf"); + when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file)); + + adminResourceGovernanceService.deleteFile(10L); + + verify(fileService).delete(owner, 10L); + } + + @Test + void shouldThrowWhenDeletingNonExistentFile() { + when(storedFileRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminResourceGovernanceService.deleteFile(99L)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("file not found"); + } + + private User createUser(Long id, String username, String email) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setEmail(email); + user.setPasswordHash("hashed"); + user.setRole(UserRole.USER); + user.setCreatedAt(LocalDateTime.now()); + return user; + } + + private StoredFile createFile(Long id, User owner, String path, String filename) { + StoredFile file = new StoredFile(); + file.setId(id); + file.setUser(owner); + file.setPath(path); + file.setFilename(filename); + file.setSize(1024L); + file.setDirectory(false); + file.setCreatedAt(LocalDateTime.now()); + return file; + } +} diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java deleted file mode 100644 index 75ee5ac..0000000 --- a/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java +++ /dev/null @@ -1,488 +0,0 @@ -package com.yoyuzh.admin; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.yoyuzh.auth.AuthTokenInvalidationService; -import com.yoyuzh.auth.PasswordPolicy; -import com.yoyuzh.auth.RegistrationInviteService; -import com.yoyuzh.auth.RefreshTokenService; -import com.yoyuzh.auth.User; -import com.yoyuzh.auth.UserRepository; -import com.yoyuzh.auth.UserRole; -import com.yoyuzh.common.BusinessException; -import com.yoyuzh.common.PageResponse; -import com.yoyuzh.files.core.FileBlobRepository; -import com.yoyuzh.files.core.FileEntityRepository; -import com.yoyuzh.files.core.FileService; -import com.yoyuzh.files.core.StoredFile; -import com.yoyuzh.files.core.StoredFileEntityRepository; -import com.yoyuzh.files.core.StoredFileRepository; -import com.yoyuzh.files.policy.StoragePolicy; -import com.yoyuzh.files.policy.StoragePolicyCapabilities; -import com.yoyuzh.files.policy.StoragePolicyCredentialMode; -import com.yoyuzh.files.policy.StoragePolicyRepository; -import com.yoyuzh.files.policy.StoragePolicyService; -import com.yoyuzh.files.policy.StoragePolicyType; -import com.yoyuzh.files.share.FileShareLinkRepository; -import com.yoyuzh.files.tasks.BackgroundTaskRepository; -import com.yoyuzh.files.tasks.BackgroundTask; -import com.yoyuzh.files.tasks.BackgroundTaskService; -import com.yoyuzh.files.tasks.BackgroundTaskStatus; -import com.yoyuzh.files.tasks.BackgroundTaskType; -import com.yoyuzh.transfer.OfflineTransferSessionRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; -import org.springframework.security.crypto.password.PasswordEncoder; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class AdminServiceTest { - - @Mock - private UserRepository userRepository; - @Mock - private StoredFileRepository storedFileRepository; - @Mock - private FileBlobRepository fileBlobRepository; - @Mock - private FileService fileService; - @Mock - private PasswordEncoder passwordEncoder; - @Mock - private RefreshTokenService refreshTokenService; - @Mock - private AuthTokenInvalidationService authTokenInvalidationService; - @Mock - private RegistrationInviteService registrationInviteService; - @Mock - private OfflineTransferSessionRepository offlineTransferSessionRepository; - @Mock - private AdminMetricsService adminMetricsService; - @Mock - private StoragePolicyRepository storagePolicyRepository; - @Mock - private StoragePolicyService storagePolicyService; - @Mock - private FileEntityRepository fileEntityRepository; - @Mock - private StoredFileEntityRepository storedFileEntityRepository; - @Mock - private BackgroundTaskRepository backgroundTaskRepository; - @Mock - private BackgroundTaskService backgroundTaskService; - @Mock - private FileShareLinkRepository fileShareLinkRepository; - - private AdminService adminService; - - @BeforeEach - void setUp() { - adminService = new AdminService( - userRepository, storedFileRepository, fileBlobRepository, fileService, - passwordEncoder, refreshTokenService, authTokenInvalidationService, registrationInviteService, - offlineTransferSessionRepository, adminMetricsService, - storagePolicyRepository, storagePolicyService, - fileEntityRepository, storedFileEntityRepository, backgroundTaskRepository, - backgroundTaskService, fileShareLinkRepository, new ObjectMapper()); - } - - // --- getSummary --- - - @Test - void shouldReturnSummaryWithCountsAndInviteCode() { - when(userRepository.count()).thenReturn(5L); - when(storedFileRepository.count()).thenReturn(42L); - when(fileBlobRepository.sumAllBlobSize()).thenReturn(8192L); - when(adminMetricsService.getSnapshot()).thenReturn(new AdminMetricsSnapshot( - 0L, - 0L, - 0L, - 20L * 1024 * 1024 * 1024, - List.of( - new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate().minusDays(1), "昨天", 1L, List.of("alice")), - new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate(), "今天", 2L, List.of("alice", "bob")) - ), - List.of( - new AdminRequestTimelinePoint(0, "00:00", 0L), - new AdminRequestTimelinePoint(1, "01:00", 3L) - ) - )); - when(offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(any())).thenReturn(0L); - when(registrationInviteService.getCurrentInviteCode()).thenReturn("INV-001"); - - AdminSummaryResponse summary = adminService.getSummary(); - - assertThat(summary.totalUsers()).isEqualTo(5L); - assertThat(summary.totalFiles()).isEqualTo(42L); - assertThat(summary.totalStorageBytes()).isEqualTo(8192L); - assertThat(summary.downloadTrafficBytes()).isZero(); - assertThat(summary.requestCount()).isZero(); - assertThat(summary.transferUsageBytes()).isZero(); - assertThat(summary.offlineTransferStorageBytes()).isZero(); - assertThat(summary.offlineTransferStorageLimitBytes()).isGreaterThan(0L); - assertThat(summary.dailyActiveUsers()).containsExactly( - new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate().minusDays(1), "昨天", 1L, List.of("alice")), - new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate(), "今天", 2L, List.of("alice", "bob")) - ); - assertThat(summary.requestTimeline()).containsExactly( - new AdminRequestTimelinePoint(0, "00:00", 0L), - new AdminRequestTimelinePoint(1, "01:00", 3L) - ); - assertThat(summary.inviteCode()).isEqualTo("INV-001"); - } - - // --- listUsers --- - - @Test - void shouldListUsersWithPagination() { - User user = createUser(1L, "alice", "alice@example.com"); - when(userRepository.searchByUsernameOrEmail(anyString(), any())) - .thenReturn(new PageImpl<>(List.of(user))); - when(storedFileRepository.sumFileSizeByUserId(1L)).thenReturn(2048L); - - PageResponse response = adminService.listUsers(0, 10, "alice"); - - assertThat(response.items()).hasSize(1); - assertThat(response.items().get(0).username()).isEqualTo("alice"); - assertThat(response.items().get(0).usedStorageBytes()).isEqualTo(2048L); - } - - @Test - void shouldNormalizeNullQueryToEmptyStringWhenListingUsers() { - when(userRepository.searchByUsernameOrEmail(anyString(), any())) - .thenReturn(new PageImpl<>(List.of())); - - adminService.listUsers(0, 10, null); - - verify(userRepository).searchByUsernameOrEmail(eq(""), any()); - } - - // --- listFiles --- - - @Test - void shouldListFilesWithPagination() { - User owner = createUser(1L, "alice", "alice@example.com"); - StoredFile file = createFile(10L, owner, "/docs", "report.pdf"); - when(storedFileRepository.searchAdminFiles(anyString(), anyString(), any())) - .thenReturn(new PageImpl<>(List.of(file))); - - PageResponse response = adminService.listFiles(0, 10, "report", "alice"); - - assertThat(response.items()).hasSize(1); - assertThat(response.items().get(0).filename()).isEqualTo("report.pdf"); - assertThat(response.items().get(0).ownerUsername()).isEqualTo("alice"); - } - - @Test - void shouldCreateStoragePolicy() { - when(storagePolicyService.writeCapabilities(any(StoragePolicyCapabilities.class))).thenReturn("{\"maxObjectSize\":20480}"); - when(storagePolicyRepository.save(any(StoragePolicy.class))).thenAnswer(invocation -> { - StoragePolicy policy = invocation.getArgument(0); - policy.setId(9L); - return policy; - }); - when(storagePolicyService.readCapabilities(any(StoragePolicy.class))).thenReturn(defaultCapabilities(20_480L)); - - AdminStoragePolicyResponse response = adminService.createStoragePolicy(new AdminStoragePolicyUpsertRequest( - " Archive Bucket ", - StoragePolicyType.S3_COMPATIBLE, - "archive-bucket", - "https://s3.example.com", - "auto", - true, - "archive/", - StoragePolicyCredentialMode.STATIC, - 20_480L, - defaultCapabilities(20_480L), - true - )); - - assertThat(response.name()).isEqualTo("Archive Bucket"); - assertThat(response.type()).isEqualTo(StoragePolicyType.S3_COMPATIBLE); - assertThat(response.bucketName()).isEqualTo("archive-bucket"); - assertThat(response.endpoint()).isEqualTo("https://s3.example.com"); - assertThat(response.region()).isEqualTo("auto"); - assertThat(response.privateBucket()).isTrue(); - assertThat(response.prefix()).isEqualTo("archive/"); - assertThat(response.credentialMode()).isEqualTo(StoragePolicyCredentialMode.STATIC); - assertThat(response.maxSizeBytes()).isEqualTo(20_480L); - assertThat(response.enabled()).isTrue(); - assertThat(response.defaultPolicy()).isFalse(); - } - - @Test - void shouldUpdateStoragePolicyFieldsWithoutChangingDefaultFlag() { - StoragePolicy existingPolicy = createStoragePolicy(7L, "Archive Bucket"); - existingPolicy.setDefaultPolicy(false); - when(storagePolicyService.writeCapabilities(any(StoragePolicyCapabilities.class))).thenReturn("{\"maxObjectSize\":40960}"); - when(storagePolicyRepository.findById(7L)).thenReturn(Optional.of(existingPolicy)); - when(storagePolicyRepository.save(existingPolicy)).thenReturn(existingPolicy); - when(storagePolicyService.readCapabilities(existingPolicy)).thenReturn(defaultCapabilities(40_960L)); - - AdminStoragePolicyResponse response = adminService.updateStoragePolicy(7L, new AdminStoragePolicyUpsertRequest( - "Hot Bucket", - StoragePolicyType.S3_COMPATIBLE, - "hot-bucket", - "https://hot.example.com", - "cn-north-1", - false, - "hot/", - StoragePolicyCredentialMode.DOGECLOUD_TEMP, - 40_960L, - defaultCapabilities(40_960L), - true - )); - - assertThat(existingPolicy.getName()).isEqualTo("Hot Bucket"); - assertThat(existingPolicy.getBucketName()).isEqualTo("hot-bucket"); - assertThat(existingPolicy.getEndpoint()).isEqualTo("https://hot.example.com"); - assertThat(existingPolicy.getRegion()).isEqualTo("cn-north-1"); - assertThat(existingPolicy.isPrivateBucket()).isFalse(); - assertThat(existingPolicy.getPrefix()).isEqualTo("hot/"); - assertThat(existingPolicy.getCredentialMode()).isEqualTo(StoragePolicyCredentialMode.DOGECLOUD_TEMP); - assertThat(existingPolicy.getMaxSizeBytes()).isEqualTo(40_960L); - assertThat(existingPolicy.isEnabled()).isTrue(); - assertThat(response.defaultPolicy()).isFalse(); - } - - @Test - void shouldRejectDisablingDefaultStoragePolicy() { - StoragePolicy existingPolicy = createStoragePolicy(3L, "Default Local Storage"); - existingPolicy.setDefaultPolicy(true); - existingPolicy.setEnabled(true); - when(storagePolicyRepository.findById(3L)).thenReturn(Optional.of(existingPolicy)); - - assertThatThrownBy(() -> adminService.updateStoragePolicyStatus(3L, false)) - .isInstanceOf(BusinessException.class); - - verify(storagePolicyRepository, never()).save(any(StoragePolicy.class)); - } - - @Test - void shouldCreateStoragePolicyMigrationTaskSkeleton() { - User adminUser = createUser(99L, "alice", "alice@example.com"); - StoragePolicy sourcePolicy = createStoragePolicy(3L, "Source Policy"); - StoragePolicy targetPolicy = createStoragePolicy(4L, "Target Policy"); - targetPolicy.setEnabled(true); - when(storagePolicyRepository.findById(3L)).thenReturn(Optional.of(sourcePolicy)); - when(storagePolicyRepository.findById(4L)).thenReturn(Optional.of(targetPolicy)); - when(fileEntityRepository.countByStoragePolicyIdAndEntityType(3L, com.yoyuzh.files.core.FileEntityType.VERSION)).thenReturn(5L); - when(storedFileEntityRepository.countDistinctStoredFilesByStoragePolicyIdAndEntityType(3L, com.yoyuzh.files.core.FileEntityType.VERSION)).thenReturn(8L); - when(backgroundTaskService.createQueuedTask(eq(adminUser), eq(BackgroundTaskType.STORAGE_POLICY_MIGRATION), any(), any(), eq("migration-1"))) - .thenAnswer(invocation -> { - BackgroundTask task = new BackgroundTask(); - task.setId(11L); - task.setType(BackgroundTaskType.STORAGE_POLICY_MIGRATION); - task.setStatus(BackgroundTaskStatus.QUEUED); - task.setUserId(adminUser.getId()); - task.setPublicStateJson(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(invocation.getArgument(2))); - task.setPrivateStateJson(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(invocation.getArgument(3))); - task.setCorrelationId("migration-1"); - task.setCreatedAt(LocalDateTime.now()); - task.setUpdatedAt(LocalDateTime.now()); - return task; - }); - - BackgroundTask task = adminService.createStoragePolicyMigrationTask(adminUser, new AdminStoragePolicyMigrationCreateRequest( - 3L, - 4L, - "migration-1" - )); - - assertThat(task.getType()).isEqualTo(BackgroundTaskType.STORAGE_POLICY_MIGRATION); - assertThat(task.getStatus()).isEqualTo(BackgroundTaskStatus.QUEUED); - assertThat(task.getPublicStateJson()).contains("\"sourcePolicyId\":3"); - assertThat(task.getPublicStateJson()).contains("\"targetPolicyId\":4"); - assertThat(task.getPublicStateJson()).contains("\"candidateEntityCount\":5"); - assertThat(task.getPublicStateJson()).contains("\"candidateStoredFileCount\":8"); - assertThat(task.getPublicStateJson()).contains("\"migrationPerformed\":false"); - assertThat(task.getPrivateStateJson()).contains("\"taskType\":\"STORAGE_POLICY_MIGRATION\""); - } - - // --- deleteFile --- - - @Test - void shouldDeleteFileByDelegatingToFileService() { - User owner = createUser(1L, "alice", "alice@example.com"); - StoredFile file = createFile(10L, owner, "/docs", "report.pdf"); - when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file)); - - adminService.deleteFile(10L); - - verify(fileService).delete(owner, 10L); - } - - @Test - void shouldThrowWhenDeletingNonExistentFile() { - when(storedFileRepository.findById(99L)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> adminService.deleteFile(99L)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("file not found"); - } - - // --- updateUserRole --- - - @Test - void shouldUpdateUserRole() { - User user = createUser(1L, "alice", "alice@example.com"); - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); - when(userRepository.save(user)).thenReturn(user); - - AdminUserResponse response = adminService.updateUserRole(1L, UserRole.ADMIN); - - assertThat(user.getRole()).isEqualTo(UserRole.ADMIN); - verify(userRepository).save(user); - } - - @Test - void shouldThrowWhenUpdatingRoleForNonExistentUser() { - when(userRepository.findById(99L)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> adminService.updateUserRole(99L, UserRole.ADMIN)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("user not found"); - } - - // --- updateUserBanned --- - - @Test - void shouldBanUserAndRevokeTokens() { - User user = createUser(1L, "alice", "alice@example.com"); - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); - when(userRepository.save(user)).thenReturn(user); - - adminService.updateUserBanned(1L, true); - - assertThat(user.isBanned()).isTrue(); - verify(refreshTokenService).revokeAllForUser(1L); - verify(userRepository).save(user); - } - - @Test - void shouldUnbanUserAndRevokeExistingTokens() { - User user = createUser(1L, "alice", "alice@example.com"); - user.setBanned(true); - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); - when(userRepository.save(user)).thenReturn(user); - - adminService.updateUserBanned(1L, false); - - assertThat(user.isBanned()).isFalse(); - verify(refreshTokenService).revokeAllForUser(1L); - } - - // --- updateUserPassword --- - - @Test - void shouldUpdateUserPasswordAndRevokeTokens() { - User user = createUser(1L, "alice", "alice@example.com"); - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); - when(passwordEncoder.encode("NewStr0ng!Pass")).thenReturn("hashed"); - when(userRepository.save(user)).thenReturn(user); - - adminService.updateUserPassword(1L, "NewStr0ng!Pass"); - - assertThat(user.getPasswordHash()).isEqualTo("hashed"); - verify(refreshTokenService).revokeAllForUser(1L); - } - - @Test - void shouldRejectWeakPasswordWhenUpdating() { - assertThatThrownBy(() -> adminService.updateUserPassword(1L, "weakpass")) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("密码至少8位"); - verify(userRepository, never()).findById(any()); - } - - // --- resetUserPassword --- - - @Test - void shouldResetUserPasswordAndReturnTemporaryPassword() { - User user = createUser(1L, "alice", "alice@example.com"); - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); - when(passwordEncoder.encode(anyString())).thenReturn("hashed"); - when(userRepository.save(user)).thenReturn(user); - - AdminPasswordResetResponse response = adminService.resetUserPassword(1L); - - assertThat(response.temporaryPassword()).isNotBlank(); - assertThat(PasswordPolicy.isStrong(response.temporaryPassword())).isTrue(); - verify(refreshTokenService).revokeAllForUser(1L); - } - - // --- helpers --- - - private User createUser(Long id, String username, String email) { - User user = new User(); - user.setId(id); - user.setUsername(username); - user.setEmail(email); - user.setPasswordHash("hashed"); - user.setRole(UserRole.USER); - user.setCreatedAt(LocalDateTime.now()); - return user; - } - - private StoredFile createFile(Long id, User owner, String path, String filename) { - StoredFile file = new StoredFile(); - file.setId(id); - file.setUser(owner); - file.setPath(path); - file.setFilename(filename); - file.setSize(1024L); - file.setDirectory(false); - file.setCreatedAt(LocalDateTime.now()); - return file; - } - - private StoragePolicy createStoragePolicy(Long id, String name) { - StoragePolicy policy = new StoragePolicy(); - policy.setId(id); - policy.setName(name); - policy.setType(StoragePolicyType.S3_COMPATIBLE); - policy.setBucketName("bucket"); - policy.setEndpoint("https://s3.example.com"); - policy.setRegion("auto"); - policy.setPrivateBucket(true); - policy.setPrefix("files/"); - policy.setCredentialMode(StoragePolicyCredentialMode.STATIC); - policy.setMaxSizeBytes(10_240L); - policy.setCapabilitiesJson("{}"); - policy.setEnabled(true); - policy.setDefaultPolicy(false); - policy.setCreatedAt(LocalDateTime.now()); - policy.setUpdatedAt(LocalDateTime.now()); - return policy; - } - - private StoragePolicyCapabilities defaultCapabilities(long maxObjectSize) { - return new StoragePolicyCapabilities( - true, - true, - true, - true, - false, - true, - true, - false, - maxObjectSize - ); - } -} diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminStorageGovernanceServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminStorageGovernanceServiceTest.java new file mode 100644 index 0000000..51346f3 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/admin/AdminStorageGovernanceServiceTest.java @@ -0,0 +1,237 @@ +package com.yoyuzh.admin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.auth.User; +import com.yoyuzh.auth.UserRole; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.files.core.FileEntityType; +import com.yoyuzh.files.core.FileEntityRepository; +import com.yoyuzh.files.core.StoredFileEntityRepository; +import com.yoyuzh.files.policy.StoragePolicy; +import com.yoyuzh.files.policy.StoragePolicyCapabilities; +import com.yoyuzh.files.policy.StoragePolicyCredentialMode; +import com.yoyuzh.files.policy.StoragePolicyRepository; +import com.yoyuzh.files.policy.StoragePolicyService; +import com.yoyuzh.files.policy.StoragePolicyType; +import com.yoyuzh.files.tasks.BackgroundTask; +import com.yoyuzh.files.tasks.BackgroundTaskCommandService; +import com.yoyuzh.files.tasks.BackgroundTaskStatus; +import com.yoyuzh.files.tasks.BackgroundTaskType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminStorageGovernanceServiceTest { + + @Mock + private StoragePolicyRepository storagePolicyRepository; + @Mock + private StoragePolicyService storagePolicyService; + @Mock + private FileEntityRepository fileEntityRepository; + @Mock + private StoredFileEntityRepository storedFileEntityRepository; + @Mock + private BackgroundTaskCommandService backgroundTaskCommandService; + @Mock + private AdminAuditService adminAuditService; + + private AdminStorageGovernanceService adminStorageGovernanceService; + + @BeforeEach + void setUp() { + adminStorageGovernanceService = new AdminStorageGovernanceService( + storagePolicyRepository, + storagePolicyService, + fileEntityRepository, + storedFileEntityRepository, + backgroundTaskCommandService, + adminAuditService + ); + } + + @Test + void shouldCreateStoragePolicy() { + when(storagePolicyService.writeCapabilities(any(StoragePolicyCapabilities.class))).thenReturn("{\"maxObjectSize\":20480}"); + when(storagePolicyRepository.save(any(StoragePolicy.class))).thenAnswer(invocation -> { + StoragePolicy policy = invocation.getArgument(0); + policy.setId(9L); + return policy; + }); + when(storagePolicyService.readCapabilities(any(StoragePolicy.class))).thenReturn(defaultCapabilities(20_480L)); + + AdminStoragePolicyResponse response = adminStorageGovernanceService.createStoragePolicy(new AdminStoragePolicyUpsertRequest( + " Archive Bucket ", + StoragePolicyType.S3_COMPATIBLE, + "archive-bucket", + "https://s3.example.com", + "auto", + true, + "archive/", + StoragePolicyCredentialMode.STATIC, + 20_480L, + defaultCapabilities(20_480L), + true + )); + + assertThat(response.name()).isEqualTo("Archive Bucket"); + assertThat(response.type()).isEqualTo(StoragePolicyType.S3_COMPATIBLE); + assertThat(response.bucketName()).isEqualTo("archive-bucket"); + assertThat(response.endpoint()).isEqualTo("https://s3.example.com"); + assertThat(response.region()).isEqualTo("auto"); + assertThat(response.privateBucket()).isTrue(); + assertThat(response.prefix()).isEqualTo("archive/"); + assertThat(response.credentialMode()).isEqualTo(StoragePolicyCredentialMode.STATIC); + assertThat(response.maxSizeBytes()).isEqualTo(20_480L); + assertThat(response.enabled()).isTrue(); + assertThat(response.defaultPolicy()).isFalse(); + } + + @Test + void shouldUpdateStoragePolicyFieldsWithoutChangingDefaultFlag() { + StoragePolicy existingPolicy = createStoragePolicy(7L, "Archive Bucket"); + existingPolicy.setDefaultPolicy(false); + when(storagePolicyService.writeCapabilities(any(StoragePolicyCapabilities.class))).thenReturn("{\"maxObjectSize\":40960}"); + when(storagePolicyRepository.findById(7L)).thenReturn(Optional.of(existingPolicy)); + when(storagePolicyRepository.save(existingPolicy)).thenReturn(existingPolicy); + when(storagePolicyService.readCapabilities(existingPolicy)).thenReturn(defaultCapabilities(40_960L)); + + AdminStoragePolicyResponse response = adminStorageGovernanceService.updateStoragePolicy(7L, new AdminStoragePolicyUpsertRequest( + "Hot Bucket", + StoragePolicyType.S3_COMPATIBLE, + "hot-bucket", + "https://hot.example.com", + "cn-north-1", + false, + "hot/", + StoragePolicyCredentialMode.DOGECLOUD_TEMP, + 40_960L, + defaultCapabilities(40_960L), + true + )); + + assertThat(existingPolicy.getName()).isEqualTo("Hot Bucket"); + assertThat(existingPolicy.getBucketName()).isEqualTo("hot-bucket"); + assertThat(existingPolicy.getEndpoint()).isEqualTo("https://hot.example.com"); + assertThat(existingPolicy.getRegion()).isEqualTo("cn-north-1"); + assertThat(existingPolicy.isPrivateBucket()).isFalse(); + assertThat(existingPolicy.getPrefix()).isEqualTo("hot/"); + assertThat(existingPolicy.getCredentialMode()).isEqualTo(StoragePolicyCredentialMode.DOGECLOUD_TEMP); + assertThat(existingPolicy.getMaxSizeBytes()).isEqualTo(40_960L); + assertThat(existingPolicy.isEnabled()).isTrue(); + assertThat(response.defaultPolicy()).isFalse(); + } + + @Test + void shouldRejectDisablingDefaultStoragePolicy() { + StoragePolicy existingPolicy = createStoragePolicy(3L, "Default Local Storage"); + existingPolicy.setDefaultPolicy(true); + existingPolicy.setEnabled(true); + when(storagePolicyRepository.findById(3L)).thenReturn(Optional.of(existingPolicy)); + + assertThatThrownBy(() -> adminStorageGovernanceService.updateStoragePolicyStatus(3L, false)) + .isInstanceOf(BusinessException.class); + + verify(storagePolicyRepository, never()).save(any(StoragePolicy.class)); + } + + @Test + void shouldCreateStoragePolicyMigrationTaskSkeleton() throws Exception { + User adminUser = createUser(99L, "alice", "alice@example.com"); + StoragePolicy sourcePolicy = createStoragePolicy(3L, "Source Policy"); + StoragePolicy targetPolicy = createStoragePolicy(4L, "Target Policy"); + targetPolicy.setEnabled(true); + when(storagePolicyRepository.findById(3L)).thenReturn(Optional.of(sourcePolicy)); + when(storagePolicyRepository.findById(4L)).thenReturn(Optional.of(targetPolicy)); + when(fileEntityRepository.countByStoragePolicyIdAndEntityType(3L, FileEntityType.VERSION)).thenReturn(5L); + when(storedFileEntityRepository.countDistinctStoredFilesByStoragePolicyIdAndEntityType(3L, FileEntityType.VERSION)).thenReturn(8L); + when(backgroundTaskCommandService.createQueuedTask(eq(adminUser), eq(BackgroundTaskType.STORAGE_POLICY_MIGRATION), any(), any(), eq("migration-1"))) + .thenAnswer(invocation -> { + BackgroundTask task = new BackgroundTask(); + task.setId(11L); + task.setType(BackgroundTaskType.STORAGE_POLICY_MIGRATION); + task.setStatus(BackgroundTaskStatus.QUEUED); + task.setUserId(adminUser.getId()); + task.setPublicStateJson(new ObjectMapper().writeValueAsString(invocation.getArgument(2))); + task.setPrivateStateJson(new ObjectMapper().writeValueAsString(invocation.getArgument(3))); + task.setCorrelationId("migration-1"); + task.setCreatedAt(LocalDateTime.now()); + task.setUpdatedAt(LocalDateTime.now()); + return task; + }); + + BackgroundTask task = adminStorageGovernanceService.createStoragePolicyMigrationTask(adminUser, new AdminStoragePolicyMigrationCreateRequest( + 3L, + 4L, + "migration-1" + )); + + assertThat(task.getType()).isEqualTo(BackgroundTaskType.STORAGE_POLICY_MIGRATION); + assertThat(task.getStatus()).isEqualTo(BackgroundTaskStatus.QUEUED); + assertThat(task.getPublicStateJson()).contains("\"sourcePolicyId\":3"); + assertThat(task.getPublicStateJson()).contains("\"targetPolicyId\":4"); + assertThat(task.getPublicStateJson()).contains("\"candidateEntityCount\":5"); + assertThat(task.getPublicStateJson()).contains("\"candidateStoredFileCount\":8"); + assertThat(task.getPublicStateJson()).contains("\"migrationPerformed\":false"); + assertThat(task.getPrivateStateJson()).contains("\"taskType\":\"STORAGE_POLICY_MIGRATION\""); + } + + private User createUser(Long id, String username, String email) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setEmail(email); + user.setPasswordHash("hashed"); + user.setRole(UserRole.ADMIN); + user.setCreatedAt(LocalDateTime.now()); + return user; + } + + private StoragePolicy createStoragePolicy(Long id, String name) { + StoragePolicy policy = new StoragePolicy(); + policy.setId(id); + policy.setName(name); + policy.setType(StoragePolicyType.S3_COMPATIBLE); + policy.setBucketName("bucket"); + policy.setEndpoint("https://s3.example.com"); + policy.setRegion("auto"); + policy.setPrivateBucket(true); + policy.setPrefix("files/"); + policy.setCredentialMode(StoragePolicyCredentialMode.STATIC); + policy.setMaxSizeBytes(10_240L); + policy.setCapabilitiesJson("{}"); + policy.setEnabled(true); + policy.setDefaultPolicy(false); + policy.setCreatedAt(LocalDateTime.now()); + policy.setUpdatedAt(LocalDateTime.now()); + return policy; + } + + private StoragePolicyCapabilities defaultCapabilities(long maxObjectSize) { + return new StoragePolicyCapabilities( + true, + true, + true, + true, + false, + true, + true, + false, + maxObjectSize + ); + } +} diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminServiceStoragePolicyCacheTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminStoragePolicyQueryServiceCacheTest.java similarity index 57% rename from backend/src/test/java/com/yoyuzh/admin/AdminServiceStoragePolicyCacheTest.java rename to backend/src/test/java/com/yoyuzh/admin/AdminStoragePolicyQueryServiceCacheTest.java index 3df33b9..f4cf667 100644 --- a/backend/src/test/java/com/yoyuzh/admin/AdminServiceStoragePolicyCacheTest.java +++ b/backend/src/test/java/com/yoyuzh/admin/AdminStoragePolicyQueryServiceCacheTest.java @@ -1,26 +1,15 @@ package com.yoyuzh.admin; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.yoyuzh.auth.AuthTokenInvalidationService; -import com.yoyuzh.auth.RefreshTokenService; -import com.yoyuzh.auth.RegistrationInviteService; -import com.yoyuzh.auth.UserRepository; import com.yoyuzh.config.RedisCacheNames; -import com.yoyuzh.files.core.FileBlobRepository; import com.yoyuzh.files.core.FileEntityRepository; -import com.yoyuzh.files.core.FileService; import com.yoyuzh.files.core.StoredFileEntityRepository; -import com.yoyuzh.files.core.StoredFileRepository; import com.yoyuzh.files.policy.StoragePolicy; import com.yoyuzh.files.policy.StoragePolicyCapabilities; import com.yoyuzh.files.policy.StoragePolicyCredentialMode; import com.yoyuzh.files.policy.StoragePolicyRepository; import com.yoyuzh.files.policy.StoragePolicyService; import com.yoyuzh.files.policy.StoragePolicyType; -import com.yoyuzh.files.share.FileShareLinkRepository; -import com.yoyuzh.files.tasks.BackgroundTaskRepository; -import com.yoyuzh.files.tasks.BackgroundTaskService; -import com.yoyuzh.transfer.OfflineTransferSessionRepository; +import com.yoyuzh.files.tasks.BackgroundTaskCommandService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -29,7 +18,6 @@ import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.concurrent.ConcurrentMapCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.time.LocalDateTime; @@ -43,11 +31,14 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@SpringJUnitConfig(AdminServiceStoragePolicyCacheTest.CacheTestConfiguration.class) -class AdminServiceStoragePolicyCacheTest { +@SpringJUnitConfig(AdminStoragePolicyQueryServiceCacheTest.CacheTestConfiguration.class) +class AdminStoragePolicyQueryServiceCacheTest { @Autowired - private AdminService adminService; + private AdminStoragePolicyQueryService adminStoragePolicyQueryService; + + @Autowired + private AdminStorageGovernanceService adminStorageGovernanceService; @Autowired private CacheManager cacheManager; @@ -71,8 +62,8 @@ class AdminServiceStoragePolicyCacheTest { when(storagePolicyRepository.findAll(any(org.springframework.data.domain.Sort.class))) .thenReturn(List.of(defaultPolicy)); - adminService.listStoragePolicies(); - adminService.listStoragePolicies(); + adminStoragePolicyQueryService.listStoragePolicies(); + adminStoragePolicyQueryService.listStoragePolicies(); verify(storagePolicyRepository, times(1)).findAll(any(org.springframework.data.domain.Sort.class)); } @@ -92,9 +83,9 @@ class AdminServiceStoragePolicyCacheTest { return saved; }); - adminService.listStoragePolicies(); - adminService.createStoragePolicy(upsertRequest("Archive Bucket", true)); - adminService.listStoragePolicies(); + adminStoragePolicyQueryService.listStoragePolicies(); + adminStorageGovernanceService.createStoragePolicy(upsertRequest("Archive Bucket", true)); + adminStoragePolicyQueryService.listStoragePolicies(); verify(storagePolicyRepository, times(2)).findAll(any(org.springframework.data.domain.Sort.class)); } @@ -109,9 +100,9 @@ class AdminServiceStoragePolicyCacheTest { when(storagePolicyRepository.findById(2L)).thenReturn(Optional.of(existingPolicy)); when(storagePolicyRepository.save(existingPolicy)).thenReturn(updatedPolicy); - adminService.listStoragePolicies(); - adminService.updateStoragePolicy(2L, upsertRequest("Hot Bucket", true)); - adminService.listStoragePolicies(); + adminStoragePolicyQueryService.listStoragePolicies(); + adminStorageGovernanceService.updateStoragePolicy(2L, upsertRequest("Hot Bucket", true)); + adminStoragePolicyQueryService.listStoragePolicies(); verify(storagePolicyRepository, times(2)).findAll(any(org.springframework.data.domain.Sort.class)); } @@ -126,9 +117,9 @@ class AdminServiceStoragePolicyCacheTest { when(storagePolicyRepository.findById(2L)).thenReturn(Optional.of(existingPolicy)); when(storagePolicyRepository.save(existingPolicy)).thenReturn(disabledPolicy); - adminService.listStoragePolicies(); - adminService.updateStoragePolicyStatus(2L, false); - adminService.listStoragePolicies(); + adminStoragePolicyQueryService.listStoragePolicies(); + adminStorageGovernanceService.updateStoragePolicyStatus(2L, false); + adminStoragePolicyQueryService.listStoragePolicies(); verify(storagePolicyRepository, times(2)).findAll(any(org.springframework.data.domain.Sort.class)); } @@ -176,56 +167,6 @@ class AdminServiceStoragePolicyCacheTest { return new ConcurrentMapCacheManager(RedisCacheNames.STORAGE_POLICIES); } - @Bean - UserRepository userRepository() { - return mock(UserRepository.class); - } - - @Bean - StoredFileRepository storedFileRepository() { - return mock(StoredFileRepository.class); - } - - @Bean - FileBlobRepository fileBlobRepository() { - return mock(FileBlobRepository.class); - } - - @Bean - FileService fileService() { - return mock(FileService.class); - } - - @Bean - PasswordEncoder passwordEncoder() { - return mock(PasswordEncoder.class); - } - - @Bean - RefreshTokenService refreshTokenService() { - return mock(RefreshTokenService.class); - } - - @Bean - AuthTokenInvalidationService authTokenInvalidationService() { - return mock(AuthTokenInvalidationService.class); - } - - @Bean - RegistrationInviteService registrationInviteService() { - return mock(RegistrationInviteService.class); - } - - @Bean - OfflineTransferSessionRepository offlineTransferSessionRepository() { - return mock(OfflineTransferSessionRepository.class); - } - - @Bean - AdminMetricsService adminMetricsService() { - return mock(AdminMetricsService.class); - } - @Bean StoragePolicyRepository storagePolicyRepository() { return mock(StoragePolicyRepository.class); @@ -247,63 +188,35 @@ class AdminServiceStoragePolicyCacheTest { } @Bean - BackgroundTaskService backgroundTaskService() { - return mock(BackgroundTaskService.class); + BackgroundTaskCommandService backgroundTaskCommandService() { + return mock(BackgroundTaskCommandService.class); } @Bean - BackgroundTaskRepository backgroundTaskRepository() { - return mock(BackgroundTaskRepository.class); + AdminAuditService adminAuditService() { + return mock(AdminAuditService.class); } @Bean - FileShareLinkRepository fileShareLinkRepository() { - return mock(FileShareLinkRepository.class); + AdminStoragePolicyQueryService adminStoragePolicyQueryService(StoragePolicyRepository storagePolicyRepository, + StoragePolicyService storagePolicyService) { + return new AdminStoragePolicyQueryService(storagePolicyRepository, storagePolicyService); } @Bean - ObjectMapper objectMapper() { - return new ObjectMapper(); - } - - @Bean - AdminService adminService(UserRepository userRepository, - StoredFileRepository storedFileRepository, - FileBlobRepository fileBlobRepository, - FileService fileService, - PasswordEncoder passwordEncoder, - RefreshTokenService refreshTokenService, - AuthTokenInvalidationService authTokenInvalidationService, - RegistrationInviteService registrationInviteService, - OfflineTransferSessionRepository offlineTransferSessionRepository, - AdminMetricsService adminMetricsService, - StoragePolicyRepository storagePolicyRepository, - StoragePolicyService storagePolicyService, - FileEntityRepository fileEntityRepository, - StoredFileEntityRepository storedFileEntityRepository, - BackgroundTaskRepository backgroundTaskRepository, - BackgroundTaskService backgroundTaskService, - FileShareLinkRepository fileShareLinkRepository, - ObjectMapper objectMapper) { - return new AdminService( - userRepository, - storedFileRepository, - fileBlobRepository, - fileService, - passwordEncoder, - refreshTokenService, - authTokenInvalidationService, - registrationInviteService, - offlineTransferSessionRepository, - adminMetricsService, + AdminStorageGovernanceService adminStorageGovernanceService(StoragePolicyRepository storagePolicyRepository, + StoragePolicyService storagePolicyService, + FileEntityRepository fileEntityRepository, + StoredFileEntityRepository storedFileEntityRepository, + BackgroundTaskCommandService backgroundTaskCommandService, + AdminAuditService adminAuditService) { + return new AdminStorageGovernanceService( storagePolicyRepository, storagePolicyService, fileEntityRepository, storedFileEntityRepository, - backgroundTaskRepository, - backgroundTaskService, - fileShareLinkRepository, - objectMapper + backgroundTaskCommandService, + adminAuditService ); } } diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminTaskQueryServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminTaskQueryServiceTest.java new file mode 100644 index 0000000..7b83890 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/admin/AdminTaskQueryServiceTest.java @@ -0,0 +1,111 @@ +package com.yoyuzh.admin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.auth.User; +import com.yoyuzh.auth.UserRepository; +import com.yoyuzh.auth.UserRole; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.PageResponse; +import com.yoyuzh.files.tasks.BackgroundTask; +import com.yoyuzh.files.tasks.BackgroundTaskRepository; +import com.yoyuzh.files.tasks.BackgroundTaskStatus; +import com.yoyuzh.files.tasks.BackgroundTaskType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminTaskQueryServiceTest { + + @Mock + private BackgroundTaskRepository backgroundTaskRepository; + @Mock + private UserRepository userRepository; + + private AdminTaskQueryService adminTaskQueryService; + + @BeforeEach + void setUp() { + adminTaskQueryService = new AdminTaskQueryService(backgroundTaskRepository, userRepository, new ObjectMapper()); + } + + @Test + void shouldListTasksWithParsedDerivedFields() { + User owner = createUser(1L, "alice", "alice@example.com"); + BackgroundTask task = createTask(11L, owner.getId(), BackgroundTaskStatus.RUNNING); + task.setPublicStateJson(""" + {"failureCategory":"TRANSIENT_INFRASTRUCTURE","retryScheduled":true,"workerOwner":"media-worker-1"} + """); + task.setLeaseOwner("worker-a"); + task.setLeaseExpiresAt(LocalDateTime.now().plusMinutes(1)); + when(backgroundTaskRepository.searchAdminTasks(eq("alice"), eq(BackgroundTaskType.MEDIA_META), eq(BackgroundTaskStatus.RUNNING), any(), eq(AdminTaskLeaseState.ACTIVE.name()), any(), any())) + .thenReturn(new PageImpl<>(List.of(task))); + when(userRepository.findAllById(any())).thenReturn(List.of(owner)); + + PageResponse response = adminTaskQueryService.listTasks( + 0, + 10, + "alice", + BackgroundTaskType.MEDIA_META, + BackgroundTaskStatus.RUNNING, + com.yoyuzh.files.tasks.BackgroundTaskFailureCategory.TRANSIENT_INFRASTRUCTURE, + AdminTaskLeaseState.ACTIVE + ); + + assertThat(response.total()).isEqualTo(1L); + AdminTaskResponse first = response.items().get(0); + assertThat(first.ownerUsername()).isEqualTo("alice"); + assertThat(first.failureCategory()).isEqualTo("TRANSIENT_INFRASTRUCTURE"); + assertThat(first.retryScheduled()).isTrue(); + assertThat(first.workerOwner()).isEqualTo("media-worker-1"); + assertThat(first.leaseState()).isEqualTo(AdminTaskLeaseState.ACTIVE); + } + + @Test + void shouldThrowWhenTaskNotFound() { + when(backgroundTaskRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminTaskQueryService.getTask(99L)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("task not found"); + } + + private User createUser(Long id, String username, String email) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setEmail(email); + user.setPasswordHash("hashed"); + user.setRole(UserRole.USER); + user.setCreatedAt(LocalDateTime.now()); + return user; + } + + private BackgroundTask createTask(Long id, Long userId, BackgroundTaskStatus status) { + BackgroundTask task = new BackgroundTask(); + task.setId(id); + task.setType(BackgroundTaskType.MEDIA_META); + task.setStatus(status); + task.setUserId(userId); + task.setPublicStateJson("{}"); + task.setCorrelationId("task-" + id); + task.setAttemptCount(1); + task.setMaxAttempts(3); + task.setCreatedAt(LocalDateTime.now().minusMinutes(1)); + task.setUpdatedAt(LocalDateTime.now()); + return task; + } +} diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminUserGovernanceServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminUserGovernanceServiceTest.java new file mode 100644 index 0000000..141ce8e --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/admin/AdminUserGovernanceServiceTest.java @@ -0,0 +1,233 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.auth.AuthSessionPolicy; +import com.yoyuzh.auth.AuthTokenInvalidationService; +import com.yoyuzh.auth.PasswordPolicy; +import com.yoyuzh.auth.RefreshTokenService; +import com.yoyuzh.auth.User; +import com.yoyuzh.auth.UserRepository; +import com.yoyuzh.auth.UserRole; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.PageResponse; +import com.yoyuzh.files.core.StoredFileRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminUserGovernanceServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private StoredFileRepository storedFileRepository; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private RefreshTokenService refreshTokenService; + @Mock + private AuthTokenInvalidationService authTokenInvalidationService; + @Mock + private AdminAuditService adminAuditService; + + private AuthSessionPolicy authSessionPolicy; + private AdminUserGovernanceService adminUserGovernanceService; + + @BeforeEach + void setUp() { + authSessionPolicy = new AuthSessionPolicy(); + adminUserGovernanceService = new AdminUserGovernanceService( + userRepository, + storedFileRepository, + passwordEncoder, + refreshTokenService, + authTokenInvalidationService, + authSessionPolicy, + adminAuditService + ); + } + + @Test + void shouldListUsersWithPagination() { + User user = createUser(1L, "alice", "alice@example.com"); + when(userRepository.searchByUsernameOrEmail(anyString(), any())) + .thenReturn(new PageImpl<>(List.of(user))); + when(storedFileRepository.sumFileSizeByUserId(1L)).thenReturn(2048L); + + PageResponse response = adminUserGovernanceService.listUsers(0, 10, "alice"); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).username()).isEqualTo("alice"); + assertThat(response.items().get(0).usedStorageBytes()).isEqualTo(2048L); + } + + @Test + void shouldNormalizeNullQueryToEmptyStringWhenListingUsers() { + when(userRepository.searchByUsernameOrEmail(anyString(), any())) + .thenReturn(new PageImpl<>(List.of())); + + adminUserGovernanceService.listUsers(0, 10, null); + + verify(userRepository).searchByUsernameOrEmail(eq(""), any()); + } + + @Test + void shouldUpdateUserRole() { + User user = createUser(1L, "alice", "alice@example.com"); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.save(user)).thenReturn(user); + + AdminUserResponse response = adminUserGovernanceService.updateUserRole(1L, UserRole.MODERATOR); + + assertThat(user.getRole()).isEqualTo(UserRole.MODERATOR); + assertThat(response.role()).isEqualTo(UserRole.MODERATOR); + verify(userRepository).save(user); + } + + @Test + void shouldThrowWhenUpdatingRoleForNonExistentUser() { + when(userRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminUserGovernanceService.updateUserRole(99L, UserRole.ADMIN)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("user not found"); + } + + @Test + void shouldBanUserAndRevokeTokens() { + User user = createUser(1L, "alice", "alice@example.com"); + String previousActiveSessionId = user.getActiveSessionId(); + String previousDesktopSessionId = user.getDesktopActiveSessionId(); + String previousMobileSessionId = user.getMobileActiveSessionId(); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.save(user)).thenReturn(user); + + adminUserGovernanceService.updateUserBanned(1L, true); + + assertThat(user.isBanned()).isTrue(); + assertThat(user.getActiveSessionId()).isNotEqualTo(previousActiveSessionId); + assertThat(user.getDesktopActiveSessionId()).isNotEqualTo(previousDesktopSessionId); + assertThat(user.getMobileActiveSessionId()).isNotEqualTo(previousMobileSessionId); + verify(authTokenInvalidationService).revokeAccessTokensForUser(1L); + verify(refreshTokenService).revokeAllForUser(1L); + verify(userRepository).save(user); + } + + @Test + void shouldUnbanUserAndRevokeExistingTokens() { + User user = createUser(1L, "alice", "alice@example.com"); + user.setBanned(true); + String previousActiveSessionId = user.getActiveSessionId(); + String previousDesktopSessionId = user.getDesktopActiveSessionId(); + String previousMobileSessionId = user.getMobileActiveSessionId(); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.save(user)).thenReturn(user); + + adminUserGovernanceService.updateUserBanned(1L, false); + + assertThat(user.isBanned()).isFalse(); + assertThat(user.getActiveSessionId()).isNotEqualTo(previousActiveSessionId); + assertThat(user.getDesktopActiveSessionId()).isNotEqualTo(previousDesktopSessionId); + assertThat(user.getMobileActiveSessionId()).isNotEqualTo(previousMobileSessionId); + verify(authTokenInvalidationService).revokeAccessTokensForUser(1L); + verify(refreshTokenService).revokeAllForUser(1L); + } + + @Test + void shouldUpdateUserPasswordAndRevokeTokens() { + User user = createUser(1L, "alice", "alice@example.com"); + String previousActiveSessionId = user.getActiveSessionId(); + String previousDesktopSessionId = user.getDesktopActiveSessionId(); + String previousMobileSessionId = user.getMobileActiveSessionId(); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(passwordEncoder.encode("NewStr0ng!Pass")).thenReturn("hashed"); + when(userRepository.save(user)).thenReturn(user); + + adminUserGovernanceService.updateUserPassword(1L, "NewStr0ng!Pass"); + + assertThat(user.getPasswordHash()).isEqualTo("hashed"); + assertThat(user.getActiveSessionId()).isNotEqualTo(previousActiveSessionId); + assertThat(user.getDesktopActiveSessionId()).isNotEqualTo(previousDesktopSessionId); + assertThat(user.getMobileActiveSessionId()).isNotEqualTo(previousMobileSessionId); + verify(authTokenInvalidationService).revokeAccessTokensForUser(1L); + verify(refreshTokenService).revokeAllForUser(1L); + } + + @Test + void shouldRejectWeakPasswordWhenUpdating() { + assertThatThrownBy(() -> adminUserGovernanceService.updateUserPassword(1L, "weakpass")) + .isInstanceOf(BusinessException.class) + .hasMessage(PasswordPolicy.VALIDATION_MESSAGE); + verify(userRepository, never()).findById(any()); + } + + @Test + void shouldUpdateUserStorageQuota() { + User user = createUser(1L, "alice", "alice@example.com"); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.save(user)).thenReturn(user); + + AdminUserResponse response = adminUserGovernanceService.updateUserStorageQuota(1L, 1234L); + + assertThat(user.getStorageQuotaBytes()).isEqualTo(1234L); + assertThat(response.storageQuotaBytes()).isEqualTo(1234L); + } + + @Test + void shouldUpdateUserMaxUploadSize() { + User user = createUser(1L, "alice", "alice@example.com"); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.save(user)).thenReturn(user); + + AdminUserResponse response = adminUserGovernanceService.updateUserMaxUploadSize(1L, 5678L); + + assertThat(user.getMaxUploadSizeBytes()).isEqualTo(5678L); + assertThat(response.maxUploadSizeBytes()).isEqualTo(5678L); + } + + @Test + void shouldResetUserPasswordAndReturnTemporaryPassword() { + User user = createUser(1L, "alice", "alice@example.com"); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(passwordEncoder.encode(anyString())).thenReturn("hashed"); + when(userRepository.save(user)).thenReturn(user); + + AdminPasswordResetResponse response = adminUserGovernanceService.resetUserPassword(1L); + + assertThat(response.temporaryPassword()).isNotBlank(); + assertThat(PasswordPolicy.isStrong(response.temporaryPassword())).isTrue(); + verify(authTokenInvalidationService).revokeAccessTokensForUser(1L); + verify(refreshTokenService).revokeAllForUser(1L); + } + + private User createUser(Long id, String username, String email) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setEmail(email); + user.setPasswordHash("hashed"); + user.setRole(UserRole.USER); + user.setActiveSessionId("active-session-" + id); + user.setDesktopActiveSessionId("desktop-session-" + id); + user.setMobileActiveSessionId("mobile-session-" + id); + user.setCreatedAt(LocalDateTime.now()); + return user; + } +} diff --git a/backend/src/test/java/com/yoyuzh/api/v2/files/UploadSessionV2ControllerTest.java b/backend/src/test/java/com/yoyuzh/api/v2/files/UploadSessionV2ControllerTest.java index a4960b4..a75bc28 100644 --- a/backend/src/test/java/com/yoyuzh/api/v2/files/UploadSessionV2ControllerTest.java +++ b/backend/src/test/java/com/yoyuzh/api/v2/files/UploadSessionV2ControllerTest.java @@ -21,7 +21,9 @@ import org.springframework.web.method.support.ModelAndViewContainer; import java.time.LocalDateTime; import java.util.Map; +import java.util.Optional; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -44,6 +46,7 @@ class UploadSessionV2ControllerTest { void setUp() { uploadSessionService = mock(UploadSessionService.class); userDetailsService = mock(CustomUserDetailsService.class); + when(uploadSessionService.getRuntimeState(anyString())).thenReturn(Optional.empty()); mockMvc = MockMvcBuilders.standaloneSetup( new UploadSessionV2Controller(uploadSessionService, userDetailsService) ).setCustomArgumentResolvers(authenticationPrincipalResolver()).build(); @@ -103,6 +106,32 @@ class UploadSessionV2ControllerTest { .andExpect(jsonPath("$.data.strategy.completeUrl").value("/api/v2/files/upload-sessions/session-1/complete")); } + @Test + void shouldExposeRuntimeStateWhenRedisUploadStateExists() throws Exception { + User user = createUser(7L); + UploadSession session = createSession(user); + when(userDetailsService.loadDomainUser("alice")).thenReturn(user); + when(uploadSessionService.getOwnedSession(user, "session-1")).thenReturn(session); + when(uploadSessionService.getRuntimeState("session-1")).thenReturn(Optional.of( + new com.yoyuzh.files.upload.UploadSessionRuntimeState( + "uploading", + 1024L, + 2, + 25, + LocalDateTime.of(2026, 4, 10, 12, 0), + LocalDateTime.of(2026, 4, 11, 12, 0) + ) + )); + + mockMvc.perform(get("/api/v2/files/upload-sessions/session-1") + .with(user(userDetails()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.runtime.phase").value("uploading")) + .andExpect(jsonPath("$.data.runtime.uploadedBytes").value(1024)) + .andExpect(jsonPath("$.data.runtime.uploadedPartCount").value(2)) + .andExpect(jsonPath("$.data.runtime.progressPercent").value(25)); + } + @Test void shouldReturnDirectSingleStrategyInSessionResponse() throws Exception { User user = createUser(7L); diff --git a/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java b/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java index bd5e0ab..e01f349 100644 --- a/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java @@ -15,6 +15,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; @@ -52,6 +53,9 @@ class AuthServiceTest { @Mock private RefreshTokenService refreshTokenService; + @Mock + private AuthTokenInvalidationService authTokenInvalidationService; + @Mock private FileService fileService; @@ -61,6 +65,9 @@ class AuthServiceTest { @Mock private RegistrationInviteService registrationInviteService; + @Spy + private AuthSessionPolicy authSessionPolicy = new AuthSessionPolicy(); + @InjectMocks private AuthService authService; @@ -238,11 +245,58 @@ class AuthServiceTest { AuthResponse response = authService.devLogin("demo"); assertThat(response.user().username()).isEqualTo("demo"); + assertThat(response.user().role()).isEqualTo(UserRole.USER); assertThat(response.accessToken()).isEqualTo("access-token"); assertThat(response.refreshToken()).isEqualTo("refresh-token"); verify(fileService).ensureDefaultDirectories(any(User.class)); } + @Test + void shouldUpgradeAdminDevLoginUserToAdminRole() { + User existing = new User(); + existing.setId(18L); + existing.setUsername("admin"); + existing.setDisplayName("admin"); + existing.setEmail("admin@dev.local"); + existing.setRole(UserRole.USER); + existing.setPreferredLanguage("zh-CN"); + existing.setCreatedAt(LocalDateTime.now()); + + when(userRepository.findByUsername("admin")).thenReturn(Optional.of(existing)); + when(userRepository.save(existing)).thenReturn(existing); + when(jwtTokenProvider.generateAccessToken(eq(18L), eq("admin"), anyString(), eq(AuthClientType.DESKTOP))).thenReturn("admin-access-token"); + when(refreshTokenService.issueRefreshToken(existing, AuthClientType.DESKTOP)).thenReturn("admin-refresh-token"); + + AuthResponse response = authService.devLogin("admin"); + + assertThat(response.user().role()).isEqualTo(UserRole.ADMIN); + assertThat(existing.getRole()).isEqualTo(UserRole.ADMIN); + verify(fileService).ensureDefaultDirectories(existing); + } + + @Test + void shouldUpgradeOperatorDevLoginUserToModeratorRole() { + User existing = new User(); + existing.setId(19L); + existing.setUsername("operator"); + existing.setDisplayName("operator"); + existing.setEmail("operator@dev.local"); + existing.setRole(UserRole.USER); + existing.setPreferredLanguage("zh-CN"); + existing.setCreatedAt(LocalDateTime.now()); + + when(userRepository.findByUsername("operator")).thenReturn(Optional.of(existing)); + when(userRepository.save(existing)).thenReturn(existing); + when(jwtTokenProvider.generateAccessToken(eq(19L), eq("operator"), anyString(), eq(AuthClientType.DESKTOP))).thenReturn("operator-access-token"); + when(refreshTokenService.issueRefreshToken(existing, AuthClientType.DESKTOP)).thenReturn("operator-refresh-token"); + + AuthResponse response = authService.devLogin("operator"); + + assertThat(response.user().role()).isEqualTo(UserRole.MODERATOR); + assertThat(existing.getRole()).isEqualTo(UserRole.MODERATOR); + verify(fileService).ensureDefaultDirectories(existing); + } + @Test void shouldUpdateCurrentUserProfile() { User user = new User(); @@ -303,6 +357,7 @@ class AuthServiceTest { assertThat(response.accessToken()).isEqualTo("new-access"); assertThat(response.refreshToken()).isEqualTo("new-refresh"); + verify(authTokenInvalidationService).revokeAccessTokensForUser(1L); verify(refreshTokenService).revokeAllForUser(1L); verify(passwordEncoder).encode("NewPass1!A"); } diff --git a/backend/src/test/java/com/yoyuzh/auth/AuthSessionPolicyTest.java b/backend/src/test/java/com/yoyuzh/auth/AuthSessionPolicyTest.java new file mode 100644 index 0000000..6558123 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/auth/AuthSessionPolicyTest.java @@ -0,0 +1,36 @@ +package com.yoyuzh.auth; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AuthSessionPolicyTest { + + private final AuthSessionPolicy authSessionPolicy = new AuthSessionPolicy(); + + @Test + void shouldRotateOnlyRequestedClientSession() { + User user = new User(); + user.setDesktopActiveSessionId("desktop-old"); + user.setMobileActiveSessionId("mobile-old"); + + authSessionPolicy.rotateActiveSession(user, AuthClientType.MOBILE); + + assertThat(user.getMobileActiveSessionId()).isNotBlank().isNotEqualTo("mobile-old"); + assertThat(user.getDesktopActiveSessionId()).isEqualTo("desktop-old"); + } + + @Test + void shouldRotateAllActiveSessions() { + User user = new User(); + user.setActiveSessionId("legacy-old"); + user.setDesktopActiveSessionId("desktop-old"); + user.setMobileActiveSessionId("mobile-old"); + + authSessionPolicy.rotateAllActiveSessions(user); + + assertThat(user.getActiveSessionId()).isNotEqualTo("legacy-old"); + assertThat(user.getDesktopActiveSessionId()).isNotEqualTo("desktop-old"); + assertThat(user.getMobileActiveSessionId()).isNotEqualTo("mobile-old"); + } +} diff --git a/backend/src/test/java/com/yoyuzh/auth/AuthTokenInvalidationServiceTest.java b/backend/src/test/java/com/yoyuzh/auth/AuthTokenInvalidationServiceTest.java new file mode 100644 index 0000000..24c783f --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/auth/AuthTokenInvalidationServiceTest.java @@ -0,0 +1,101 @@ +package com.yoyuzh.auth; + +import com.yoyuzh.config.AppRedisProperties; +import com.yoyuzh.config.JwtProperties; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AuthTokenInvalidationServiceTest { + + @Test + void shouldStoreAccessRevocationCutoffInEpochSeconds() { + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + AuthTokenInvalidationService service = new AuthTokenInvalidationService( + redisTemplate, + redisProperties(), + jwtProperties() + ); + + service.revokeAccessTokensForUser(7L, AuthClientType.DESKTOP); + + verify(valueOperations).set( + eq("yoyuzh:auth:access-revoked-before:7:DESKTOP"), + any(String.class), + eq(Duration.ofSeconds(960)) + ); + } + + @Test + void shouldNotTreatSameSecondFreshTokenAsRevoked() { + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("yoyuzh:auth:access-revoked-before:7:DESKTOP")).thenReturn("1710000000"); + + AuthTokenInvalidationService service = new AuthTokenInvalidationService( + redisTemplate, + redisProperties(), + jwtProperties() + ); + + assertThat(service.isAccessTokenRevoked( + 7L, + AuthClientType.DESKTOP, + Instant.ofEpochSecond(1710000000L) + )).isFalse(); + } + + @Test + void shouldRemainCompatibleWithOldMillisecondRevocationValues() { + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("yoyuzh:auth:access-revoked-before:7:DESKTOP")).thenReturn("1710000000123"); + + AuthTokenInvalidationService service = new AuthTokenInvalidationService( + redisTemplate, + redisProperties(), + jwtProperties() + ); + + assertThat(service.isAccessTokenRevoked( + 7L, + AuthClientType.DESKTOP, + Instant.ofEpochSecond(1709999999L) + )).isTrue(); + assertThat(service.isAccessTokenRevoked( + 7L, + AuthClientType.DESKTOP, + Instant.ofEpochSecond(1710000000L) + )).isFalse(); + } + + private AppRedisProperties redisProperties() { + AppRedisProperties properties = new AppRedisProperties(); + properties.setTtlBufferSeconds(60); + return properties; + } + + private JwtProperties jwtProperties() { + JwtProperties properties = new JwtProperties(); + properties.setAccessExpirationSeconds(900); + return properties; + } +} diff --git a/backend/src/test/java/com/yoyuzh/auth/RefreshTokenServiceIntegrationTest.java b/backend/src/test/java/com/yoyuzh/auth/RefreshTokenServiceIntegrationTest.java index 4996c9f..869e6e4 100644 --- a/backend/src/test/java/com/yoyuzh/auth/RefreshTokenServiceIntegrationTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/RefreshTokenServiceIntegrationTest.java @@ -58,7 +58,7 @@ class RefreshTokenServiceIntegrationTest { assertThat(rotated.refreshToken()).isNotBlank().isNotEqualTo(rawToken); assertThatThrownBy(() -> refreshTokenService.rotateRefreshToken(rawToken)) .isInstanceOf(BusinessException.class) - .hasMessageContaining("无效或已使用"); + .hasMessageContaining("刷新令牌无效或已使用"); assertThat(refreshTokenRepository.findAll()) .hasSize(2) .filteredOn(RefreshToken::isRevoked) diff --git a/backend/src/test/java/com/yoyuzh/auth/RegistrationInviteServiceTest.java b/backend/src/test/java/com/yoyuzh/auth/RegistrationInviteServiceTest.java new file mode 100644 index 0000000..fca4b6e --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/auth/RegistrationInviteServiceTest.java @@ -0,0 +1,77 @@ +package com.yoyuzh.auth; + +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.config.RegistrationProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RegistrationInviteServiceTest { + + @Mock + private RegistrationInviteStateRepository registrationInviteStateRepository; + + private RegistrationInviteService registrationInviteService; + + @BeforeEach + void setUp() { + registrationInviteService = new RegistrationInviteService( + registrationInviteStateRepository, + new RegistrationProperties() + ); + } + + @Test + void shouldRejectBlankInviteCodeUpdate() { + RegistrationInviteState state = existingState("INITIAL-CODE"); + when(registrationInviteStateRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(state)); + + assertThatThrownBy(() -> registrationInviteService.updateCurrentInviteCode(" ")) + .isInstanceOf(BusinessException.class); + verify(registrationInviteStateRepository, never()).save(any(RegistrationInviteState.class)); + } + + @Test + void shouldRejectInviteCodeLongerThan64Characters() { + RegistrationInviteState state = existingState("INITIAL-CODE"); + when(registrationInviteStateRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(state)); + + assertThatThrownBy(() -> registrationInviteService.updateCurrentInviteCode("A".repeat(65))) + .isInstanceOf(BusinessException.class); + verify(registrationInviteStateRepository, never()).save(any(RegistrationInviteState.class)); + } + + @Test + void shouldReturnExistingInviteCodeWhenConcurrentInitializationAlreadyInsertedState() { + RegistrationInviteState existing = existingState("CONCURRENT-CODE"); + when(registrationInviteStateRepository.findById(1L)) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(existing)); + when(registrationInviteStateRepository.saveAndFlush(any(RegistrationInviteState.class))) + .thenThrow(new DataIntegrityViolationException("duplicate key")); + + String inviteCode = registrationInviteService.getCurrentInviteCode(); + + assertThat(inviteCode).isEqualTo("CONCURRENT-CODE"); + } + + private RegistrationInviteState existingState(String inviteCode) { + RegistrationInviteState state = new RegistrationInviteState(); + state.setId(1L); + state.setInviteCode(inviteCode); + return state; + } +} diff --git a/backend/src/test/java/com/yoyuzh/common/broker/RedisLightweightBrokerServiceTest.java b/backend/src/test/java/com/yoyuzh/common/broker/RedisLightweightBrokerServiceTest.java new file mode 100644 index 0000000..2e1aed3 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/common/broker/RedisLightweightBrokerServiceTest.java @@ -0,0 +1,44 @@ +package com.yoyuzh.common.broker; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.config.AppRedisProperties; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.ListOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RedisLightweightBrokerServiceTest { + + @Test + void shouldSkipMalformedRawPayloadAndContinuePollingQueue() { + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ListOperations listOperations = mock(ListOperations.class); + when(redisTemplate.opsForList()).thenReturn(listOperations); + when(listOperations.leftPop("yoyuzh:broker:media-metadata-trigger:queue")) + .thenReturn("{bad-json") + .thenReturn("{\"userId\":7,\"fileId\":11,\"correlationId\":\"media-meta:auto:file:11\"}"); + + RedisLightweightBrokerService service = new RedisLightweightBrokerService( + redisTemplate, + new ObjectMapper(), + new AppRedisProperties() + ); + + Optional> result = service.poll("media-metadata-trigger"); + + assertThat(result).isPresent(); + assertThat(result.orElseThrow()).containsEntry("userId", 7); + assertThat(result.orElseThrow()).containsEntry("fileId", 11); + assertThat(result.orElseThrow()).containsEntry("correlationId", "media-meta:auto:file:11"); + verify(listOperations, times(2)).leftPop("yoyuzh:broker:media-metadata-trigger:queue"); + } +} diff --git a/backend/src/test/java/com/yoyuzh/config/AndroidReleaseServiceCacheTest.java b/backend/src/test/java/com/yoyuzh/config/AndroidReleaseServiceCacheTest.java new file mode 100644 index 0000000..ba946b4 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/config/AndroidReleaseServiceCacheTest.java @@ -0,0 +1,88 @@ +package com.yoyuzh.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.files.storage.FileContentStorage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(AndroidReleaseServiceCacheTest.CacheTestConfiguration.class) +class AndroidReleaseServiceCacheTest { + + @Autowired + private AndroidReleaseService androidReleaseService; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private FileContentStorage fileContentStorage; + + @BeforeEach + void setUp() { + cacheManager.getCache(RedisCacheNames.ANDROID_RELEASE).clear(); + reset(fileContentStorage); + } + + @Test + void shouldCacheLatestReleaseMetadata() { + when(fileContentStorage.readBlob("android/releases/latest.json")).thenReturn(""" + { + "objectKey": "android/releases/yoyuzh-portal-2026.04.03.1754.apk", + "fileName": "yoyuzh-portal-2026.04.03.1754.apk", + "versionCode": "260931754", + "versionName": "2026.04.03.1754", + "publishedAt": "2026-04-03T09:54:00Z" + } + """.getBytes()); + + androidReleaseService.getLatestRelease(); + androidReleaseService.getLatestRelease(); + + verify(fileContentStorage, times(1)).readBlob("android/releases/latest.json"); + } + + @Configuration + @EnableCaching + static class CacheTestConfiguration { + + @Bean + CacheManager cacheManager() { + return new ConcurrentMapCacheManager(RedisCacheNames.ANDROID_RELEASE); + } + + @Bean + FileContentStorage fileContentStorage() { + return mock(FileContentStorage.class); + } + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + @Bean + AndroidReleaseProperties androidReleaseProperties() { + return new AndroidReleaseProperties(); + } + + @Bean + AndroidReleaseService androidReleaseService(FileContentStorage fileContentStorage, + ObjectMapper objectMapper, + AndroidReleaseProperties androidReleaseProperties) { + return new AndroidReleaseService(fileContentStorage, objectMapper, androidReleaseProperties); + } + } +} diff --git a/backend/src/test/java/com/yoyuzh/config/DevH2PreinitScriptTest.java b/backend/src/test/java/com/yoyuzh/config/DevH2PreinitScriptTest.java new file mode 100644 index 0000000..ee5bc23 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/config/DevH2PreinitScriptTest.java @@ -0,0 +1,209 @@ +package com.yoyuzh.config; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.support.EncodedResource; +import org.springframework.jdbc.datasource.init.ScriptUtils; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DevH2PreinitScriptTest { + + @Test + void backfillsLegacyPortalUserColumnsBeforeHibernateUpgrade() throws Exception { + try (Connection connection = DriverManager.getConnection( + "jdbc:h2:mem:dev_h2_preinit_test;MODE=MySQL;DB_CLOSE_DELAY=-1", + "sa", + "" + )) { + try (Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE portal_user ( + id BIGINT PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + email VARCHAR(128) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + username VARCHAR(64) NOT NULL, + last_school_semester VARCHAR(32), + last_school_student_id VARCHAR(64), + active_session_id VARCHAR(64), + avatar_content_type VARCHAR(128), + avatar_storage_name VARCHAR(255), + avatar_updated_at TIMESTAMP, + bio VARCHAR(280), + desktop_active_session_id VARCHAR(64), + max_upload_size_bytes BIGINT, + mobile_active_session_id VARCHAR(64), + phone_number VARCHAR(32), + storage_quota_bytes BIGINT + ) + """); + statement.execute(""" + INSERT INTO portal_user ( + id, + created_at, + email, + password_hash, + username + ) VALUES ( + 1, + CURRENT_TIMESTAMP, + 'dev@example.com', + 'hash', + 'dev-user' + ) + """); + } + + ScriptUtils.executeSqlScript( + connection, + new EncodedResource(new ClassPathResource("dev-h2-preinit.sql")), + true, + false, + ScriptUtils.DEFAULT_COMMENT_PREFIX, + ScriptUtils.DEFAULT_STATEMENT_SEPARATOR, + ScriptUtils.DEFAULT_BLOCK_COMMENT_START_DELIMITER, + ScriptUtils.DEFAULT_BLOCK_COMMENT_END_DELIMITER + ); + + try (Statement statement = connection.createStatement(); + ResultSet columns = statement.executeQuery(""" + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'PORTAL_USER' + AND COLUMN_NAME IN ('DISPLAY_NAME', 'PREFERRED_LANGUAGE', 'ROLE', 'BANNED') + ORDER BY COLUMN_NAME + """)) { + StringBuilder actualColumns = new StringBuilder(); + while (columns.next()) { + if (!actualColumns.isEmpty()) { + actualColumns.append(","); + } + actualColumns.append(columns.getString(1)); + } + assertEquals("BANNED,DISPLAY_NAME,PREFERRED_LANGUAGE,ROLE", actualColumns.toString()); + } + + try (Statement statement = connection.createStatement(); + ResultSet user = statement.executeQuery(""" + SELECT display_name, preferred_language, role, banned + FROM portal_user + WHERE id = 1 + """)) { + user.next(); + assertEquals("dev-user", user.getString("display_name")); + assertEquals("zh-CN", user.getString("preferred_language")); + assertEquals("USER", user.getString("role")); + assertEquals(false, user.getBoolean("banned")); + } + } + } + + @Test + void backfillsLegacyPortalFileColumnsBeforeBackfillQueriesRun() throws Exception { + try (Connection connection = DriverManager.getConnection( + "jdbc:h2:mem:dev_h2_preinit_portal_file_test;MODE=MySQL;DB_CLOSE_DELAY=-1", + "sa", + "" + )) { + try (Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE portal_file ( + id BIGINT PRIMARY KEY, + content_type VARCHAR(255), + created_at TIMESTAMP NOT NULL, + is_directory BOOLEAN NOT NULL, + filename VARCHAR(255) NOT NULL, + path VARCHAR(512) NOT NULL, + size BIGINT NOT NULL, + storage_name VARCHAR(255) NOT NULL, + user_id BIGINT NOT NULL, + bucket VARCHAR(255), + etag VARCHAR(255), + object_key VARCHAR(255), + storage_provider VARCHAR(64), + deleted_at TIMESTAMP, + recycle_group_id VARCHAR(64), + recycle_original_path VARCHAR(512), + updated_at TIMESTAMP, + blob_id BIGINT, + primary_entity_id BIGINT + ) + """); + statement.execute(""" + INSERT INTO portal_file ( + id, + created_at, + is_directory, + filename, + path, + size, + storage_name, + user_id + ) VALUES ( + 1, + CURRENT_TIMESTAMP, + FALSE, + 'demo.txt', + '/', + 123, + 'legacy-key', + 1 + ) + """); + } + + executeDevPreinitScript(connection); + + try (Statement statement = connection.createStatement(); + ResultSet columns = statement.executeQuery(""" + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'PORTAL_FILE' + AND COLUMN_NAME = 'IS_RECYCLE_ROOT' + """)) { + columns.next(); + assertEquals("IS_RECYCLE_ROOT", columns.getString(1)); + } + + try (Statement statement = connection.createStatement(); + ResultSet file = statement.executeQuery(""" + SELECT is_recycle_root + FROM portal_file + WHERE id = 1 + """)) { + file.next(); + assertEquals(false, file.getBoolean("is_recycle_root")); + } + } + } + + @Test + void ignoresLegacyBackfillStatementsWhenPortalUserTableDoesNotExistYet() throws Exception { + try (Connection connection = DriverManager.getConnection( + "jdbc:h2:mem:dev_h2_preinit_missing_table_test;MODE=MySQL;DB_CLOSE_DELAY=-1", + "sa", + "" + )) { + executeDevPreinitScript(connection); + } + } + + private static void executeDevPreinitScript(Connection connection) throws Exception { + ScriptUtils.executeSqlScript( + connection, + new EncodedResource(new ClassPathResource("dev-h2-preinit.sql")), + true, + false, + ScriptUtils.DEFAULT_COMMENT_PREFIX, + ScriptUtils.DEFAULT_STATEMENT_SEPARATOR, + ScriptUtils.DEFAULT_BLOCK_COMMENT_START_DELIMITER, + ScriptUtils.DEFAULT_BLOCK_COMMENT_END_DELIMITER + ); + } +} diff --git a/backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java b/backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java index 741df3a..ed4b517 100644 --- a/backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java +++ b/backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java @@ -1,6 +1,8 @@ package com.yoyuzh.config; import com.yoyuzh.admin.AdminMetricsService; +import com.yoyuzh.auth.AuthClientType; +import com.yoyuzh.auth.AuthTokenInvalidationService; import com.yoyuzh.auth.CustomUserDetailsService; import com.yoyuzh.auth.JwtTokenProvider; import com.yoyuzh.auth.User; @@ -18,6 +20,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; +import java.time.Instant; import java.time.LocalDateTime; import java.util.List; @@ -32,6 +35,8 @@ class JwtAuthenticationFilterTest { @Mock private JwtTokenProvider jwtTokenProvider; @Mock + private AuthTokenInvalidationService authTokenInvalidationService; + @Mock private CustomUserDetailsService userDetailsService; @Mock private AdminMetricsService adminMetricsService; @@ -42,7 +47,12 @@ class JwtAuthenticationFilterTest { @BeforeEach void setUp() { - filter = new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService, adminMetricsService); + filter = new JwtAuthenticationFilter( + jwtTokenProvider, + authTokenInvalidationService, + userDetailsService, + adminMetricsService + ); SecurityContextHolder.clearContext(); } @@ -83,15 +93,37 @@ class JwtAuthenticationFilterTest { assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); } + @Test + void shouldPassThroughWhenAccessTokenWasRevokedInRedis() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer valid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + Instant issuedAt = Instant.now().minusSeconds(30); + when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true); + when(jwtTokenProvider.getUserId("valid-token")).thenReturn(1L); + when(jwtTokenProvider.getClientType("valid-token")).thenReturn(AuthClientType.DESKTOP); + when(jwtTokenProvider.getIssuedAt("valid-token")).thenReturn(issuedAt); + when(authTokenInvalidationService.isAccessTokenRevoked(1L, AuthClientType.DESKTOP, issuedAt)).thenReturn(true); + + filter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(userDetailsService, never()).loadDomainUser(any()); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + @Test void shouldPassThroughWhenUserNotFound() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader("Authorization", "Bearer valid-token"); MockHttpServletResponse response = new MockHttpServletResponse(); when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true); + when(jwtTokenProvider.getUserId("valid-token")).thenReturn(1L); + when(jwtTokenProvider.getClientType("valid-token")).thenReturn(AuthClientType.DESKTOP); + when(jwtTokenProvider.getIssuedAt("valid-token")).thenReturn(Instant.now()); when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice"); when(userDetailsService.loadDomainUser("alice")) - .thenThrow(new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在")); + .thenThrow(new BusinessException(ErrorCode.NOT_LOGGED_IN, "user not found")); filter.doFilterInternal(request, response, filterChain); @@ -106,6 +138,9 @@ class JwtAuthenticationFilterTest { MockHttpServletResponse response = new MockHttpServletResponse(); User domainUser = createDomainUser("alice", "session-1", null); when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true); + when(jwtTokenProvider.getUserId("valid-token")).thenReturn(1L); + when(jwtTokenProvider.getClientType("valid-token")).thenReturn(AuthClientType.DESKTOP); + when(jwtTokenProvider.getIssuedAt("valid-token")).thenReturn(Instant.now()); when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice"); when(userDetailsService.loadDomainUser("alice")).thenReturn(domainUser); when(jwtTokenProvider.hasMatchingSession("valid-token", domainUser)).thenReturn(false); @@ -129,6 +164,9 @@ class JwtAuthenticationFilterTest { .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER"))) .build(); when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true); + when(jwtTokenProvider.getUserId("valid-token")).thenReturn(1L); + when(jwtTokenProvider.getClientType("valid-token")).thenReturn(AuthClientType.DESKTOP); + when(jwtTokenProvider.getIssuedAt("valid-token")).thenReturn(Instant.now()); when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice"); when(userDetailsService.loadDomainUser("alice")).thenReturn(domainUser); when(jwtTokenProvider.hasMatchingSession("valid-token", domainUser)).thenReturn(true); @@ -152,6 +190,9 @@ class JwtAuthenticationFilterTest { .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER"))) .build(); when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true); + when(jwtTokenProvider.getUserId("valid-token")).thenReturn(1L); + when(jwtTokenProvider.getClientType("valid-token")).thenReturn(AuthClientType.DESKTOP); + when(jwtTokenProvider.getIssuedAt("valid-token")).thenReturn(Instant.now()); when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice"); when(userDetailsService.loadDomainUser("alice")).thenReturn(domainUser); when(jwtTokenProvider.hasMatchingSession("valid-token", domainUser)).thenReturn(true); @@ -178,7 +219,6 @@ class JwtAuthenticationFilterTest { return user; } - // Helper to avoid unused import warning on Mockito.any() private static T any() { return org.mockito.ArgumentMatchers.any(); } diff --git a/backend/src/test/java/com/yoyuzh/config/RedisConfigurationTest.java b/backend/src/test/java/com/yoyuzh/config/RedisConfigurationTest.java new file mode 100644 index 0000000..c10b203 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/config/RedisConfigurationTest.java @@ -0,0 +1,39 @@ +package com.yoyuzh.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.files.core.FileMetadataResponse; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.serializer.RedisSerializer; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class RedisConfigurationTest { + + @Test + void redisValueSerializerShouldHandleJavaTimeTypes() { + RedisSerializer serializer = RedisConfiguration.redisValueSerializer( + new ObjectMapper().findAndRegisterModules() + ); + + byte[] serialized = serializer.serialize(new TestPage( + List.of(new FileMetadataResponse(1L, "notes.txt", "/docs", 12L, "text/plain", false, + LocalDateTime.of(2026, 4, 10, 18, 30))) + )); + Object restored = serializer.deserialize(serialized); + TestPage restoredPage = new ObjectMapper() + .findAndRegisterModules() + .convertValue(restored, TestPage.class); + + assertThat(serialized).isNotNull(); + assertThat(restoredPage).isEqualTo(new TestPage( + List.of(new FileMetadataResponse(1L, "notes.txt", "/docs", 12L, "text/plain", false, + LocalDateTime.of(2026, 4, 10, 18, 30))) + )); + } + + private record TestPage(List items) { + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/core/ContentAssetBindingServiceTest.java b/backend/src/test/java/com/yoyuzh/files/core/ContentAssetBindingServiceTest.java new file mode 100644 index 0000000..3be2817 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/core/ContentAssetBindingServiceTest.java @@ -0,0 +1,89 @@ +package com.yoyuzh.files.core; + +import com.yoyuzh.auth.User; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ContentAssetBindingServiceTest { + + @Mock + private FileEntityRepository fileEntityRepository; + + @Mock + private StoredFileEntityRepository storedFileEntityRepository; + + @Test + void shouldCreateTransientPrimaryEntityWhenRepositoryIsUnavailable() { + ContentAssetBindingService service = new ContentAssetBindingService(null, null, null); + + FileEntity entity = service.createOrReferencePrimaryEntity(createUser(7L), createBlob("blobs/blob-1")); + + assertThat(entity.getObjectKey()).isEqualTo("blobs/blob-1"); + assertThat(entity.getEntityType()).isEqualTo(FileEntityType.VERSION); + assertThat(entity.getReferenceCount()).isEqualTo(1); + assertThat(entity.getStoragePolicyId()).isNull(); + } + + @Test + void shouldIncreaseReferenceCountWhenEntityAlreadyExists() { + ContentAssetBindingService service = new ContentAssetBindingService(fileEntityRepository, null, null); + FileEntity existing = new FileEntity(); + existing.setObjectKey("blobs/blob-1"); + existing.setEntityType(FileEntityType.VERSION); + existing.setReferenceCount(2); + when(fileEntityRepository.findByObjectKeyAndEntityType("blobs/blob-1", FileEntityType.VERSION)) + .thenReturn(Optional.of(existing)); + when(fileEntityRepository.save(existing)).thenReturn(existing); + + FileEntity entity = service.createOrReferencePrimaryEntity(createUser(7L), createBlob("blobs/blob-1")); + + assertThat(entity.getReferenceCount()).isEqualTo(3); + verify(fileEntityRepository).save(existing); + } + + @Test + void shouldSavePrimaryEntityRelationWhenRepositoryAvailable() { + ContentAssetBindingService service = new ContentAssetBindingService(null, storedFileEntityRepository, null); + StoredFile storedFile = new StoredFile(); + storedFile.setId(10L); + FileEntity fileEntity = new FileEntity(); + fileEntity.setId(20L); + + service.savePrimaryEntityRelation(storedFile, fileEntity); + + ArgumentCaptor captor = ArgumentCaptor.forClass(StoredFileEntity.class); + verify(storedFileEntityRepository).save(captor.capture()); + assertThat(captor.getValue().getStoredFile().getId()).isEqualTo(10L); + assertThat(captor.getValue().getFileEntity().getId()).isEqualTo(20L); + assertThat(captor.getValue().getEntityRole()).isEqualTo("PRIMARY"); + } + + private User createUser(Long id) { + User user = new User(); + user.setId(id); + user.setUsername("user-" + id); + user.setEmail("user-" + id + "@example.com"); + user.setPasswordHash("encoded"); + user.setCreatedAt(LocalDateTime.now()); + return user; + } + + private FileBlob createBlob(String objectKey) { + FileBlob blob = new FileBlob(); + blob.setObjectKey(objectKey); + blob.setContentType("text/plain"); + blob.setSize(5L); + return blob; + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/core/ContentBlobLifecycleServiceTest.java b/backend/src/test/java/com/yoyuzh/files/core/ContentBlobLifecycleServiceTest.java new file mode 100644 index 0000000..2cd9b23 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/core/ContentBlobLifecycleServiceTest.java @@ -0,0 +1,172 @@ +package com.yoyuzh.files.core; + +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.files.storage.FileContentStorage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ContentBlobLifecycleServiceTest { + + @Mock + private StoredFileRepository storedFileRepository; + + @Mock + private FileBlobRepository fileBlobRepository; + + @Mock + private FileContentStorage fileContentStorage; + + @Test + void shouldCreateAndSaveBlob() { + ContentBlobLifecycleService service = createService(); + when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + FileBlob saved = service.createAndSaveBlob("blobs/blob-1", "text/plain", 5L); + + assertThat(saved.getObjectKey()).isEqualTo("blobs/blob-1"); + assertThat(saved.getContentType()).isEqualTo("text/plain"); + assertThat(saved.getSize()).isEqualTo(5L); + verify(fileBlobRepository).save(any(FileBlob.class)); + } + + @Test + void shouldDeleteBlobWhenOperationFailsAfterWrite() { + ContentBlobLifecycleService service = createService(); + + assertThatThrownBy(() -> service.executeAfterBlobStored("blobs/blob-1", () -> { + throw new IllegalStateException("write-failed"); + })).isInstanceOf(IllegalStateException.class) + .hasMessage("write-failed"); + + verify(fileContentStorage).deleteBlob("blobs/blob-1"); + } + + @Test + void shouldKeepCleanupFailuresAsSuppressedException() { + ContentBlobLifecycleService service = createService(); + doThrow(new IllegalStateException("cleanup-failed")) + .when(fileContentStorage).deleteBlob("blobs/blob-1"); + + Throwable thrown = null; + try { + service.executeAfterBlobStored("blobs/blob-1", () -> { + throw new IllegalStateException("write-failed"); + }); + } catch (Throwable ex) { + thrown = ex; + } + + assertThat(thrown).isInstanceOf(IllegalStateException.class) + .hasMessage("write-failed"); + assertThat(thrown).isNotNull(); + assertThat(thrown.getSuppressed()).hasSize(1); + assertThat(thrown.getSuppressed()[0]) + .isInstanceOf(IllegalStateException.class) + .hasMessage("cleanup-failed"); + } + + @Test + void shouldCollectOnlyUnreferencedBlobsAfterDeletion() { + ContentBlobLifecycleService service = createService(); + FileBlob onlyReferencedByDeletedFiles = createBlob(10L, "blobs/blob-10"); + FileBlob stillReferencedElsewhere = createBlob(20L, "blobs/blob-20"); + StoredFile fileA = createStoredFile(false, onlyReferencedByDeletedFiles); + StoredFile fileB = createStoredFile(false, onlyReferencedByDeletedFiles); + StoredFile fileC = createStoredFile(false, stillReferencedElsewhere); + + when(storedFileRepository.countByBlobId(10L)).thenReturn(2L); + when(storedFileRepository.countByBlobId(20L)).thenReturn(3L); + + List blobsToDelete = service.collectBlobsToDelete(List.of(fileA, fileB, fileC)); + + assertThat(blobsToDelete).containsExactly(onlyReferencedByDeletedFiles); + } + + @Test + void shouldDeleteBlobObjectAndMetadata() { + ContentBlobLifecycleService service = createService(); + FileBlob blob = createBlob(10L, "blobs/blob-10"); + + service.deleteBlobs(List.of(blob)); + + verify(fileContentStorage).deleteBlob("blobs/blob-10"); + verify(fileBlobRepository).delete(blob); + } + + @Test + void shouldReturnRequiredBlobForRegularFile() { + ContentBlobLifecycleService service = createService(); + FileBlob blob = createBlob(10L, "blobs/blob-10"); + StoredFile storedFile = createStoredFile(false, blob); + + FileBlob resolved = service.getRequiredBlob(storedFile); + + assertThat(resolved).isSameAs(blob); + } + + @Test + void shouldRejectMissingBlobForDirectoryOrDetachedFile() { + ContentBlobLifecycleService service = createService(); + StoredFile directory = createStoredFile(true, null); + StoredFile detachedFile = createStoredFile(false, null); + + assertThatThrownBy(() -> service.getRequiredBlob(directory)).isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.getRequiredBlob(detachedFile)).isInstanceOf(BusinessException.class); + } + + @Test + void shouldCleanupWrittenBlobListOnFailure() { + ContentBlobLifecycleService service = createService(); + RuntimeException operationFailure = new RuntimeException("import-failed"); + doAnswer(invocation -> { + String objectKey = invocation.getArgument(0); + if ("blobs/blob-2".equals(objectKey)) { + throw new IllegalStateException("cleanup-failed"); + } + return null; + }).when(fileContentStorage).deleteBlob(anyString()); + + service.cleanupWrittenBlobs(List.of("blobs/blob-1", "blobs/blob-2"), operationFailure); + + verify(fileContentStorage).deleteBlob("blobs/blob-1"); + verify(fileContentStorage).deleteBlob("blobs/blob-2"); + assertThat(operationFailure.getSuppressed()).hasSize(1); + assertThat(operationFailure.getSuppressed()[0]) + .isInstanceOf(IllegalStateException.class) + .hasMessage("cleanup-failed"); + } + + private ContentBlobLifecycleService createService() { + return new ContentBlobLifecycleService(storedFileRepository, fileBlobRepository, fileContentStorage); + } + + private StoredFile createStoredFile(boolean directory, FileBlob blob) { + StoredFile storedFile = new StoredFile(); + storedFile.setDirectory(directory); + storedFile.setBlob(blob); + return storedFile; + } + + private FileBlob createBlob(Long id, String objectKey) { + FileBlob blob = new FileBlob(); + blob.setId(id); + blob.setObjectKey(objectKey); + blob.setContentType("text/plain"); + blob.setSize(1L); + return blob; + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/core/ExternalImportRulesServiceTest.java b/backend/src/test/java/com/yoyuzh/files/core/ExternalImportRulesServiceTest.java new file mode 100644 index 0000000..50dc458 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/core/ExternalImportRulesServiceTest.java @@ -0,0 +1,95 @@ +package com.yoyuzh.files.core; + +import com.yoyuzh.auth.User; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.files.storage.FileContentStorage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ExternalImportRulesServiceTest { + + @Mock + private StoredFileRepository storedFileRepository; + + @Mock + private FileContentStorage fileContentStorage; + + @Test + void shouldNormalizeDirectoriesAndFiles() { + ExternalImportRulesService service = createService(10_000L); + + List directories = service.normalizeDirectories(List.of("docs//a/", "/docs", "docs/a")); + List files = service.normalizeFiles(List.of( + new FileService.ExternalFileImport("docs/a", "x.txt", null, null) + )); + + assertThat(directories).containsExactly("/docs", "/docs/a"); + assertThat(files).hasSize(1); + assertThat(files.get(0).path()).isEqualTo("/docs/a"); + assertThat(files.get(0).filename()).isEqualTo("x.txt"); + assertThat(files.get(0).contentType()).isEqualTo("application/octet-stream"); + assertThat(files.get(0).content()).isEmpty(); + } + + @Test + void shouldRejectDuplicatePlannedTargetsInsideBatch() { + ExternalImportRulesService service = createService(10_000L); + User user = createUser(7L, 10_000L, 10_000L); + when(storedFileRepository.sumFileSizeByUserId(7L)).thenReturn(0L); + + assertThatThrownBy(() -> service.validateBatch( + user, + List.of("/docs/a"), + List.of(new FileService.ExternalFileImport("/docs", "a", "text/plain", new byte[]{1})) + )).isInstanceOf(BusinessException.class); + } + + @Test + void shouldAcceptBatchWhenNoQuotaAndTargetConflicts() { + ExternalImportRulesService service = createService(10_000L); + User user = createUser(7L, 10_000L, 10_000L); + when(storedFileRepository.sumFileSizeByUserId(7L)).thenReturn(100L); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "docs")).thenReturn(false); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "a.txt")).thenReturn(false); + + assertThatCode(() -> service.validateBatch( + user, + List.of("/docs"), + List.of(new FileService.ExternalFileImport("/docs", "a.txt", "text/plain", new byte[]{1, 2, 3})) + )).doesNotThrowAnyException(); + } + + private ExternalImportRulesService createService(long maxFileSize) { + WorkspaceNodeRulesService workspaceNodeRulesService = new WorkspaceNodeRulesService(storedFileRepository, fileContentStorage); + FileUploadRulesService fileUploadRulesService = new FileUploadRulesService( + storedFileRepository, + null, + workspaceNodeRulesService, + maxFileSize + ); + return new ExternalImportRulesService(workspaceNodeRulesService, fileUploadRulesService); + } + + private User createUser(Long id, Long storageQuotaBytes, Long maxUploadSizeBytes) { + User user = new User(); + user.setId(id); + user.setUsername("user-" + id); + user.setEmail("user-" + id + "@example.com"); + user.setPasswordHash("encoded"); + user.setCreatedAt(LocalDateTime.now()); + user.setStorageQuotaBytes(storageQuotaBytes); + user.setMaxUploadSizeBytes(maxUploadSizeBytes); + return user; + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/core/FileServiceMkdirStorageNameTest.java b/backend/src/test/java/com/yoyuzh/files/core/FileServiceMkdirStorageNameTest.java new file mode 100644 index 0000000..95870cc --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/core/FileServiceMkdirStorageNameTest.java @@ -0,0 +1,78 @@ +package com.yoyuzh.files.core; + +import com.yoyuzh.admin.AdminMetricsService; +import com.yoyuzh.auth.User; +import com.yoyuzh.config.FileStorageProperties; +import com.yoyuzh.files.share.FileShareLinkRepository; +import com.yoyuzh.files.storage.FileContentStorage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FileServiceMkdirStorageNameTest { + + @Mock + private StoredFileRepository storedFileRepository; + @Mock + private FileBlobRepository fileBlobRepository; + @Mock + private FileContentStorage fileContentStorage; + @Mock + private FileShareLinkRepository fileShareLinkRepository; + @Mock + private AdminMetricsService adminMetricsService; + + private FileService fileService; + + @BeforeEach + void setUp() { + FileStorageProperties properties = new FileStorageProperties(); + properties.setMaxFileSize(500L * 1024 * 1024); + fileService = new FileService( + storedFileRepository, + fileBlobRepository, + fileContentStorage, + fileShareLinkRepository, + adminMetricsService, + properties + ); + } + + @Test + void shouldPersistDirectoryStorageNameWhenCreatingDirectory() { + User user = createUser(1L); + when(storedFileRepository.existsByUserIdAndPathAndFilename(1L, "/", "docs")).thenReturn(false); + when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> { + StoredFile storedFile = invocation.getArgument(0); + storedFile.setId(10L); + return storedFile; + }); + + fileService.mkdir(user, "/docs"); + + ArgumentCaptor storedFileCaptor = ArgumentCaptor.forClass(StoredFile.class); + verify(storedFileRepository).save(storedFileCaptor.capture()); + assertThat(storedFileCaptor.getValue().getLegacyStorageName()).isEqualTo("docs"); + } + + private User createUser(Long id) { + User user = new User(); + user.setId(id); + user.setUsername("user-" + id); + user.setEmail("user-" + id + "@example.com"); + user.setPasswordHash("encoded"); + user.setCreatedAt(LocalDateTime.now()); + return user; + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/core/FileServiceTest.java b/backend/src/test/java/com/yoyuzh/files/core/FileServiceTest.java index 3c7f92f..7aed18b 100644 --- a/backend/src/test/java/com/yoyuzh/files/core/FileServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/files/core/FileServiceTest.java @@ -11,6 +11,7 @@ import com.yoyuzh.files.policy.StoragePolicyType; import com.yoyuzh.files.share.CreateFileShareLinkResponse; import com.yoyuzh.files.share.FileShareLink; import com.yoyuzh.files.share.FileShareLinkRepository; +import com.yoyuzh.files.tasks.MediaMetadataTaskBrokerPublisher; import com.yoyuzh.files.upload.CompleteUploadRequest; import com.yoyuzh.files.upload.InitiateUploadRequest; import com.yoyuzh.files.upload.InitiateUploadResponse; @@ -19,6 +20,7 @@ import com.yoyuzh.files.storage.PreparedUpload; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageImpl; @@ -27,6 +29,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -57,6 +60,8 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; @ExtendWith(MockitoExtension.class) class FileServiceTest { @@ -80,6 +85,8 @@ class FileServiceTest { private AdminMetricsService adminMetricsService; @Mock private StoragePolicyService storagePolicyService; + @Mock + private MediaMetadataTaskBrokerPublisher mediaMetadataTaskBrokerPublisher; private FileService fileService; @@ -126,6 +133,33 @@ class FileServiceTest { && "text/plain".equals(blob.getContentType()))); } + @Test + void shouldPublishMediaMetadataTriggerWhenSavingImageFile() { + ReflectionTestUtils.setField(fileService, "mediaMetadataTaskBrokerPublisher", mediaMetadataTaskBrokerPublisher); + User user = createUser(7L); + MockMultipartFile multipartFile = new MockMultipartFile( + "file", "photo.png", "image/png", "hello".getBytes()); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "photo.png")).thenReturn(false); + when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> { + FileBlob blob = invocation.getArgument(0); + blob.setId(100L); + return blob; + }); + when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> { + StoredFile file = invocation.getArgument(0); + file.setId(10L); + return file; + }); + + fileService.upload(user, "/docs", multipartFile); + + verify(mediaMetadataTaskBrokerPublisher).publishAfterCommit(org.mockito.ArgumentMatchers.argThat(file -> + file != null + && file.getId().equals(10L) + && "image/png".equals(file.getContentType()) + && "photo.png".equals(file.getFilename()))); + } + @Test void shouldAttachPrimaryEntityWhenUploadingFile() { User user = createUser(7L); @@ -611,14 +645,19 @@ class FileServiceTest { void shouldListFilesByPathWithPagination() { User user = createUser(7L); StoredFile file = createFile(100L, user, "/docs", "notes.txt"); + FileListDirectoryCacheService cacheService = org.mockito.Mockito.mock(FileListDirectoryCacheService.class); + ReflectionTestUtils.setField(fileService, "fileListDirectoryCacheService", cacheService); when(storedFileRepository.findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc( 7L, "/docs", PageRequest.of(0, 10))) .thenReturn(new PageImpl<>(List.of(file))); + when(cacheService.getOrLoad(eq(7L), eq("/docs"), eq(0), eq(10), any())) + .thenAnswer(invocation -> invocation.>>getArgument(4).get()); var result = fileService.list(user, "/docs", 0, 10); assertThat(result.items()).hasSize(1); assertThat(result.items().get(0).filename()).isEqualTo("notes.txt"); + verify(cacheService).getOrLoad(eq(7L), eq("/docs"), eq(0), eq(10), any()); } @Test @@ -651,6 +690,21 @@ class FileServiceTest { assertThat(response.url()).isEqualTo("https://download.example.com/file"); } + @Test + void shouldPersistLegacyStorageNameWhenCreatingDefaultDirectories() { + User user = createUser(7L); + when(storedFileRepository.existsByUserIdAndPathAndFilename(anyLong(), anyString(), anyString())).thenReturn(false); + when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + fileService.ensureDefaultDirectories(user); + + ArgumentCaptor captor = ArgumentCaptor.forClass(StoredFile.class); + verify(storedFileRepository, times(3)).save(captor.capture()); + assertThat(captor.getAllValues()) + .extracting(StoredFile::getLegacyStorageName) + .doesNotContainNull(); + } + @Test void shouldUseDlUrlForPrivateApkWhenConfigured() { FileStorageProperties properties = new FileStorageProperties(); diff --git a/backend/src/test/java/com/yoyuzh/files/core/FileServiceUploadStorageNameTest.java b/backend/src/test/java/com/yoyuzh/files/core/FileServiceUploadStorageNameTest.java new file mode 100644 index 0000000..565434e --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/core/FileServiceUploadStorageNameTest.java @@ -0,0 +1,88 @@ +package com.yoyuzh.files.core; + +import com.yoyuzh.admin.AdminMetricsService; +import com.yoyuzh.auth.User; +import com.yoyuzh.config.FileStorageProperties; +import com.yoyuzh.files.share.FileShareLinkRepository; +import com.yoyuzh.files.storage.FileContentStorage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FileServiceUploadStorageNameTest { + + @Mock + private StoredFileRepository storedFileRepository; + @Mock + private FileBlobRepository fileBlobRepository; + @Mock + private FileContentStorage fileContentStorage; + @Mock + private FileShareLinkRepository fileShareLinkRepository; + @Mock + private AdminMetricsService adminMetricsService; + + private FileService fileService; + + @BeforeEach + void setUp() { + FileStorageProperties properties = new FileStorageProperties(); + properties.setMaxFileSize(500L * 1024 * 1024); + fileService = new FileService( + storedFileRepository, + fileBlobRepository, + fileContentStorage, + fileShareLinkRepository, + adminMetricsService, + properties + ); + } + + @Test + void shouldPersistUploadedFileStorageName() { + User user = createUser(1L); + MockMultipartFile multipartFile = new MockMultipartFile( + "file", "notes.txt", "text/plain", "hello".getBytes() + ); + when(storedFileRepository.existsByUserIdAndPathAndFilename(1L, "/", "notes.txt")).thenReturn(false); + when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> { + FileBlob blob = invocation.getArgument(0); + blob.setId(100L); + return blob; + }); + when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> { + StoredFile storedFile = invocation.getArgument(0); + storedFile.setId(10L); + return storedFile; + }); + + fileService.upload(user, "/", multipartFile); + + ArgumentCaptor storedFileCaptor = ArgumentCaptor.forClass(StoredFile.class); + verify(storedFileRepository).save(storedFileCaptor.capture()); + StoredFile savedFile = storedFileCaptor.getValue(); + assertThat(savedFile.getLegacyStorageName()).startsWith("blobs/"); + } + + private User createUser(Long id) { + User user = new User(); + user.setId(id); + user.setUsername("user-" + id); + user.setEmail("user-" + id + "@example.com"); + user.setPasswordHash("encoded"); + user.setCreatedAt(LocalDateTime.now()); + return user; + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/core/FileShareControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/files/core/FileShareControllerIntegrationTest.java index 9a43d37..815c0d5 100644 --- a/backend/src/test/java/com/yoyuzh/files/core/FileShareControllerIntegrationTest.java +++ b/backend/src/test/java/com/yoyuzh/files/core/FileShareControllerIntegrationTest.java @@ -180,6 +180,89 @@ class FileShareControllerIntegrationTest { } } + @Test + void shouldRejectLegacyCreateShareForDirectoryUsingUnifiedShareRules() throws Exception { + User owner = userRepository.findByUsername("alice").orElseThrow(); + StoredFile directory = new StoredFile(); + directory.setUser(owner); + directory.setFilename("docs"); + directory.setPath("/"); + directory.setContentType("directory"); + directory.setSize(0L); + directory.setDirectory(true); + Long directoryId = storedFileRepository.save(directory).getId(); + + mockMvc.perform(post("/api/files/{fileId}/share-links", directoryId) + .with(user("alice"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1000)); + } + + @Test + void shouldRejectPasswordProtectedV2ShareOnLegacyEndpoints() throws Exception { + String response = mockMvc.perform(post("/api/v2/shares") + .with(user("alice")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "fileId": %d, + "password": "Share123", + "allowImport": true, + "allowDownload": true + } + """.formatted(sharedFileId))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + String token = com.jayway.jsonpath.JsonPath.read(response, "$.data.token"); + + mockMvc.perform(get("/api/files/share-links/{token}", token)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(1002)); + + mockMvc.perform(post("/api/files/share-links/{token}/import", token) + .with(user("bob")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "path": "/涓嬭浇" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1000)); + } + + @Test + void shouldRejectLegacyImportWhenV2ShareImportDisabled() throws Exception { + String response = mockMvc.perform(post("/api/v2/shares") + .with(user("alice")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "fileId": %d, + "allowImport": false, + "allowDownload": true + } + """.formatted(sharedFileId))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + String token = com.jayway.jsonpath.JsonPath.read(response, "$.data.token"); + + mockMvc.perform(post("/api/files/share-links/{token}/import", token) + .with(user("bob")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "path": "/涓嬭浇" + } + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(1002)); + } + @Test void shouldMoveFileIntoAnotherDirectoryThroughApi() throws Exception { User owner = userRepository.findByUsername("alice").orElseThrow(); diff --git a/backend/src/test/java/com/yoyuzh/files/core/FileUploadRulesServiceTest.java b/backend/src/test/java/com/yoyuzh/files/core/FileUploadRulesServiceTest.java new file mode 100644 index 0000000..8e454cf --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/core/FileUploadRulesServiceTest.java @@ -0,0 +1,97 @@ +package com.yoyuzh.files.core; + +import com.yoyuzh.auth.User; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.files.policy.StoragePolicy; +import com.yoyuzh.files.policy.StoragePolicyCapabilities; +import com.yoyuzh.files.policy.StoragePolicyService; +import com.yoyuzh.files.storage.FileContentStorage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FileUploadRulesServiceTest { + + @Mock + private StoredFileRepository storedFileRepository; + + @Mock + private StoragePolicyService storagePolicyService; + + @Mock + private FileContentStorage fileContentStorage; + + @Test + void shouldRejectUploadWhenExceedingEffectiveMaxSize() { + FileUploadRulesService service = new FileUploadRulesService( + storedFileRepository, + storagePolicyService, + new WorkspaceNodeRulesService(storedFileRepository, fileContentStorage), + 2_000L + ); + User user = createUser(7L, 5_000L, 1_500L); + StoragePolicy defaultPolicy = new StoragePolicy(); + defaultPolicy.setMaxSizeBytes(1_200L); + StoragePolicyCapabilities capabilities = new StoragePolicyCapabilities( + true, true, true, true, false, true, true, false, 900L + ); + when(storagePolicyService.ensureDefaultPolicy()).thenReturn(defaultPolicy); + when(storagePolicyService.readCapabilities(defaultPolicy)).thenReturn(capabilities); + + assertThatThrownBy(() -> service.validateUpload(user, "/docs", "a.txt", 901L)) + .isInstanceOf(BusinessException.class); + } + + @Test + void shouldRejectWhenStorageQuotaExceeded() { + FileUploadRulesService service = new FileUploadRulesService( + storedFileRepository, + null, + new WorkspaceNodeRulesService(storedFileRepository, fileContentStorage), + 2_000L + ); + User user = createUser(7L, 1_000L, 2_000L); + when(storedFileRepository.sumFileSizeByUserId(7L)).thenReturn(990L); + + assertThatThrownBy(() -> service.ensureWithinStorageQuota(user, 20L)) + .isInstanceOf(BusinessException.class); + } + + @Test + void shouldValidateUploadWhenWithinLimitsAndNoConflict() { + FileUploadRulesService service = new FileUploadRulesService( + storedFileRepository, + null, + new WorkspaceNodeRulesService(storedFileRepository, fileContentStorage), + 2_000L + ); + User user = createUser(7L, 10_000L, 2_000L); + when(storedFileRepository.sumFileSizeByUserId(7L)).thenReturn(500L); + + assertThatCode(() -> service.validateUpload(user, "/docs", "a.txt", 200L)) + .doesNotThrowAnyException(); + + verify(storedFileRepository).existsByUserIdAndPathAndFilename(7L, "/docs", "a.txt"); + } + + private User createUser(Long id, Long storageQuotaBytes, Long maxUploadSizeBytes) { + User user = new User(); + user.setId(id); + user.setUsername("user-" + id); + user.setEmail("user-" + id + "@example.com"); + user.setPasswordHash("encoded"); + user.setCreatedAt(LocalDateTime.now()); + user.setStorageQuotaBytes(storageQuotaBytes); + user.setMaxUploadSizeBytes(maxUploadSizeBytes); + return user; + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/core/RedisFileListDirectoryCacheServiceTest.java b/backend/src/test/java/com/yoyuzh/files/core/RedisFileListDirectoryCacheServiceTest.java new file mode 100644 index 0000000..5a54e60 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/core/RedisFileListDirectoryCacheServiceTest.java @@ -0,0 +1,76 @@ +package com.yoyuzh.files.core; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.common.PageResponse; +import com.yoyuzh.config.AppRedisProperties; +import com.yoyuzh.config.RedisCacheNames; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class RedisFileListDirectoryCacheServiceTest { + + private ConcurrentMapCacheManager cacheManager; + private StringRedisTemplate stringRedisTemplate; + private RedisFileListDirectoryCacheService cacheService; + + @BeforeEach + void setUp() { + cacheManager = new ConcurrentMapCacheManager(RedisCacheNames.FILES_LIST); + stringRedisTemplate = mock(StringRedisTemplate.class); + when(stringRedisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class)); + when(stringRedisTemplate.opsForValue().get(anyString())).thenReturn(null); + + AppRedisProperties redisProperties = new AppRedisProperties(); + cacheService = new RedisFileListDirectoryCacheService( + cacheManager, + stringRedisTemplate, + redisProperties, + new ObjectMapper().findAndRegisterModules() + ); + } + + @Test + void shouldReadCachedPageStoredAsGenericMap() { + Cache cache = cacheManager.getCache(RedisCacheNames.FILES_LIST); + cache.put("u:7:path:Lw:page:0:size:10:sort:directory-desc-created-desc:v:0", Map.of( + "items", List.of(Map.of( + "id", 1L, + "filename", "notes.txt", + "path", "/docs", + "size", 12L, + "contentType", "text/plain", + "directory", false, + "createdAt", List.of(2026, 4, 10, 18, 30) + )), + "total", 1L, + "page", 0, + "size", 10 + )); + + PageResponse result = cacheService.getOrLoad(7L, "/", 0, 10, + () -> new PageResponse<>(List.of(), 0, 0, 10)); + + assertThat(result.total()).isEqualTo(1L); + assertThat(result.items()).containsExactly(new FileMetadataResponse( + 1L, + "notes.txt", + "/docs", + 12L, + "text/plain", + false, + LocalDateTime.of(2026, 4, 10, 18, 30) + )); + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/core/WorkspaceNodeRulesServiceTest.java b/backend/src/test/java/com/yoyuzh/files/core/WorkspaceNodeRulesServiceTest.java new file mode 100644 index 0000000..e989a46 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/core/WorkspaceNodeRulesServiceTest.java @@ -0,0 +1,121 @@ +package com.yoyuzh.files.core; + +import com.yoyuzh.auth.User; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.files.storage.FileContentStorage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WorkspaceNodeRulesServiceTest { + + @Mock + private StoredFileRepository storedFileRepository; + + @Mock + private FileContentStorage fileContentStorage; + + @Test + void shouldNormalizeDirectoryPath() { + WorkspaceNodeRulesService rulesService = new WorkspaceNodeRulesService(storedFileRepository, fileContentStorage); + + String normalized = rulesService.normalizeDirectoryPath("docs//images/"); + + assertThat(normalized).isEqualTo("/docs/images"); + } + + @Test + void shouldRejectPathTraversalDirectoryPath() { + WorkspaceNodeRulesService rulesService = new WorkspaceNodeRulesService(storedFileRepository, fileContentStorage); + + assertThatThrownBy(() -> rulesService.normalizeDirectoryPath("../docs")) + .isInstanceOf(BusinessException.class); + } + + @Test + void shouldCreateMissingDirectoryHierarchy() { + WorkspaceNodeRulesService rulesService = new WorkspaceNodeRulesService(storedFileRepository, fileContentStorage); + User user = createUser(7L); + when(storedFileRepository.findByUserIdAndPathAndFilename(eq(7L), any(), any())).thenReturn(Optional.empty()); + when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + rulesService.ensureDirectoryHierarchy(user, "/projects/site"); + + verify(fileContentStorage).ensureDirectory(7L, "/projects"); + verify(fileContentStorage).ensureDirectory(7L, "/projects/site"); + verify(storedFileRepository, times(2)).save(any(StoredFile.class)); + } + + @Test + void shouldRejectExistingPathWhenEntryIsFile() { + WorkspaceNodeRulesService rulesService = new WorkspaceNodeRulesService(storedFileRepository, fileContentStorage); + StoredFile file = createFile(11L, 7L, "/", "projects"); + when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "projects")) + .thenReturn(Optional.of(file)); + + assertThatThrownBy(() -> rulesService.ensureExistingDirectoryPath(7L, "/projects")) + .isInstanceOf(BusinessException.class); + } + + @Test + void shouldRejectUnavailableNodeName() { + WorkspaceNodeRulesService rulesService = new WorkspaceNodeRulesService(storedFileRepository, fileContentStorage); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(true); + + assertThatThrownBy(() -> rulesService.ensureNodeNameAvailable(7L, "/docs", "notes.txt", "冲突")) + .isInstanceOf(BusinessException.class) + .hasMessage("冲突"); + } + + @Test + void shouldRejectRecycleRestoreWhenTargetAlreadyExists() { + WorkspaceNodeRulesService rulesService = new WorkspaceNodeRulesService(storedFileRepository, fileContentStorage); + StoredFile recycledFile = new StoredFile(); + recycledFile.setFilename("notes.txt"); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(true); + + assertThatThrownBy(() -> rulesService.validateRecycleRestoreTargets( + 7L, + List.of(recycledFile), + ignored -> "/docs" + )).isInstanceOf(BusinessException.class); + } + + private User createUser(Long id) { + User user = new User(); + user.setId(id); + user.setUsername("user-" + id); + user.setEmail("user-" + id + "@example.com"); + user.setPasswordHash("encoded"); + user.setCreatedAt(LocalDateTime.now()); + return user; + } + + private StoredFile createFile(Long id, Long userId, String path, String filename) { + User user = createUser(userId); + StoredFile file = new StoredFile(); + file.setId(id); + file.setUser(user); + file.setFilename(filename); + file.setPath(path); + file.setSize(5L); + file.setDirectory(false); + file.setContentType("text/plain"); + file.setCreatedAt(LocalDateTime.now()); + return file; + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/events/FileEventServiceTest.java b/backend/src/test/java/com/yoyuzh/files/events/FileEventServiceTest.java new file mode 100644 index 0000000..0556df1 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/events/FileEventServiceTest.java @@ -0,0 +1,70 @@ +package com.yoyuzh.files.events; + +import com.yoyuzh.auth.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FileEventServiceTest { + + @Mock + private FileEventRepository fileEventRepository; + + @Mock + private FileEventPayloadCodec payloadCodec; + + @Mock + private FileEventDispatcher fileEventDispatcher; + + @Mock + private FileEventCrossInstancePublisher fileEventCrossInstancePublisher; + + private FileEventService fileEventService; + + @BeforeEach + void setUp() { + fileEventService = new FileEventService( + fileEventRepository, + payloadCodec, + fileEventDispatcher, + fileEventCrossInstancePublisher + ); + } + + @Test + void shouldPublishRecordedEventToCrossInstanceChannel() throws Exception { + when(payloadCodec.toJson(any(Map.class))).thenReturn("{\"action\":\"RENAMED\"}"); + when(fileEventRepository.save(any(FileEvent.class))).thenAnswer(invocation -> { + FileEvent event = invocation.getArgument(0); + event.setId(10L); + event.setCreatedAt(LocalDateTime.now()); + return event; + }); + User user = new User(); + user.setId(7L); + + fileEventService.record( + user, + FileEventType.RENAMED, + 10L, + "/docs/old.txt", + "/docs/new.txt", + "tab-1", + Map.of("action", "RENAMED") + ); + + verify(fileEventDispatcher).broadcast(any(FileEvent.class)); + verify(fileEventCrossInstancePublisher).publish(any(FileEvent.class)); + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/events/RedisFileEventPubSubListenerTest.java b/backend/src/test/java/com/yoyuzh/files/events/RedisFileEventPubSubListenerTest.java new file mode 100644 index 0000000..9b3ed55 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/events/RedisFileEventPubSubListenerTest.java @@ -0,0 +1,107 @@ +package com.yoyuzh.files.events; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.config.AppRedisProperties; +import org.springframework.data.redis.connection.DefaultMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.connection.Message; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RedisFileEventPubSubListenerTest { + + @Mock + private ObjectMapper objectMapper; + + @Mock + private FileEventService fileEventService; + + private RedisFileEventPubSubListener listener; + + @BeforeEach + void setUp() { + listener = new RedisFileEventPubSubListener( + objectMapper, + createRedisProperties(), + fileEventService, + "instance-a" + ); + } + + @Test + void shouldIgnoreEventPublishedBySameInstance() throws Exception { + when(objectMapper.readValue(any(String.class), eq(FileEventPubSubMessage.class))) + .thenReturn(new FileEventPubSubMessage( + "instance-a", + 10L, + 7L, + FileEventType.RENAMED, + 11L, + "/docs/old.txt", + "/docs/new.txt", + "tab-1", + "{\"action\":\"RENAMED\"}", + LocalDateTime.now() + )); + + listener.onMessage(createMessage("{\"ok\":true}"), null); + + verify(fileEventService, never()).broadcastReplicatedEvent(any(FileEvent.class)); + } + + @Test + void shouldForwardRemoteEventToLocalSubscribers() throws Exception { + when(objectMapper.readValue(any(String.class), eq(FileEventPubSubMessage.class))) + .thenReturn(new FileEventPubSubMessage( + "instance-b", + 10L, + 7L, + FileEventType.RENAMED, + 11L, + "/docs/old.txt", + "/docs/new.txt", + "tab-1", + "{\"action\":\"RENAMED\"}", + LocalDateTime.now() + )); + + listener.onMessage(createMessage("{\"ok\":true}"), null); + + verify(fileEventService).broadcastReplicatedEvent(any(FileEvent.class)); + } + + @Test + void shouldDropMalformedPayloadWithoutBreakingListener() throws Exception { + doThrow(new com.fasterxml.jackson.core.JsonParseException(null, "bad json")) + .when(objectMapper) + .readValue(any(String.class), eq(FileEventPubSubMessage.class)); + + listener.onMessage(createMessage("{bad-json"), null); + + verify(fileEventService, never()).broadcastReplicatedEvent(any(FileEvent.class)); + } + + private Message createMessage(String payload) { + return new DefaultMessage(payload.getBytes(StandardCharsets.UTF_8), "file-events".getBytes(StandardCharsets.UTF_8)); + } + + private AppRedisProperties createRedisProperties() { + AppRedisProperties properties = new AppRedisProperties(); + properties.setKeyPrefix("yoyuzh"); + properties.getNamespaces().setFileEvents("file-events"); + return properties; + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/events/RedisFileEventPubSubPublisherTest.java b/backend/src/test/java/com/yoyuzh/files/events/RedisFileEventPubSubPublisherTest.java new file mode 100644 index 0000000..cb95100 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/events/RedisFileEventPubSubPublisherTest.java @@ -0,0 +1,70 @@ +package com.yoyuzh.files.events; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.config.AppRedisProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RedisFileEventPubSubPublisherTest { + + @Mock + private StringRedisTemplate stringRedisTemplate; + + @Mock + private ObjectMapper objectMapper; + + private RedisFileEventPubSubPublisher publisher; + + @BeforeEach + void setUp() { + publisher = new RedisFileEventPubSubPublisher( + stringRedisTemplate, + objectMapper, + createRedisProperties(), + "instance-a" + ); + } + + @Test + void shouldPublishEventEnvelopeWithOriginInstanceId() throws Exception { + when(objectMapper.writeValueAsString(any(FileEventPubSubMessage.class))).thenReturn("{\"ok\":true}"); + FileEvent event = new FileEvent(); + event.setId(10L); + event.setUserId(7L); + event.setEventType(FileEventType.RENAMED); + event.setFileId(11L); + event.setFromPath("/docs/old.txt"); + event.setToPath("/docs/new.txt"); + event.setClientId("tab-1"); + event.setPayloadJson("{\"action\":\"RENAMED\"}"); + event.setCreatedAt(LocalDateTime.now()); + + publisher.publish(event); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(FileEventPubSubMessage.class); + verify(objectMapper).writeValueAsString(messageCaptor.capture()); + assertThat(messageCaptor.getValue().originInstanceId()).isEqualTo("instance-a"); + assertThat(messageCaptor.getValue().eventId()).isEqualTo(10L); + verify(stringRedisTemplate).convertAndSend("yoyuzh:file-events:pubsub", "{\"ok\":true}"); + } + + private AppRedisProperties createRedisProperties() { + AppRedisProperties properties = new AppRedisProperties(); + properties.setKeyPrefix("yoyuzh"); + properties.getNamespaces().setFileEvents("file-events"); + return properties; + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/storage/DogeCloudS3SessionProviderTest.java b/backend/src/test/java/com/yoyuzh/files/storage/DogeCloudS3SessionProviderTest.java index 3c44948..74d0eb8 100644 --- a/backend/src/test/java/com/yoyuzh/files/storage/DogeCloudS3SessionProviderTest.java +++ b/backend/src/test/java/com/yoyuzh/files/storage/DogeCloudS3SessionProviderTest.java @@ -12,6 +12,8 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; class DogeCloudS3SessionProviderTest { @@ -57,6 +59,51 @@ class DogeCloudS3SessionProviderTest { assertThat(fetchCount.get()).isEqualTo(2); } + @Test + void refreshAndCloseShouldReleaseCachedRuntimeResources() { + FileStorageProperties.S3 properties = new FileStorageProperties.S3(); + properties.setRegion("automatic"); + + MutableClock clock = new MutableClock(Instant.parse("2026-04-01T10:00:00Z")); + AtomicInteger fetchCount = new AtomicInteger(); + S3Client firstClient = mock(S3Client.class, "first-client"); + S3Presigner firstPresigner = mock(S3Presigner.class, "first-presigner"); + S3Client secondClient = mock(S3Client.class, "second-client"); + S3Presigner secondPresigner = mock(S3Presigner.class, "second-presigner"); + + DogeCloudS3SessionProvider provider = new DogeCloudS3SessionProvider( + properties, + () -> { + int index = fetchCount.incrementAndGet(); + return new DogeCloudTemporaryS3Session( + "bucket-" + index, + "https://cos.ap-chengdu.myqcloud.com", + "ak-" + index, + "sk-" + index, + "token-" + index, + clock.instant().plusSeconds(index == 1 ? 600 : 1200) + ); + }, + clock, + session -> { + if ("bucket-1".equals(session.bucket())) { + return new S3FileRuntimeSession(session.bucket(), firstClient, firstPresigner); + } + return new S3FileRuntimeSession(session.bucket(), secondClient, secondPresigner); + } + ); + + provider.currentSession(); + clock.setInstant(Instant.parse("2026-04-01T10:09:30Z")); + provider.currentSession(); + provider.close(); + + verify(firstClient, times(1)).close(); + verify(firstPresigner, times(1)).close(); + verify(secondClient, times(1)).close(); + verify(secondPresigner, times(1)).close(); + } + private static final class MutableClock extends Clock { private Instant instant; diff --git a/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskArchiveHandlerTest.java b/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskArchiveHandlerTest.java index 54c37bd..6a0ee5f 100644 --- a/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskArchiveHandlerTest.java +++ b/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskArchiveHandlerTest.java @@ -51,7 +51,7 @@ class BackgroundTaskArchiveHandlerTest { storedFileRepository, userRepository, fileService, - new ObjectMapper() + new BackgroundTaskStateManager(new ObjectMapper()) ); archiveBytesCaptor = ArgumentCaptor.forClass(byte[].class); } diff --git a/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskRepositoryIntegrationTest.java b/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskRepositoryIntegrationTest.java new file mode 100644 index 0000000..68fc66e --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskRepositoryIntegrationTest.java @@ -0,0 +1,38 @@ +package com.yoyuzh.files.tasks; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.dao.DataIntegrityViolationException; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DataJpaTest(properties = { + "spring.jpa.hibernate.ddl-auto=create-drop" +}) +class BackgroundTaskRepositoryIntegrationTest { + + @Autowired + private BackgroundTaskRepository backgroundTaskRepository; + + @Test + void shouldRejectDuplicateCorrelationIdsAtDatabaseLevel() { + backgroundTaskRepository.saveAndFlush(createTask("media-meta:auto:file:77", 7L)); + + assertThatThrownBy(() -> backgroundTaskRepository.saveAndFlush(createTask("media-meta:auto:file:77", 8L))) + .isInstanceOf(DataIntegrityViolationException.class); + } + + private BackgroundTask createTask(String correlationId, Long userId) { + BackgroundTask task = new BackgroundTask(); + task.setType(BackgroundTaskType.MEDIA_META); + task.setStatus(BackgroundTaskStatus.QUEUED); + task.setUserId(userId); + task.setPublicStateJson("{\"phase\":\"queued\"}"); + task.setPrivateStateJson("{\"taskType\":\"MEDIA_META\"}"); + task.setCorrelationId(correlationId); + task.setAttemptCount(0); + task.setMaxAttempts(2); + return task; + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskRetryPolicyTest.java b/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskRetryPolicyTest.java new file mode 100644 index 0000000..f579188 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskRetryPolicyTest.java @@ -0,0 +1,39 @@ +package com.yoyuzh.files.tasks; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BackgroundTaskRetryPolicyTest { + + private final BackgroundTaskRetryPolicy retryPolicy = new BackgroundTaskRetryPolicy(); + + @Test + void shouldResolveConfiguredMaxAttemptsByTaskType() { + assertThat(retryPolicy.resolveMaxAttempts(BackgroundTaskType.ARCHIVE)).isEqualTo(4); + assertThat(retryPolicy.resolveMaxAttempts(BackgroundTaskType.EXTRACT)).isEqualTo(3); + assertThat(retryPolicy.resolveMaxAttempts(BackgroundTaskType.MEDIA_META)).isEqualTo(2); + } + + @Test + void shouldUseLongerBackoffForRateLimitedFailures() { + long delay = retryPolicy.resolveRetryDelaySeconds( + BackgroundTaskType.ARCHIVE, + BackgroundTaskFailureCategory.RATE_LIMITED, + 1 + ); + + assertThat(delay).isEqualTo(120L); + } + + @Test + void shouldCapBackoffGrowthForUnknownFailures() { + long delay = retryPolicy.resolveRetryDelaySeconds( + BackgroundTaskType.MEDIA_META, + BackgroundTaskFailureCategory.UNKNOWN, + 5 + ); + + assertThat(delay).isEqualTo(120L); + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskServiceTest.java b/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskServiceTest.java index 0e87025..62d00a8 100644 --- a/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskServiceTest.java @@ -3,22 +3,27 @@ package com.yoyuzh.files.tasks; import com.fasterxml.jackson.databind.ObjectMapper; import com.yoyuzh.api.v2.ApiV2Exception; import com.yoyuzh.auth.User; +import com.yoyuzh.common.lock.DistributedLockService; import com.yoyuzh.files.core.StoredFile; import com.yoyuzh.files.core.StoredFileRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.dao.DataIntegrityViolationException; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Supplier; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -31,11 +36,30 @@ class BackgroundTaskServiceTest { @Mock private StoredFileRepository storedFileRepository; + @Mock + private DistributedLockService distributedLockService; + private BackgroundTaskService backgroundTaskService; + private BackgroundTaskExecutionService backgroundTaskExecutionService; @BeforeEach void setUp() { - backgroundTaskService = new BackgroundTaskService(backgroundTaskRepository, storedFileRepository, new ObjectMapper()); + backgroundTaskService = new BackgroundTaskService( + backgroundTaskRepository, + storedFileRepository, + new ObjectMapper(), + distributedLockService + ); + backgroundTaskExecutionService = new BackgroundTaskExecutionService( + backgroundTaskRepository, + new BackgroundTaskRetryPolicy(), + new BackgroundTaskStateManager(new ObjectMapper()) + ); + lenient().when(distributedLockService.executeWithLock(any(), any(), any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + Supplier action = (Supplier) invocation.getArgument(2); + return action.get(); + }); } @Test @@ -218,7 +242,7 @@ class BackgroundTaskServiceTest { when(backgroundTaskRepository.findById(1L)).thenReturn(Optional.of(task)); when(backgroundTaskRepository.save(any(BackgroundTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); - Optional result = backgroundTaskService.claimQueuedTask(1L, "worker-a", 120L); + Optional result = backgroundTaskExecutionService.claimQueuedTask(1L, "worker-a", 120L); assertThat(result).containsSame(task); assertThat(result.orElseThrow().getLeaseOwner()).isEqualTo("worker-a"); @@ -244,7 +268,7 @@ class BackgroundTaskServiceTest { any() )).thenReturn(0); - Optional result = backgroundTaskService.claimQueuedTask(2L, "worker-a", 120L); + Optional result = backgroundTaskExecutionService.claimQueuedTask(2L, "worker-a", 120L); assertThat(result).isEmpty(); } @@ -267,7 +291,7 @@ class BackgroundTaskServiceTest { when(backgroundTaskRepository.findById(3L)).thenReturn(Optional.of(task)); when(backgroundTaskRepository.save(any(BackgroundTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); - BackgroundTask result = backgroundTaskService.markWorkerTaskCompleted(3L, "worker-a", Map.of("worker", "noop"), 120L); + BackgroundTask result = backgroundTaskExecutionService.markWorkerTaskCompleted(3L, "worker-a", Map.of("worker", "noop"), 120L); assertThat(result.getStatus()).isEqualTo(BackgroundTaskStatus.COMPLETED); assertThat(result.getFinishedAt()).isNotNull(); @@ -300,7 +324,7 @@ class BackgroundTaskServiceTest { when(backgroundTaskRepository.findById(7L)).thenReturn(Optional.of(task)); when(backgroundTaskRepository.save(any(BackgroundTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); - BackgroundTask result = backgroundTaskService.markWorkerTaskProgress( + BackgroundTask result = backgroundTaskExecutionService.markWorkerTaskProgress( 7L, "worker-a", Map.of("phase", "extracting", "progressPercent", 50), @@ -333,7 +357,7 @@ class BackgroundTaskServiceTest { when(backgroundTaskRepository.findById(4L)).thenReturn(Optional.of(task)); when(backgroundTaskRepository.save(any(BackgroundTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); - BackgroundTask result = backgroundTaskService.markWorkerTaskFailed( + BackgroundTask result = backgroundTaskExecutionService.markWorkerTaskFailed( 4L, "worker-a", "media parser unavailable", @@ -372,7 +396,7 @@ class BackgroundTaskServiceTest { when(backgroundTaskRepository.findById(14L)).thenReturn(Optional.of(task)); when(backgroundTaskRepository.save(any(BackgroundTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); - BackgroundTask result = backgroundTaskService.markWorkerTaskFailed( + BackgroundTask result = backgroundTaskExecutionService.markWorkerTaskFailed( 14L, "worker-a", "storage timeout", @@ -412,7 +436,7 @@ class BackgroundTaskServiceTest { when(backgroundTaskRepository.findById(18L)).thenReturn(Optional.of(task)); when(backgroundTaskRepository.save(any(BackgroundTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); - BackgroundTask result = backgroundTaskService.markWorkerTaskFailed( + BackgroundTask result = backgroundTaskExecutionService.markWorkerTaskFailed( 18L, "worker-a", "429 too many requests", @@ -445,7 +469,7 @@ class BackgroundTaskServiceTest { when(backgroundTaskRepository.findById(15L)).thenReturn(Optional.of(task)); when(backgroundTaskRepository.save(any(BackgroundTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); - BackgroundTask result = backgroundTaskService.markWorkerTaskFailed( + BackgroundTask result = backgroundTaskExecutionService.markWorkerTaskFailed( 15L, "worker-a", "storage timeout", @@ -533,7 +557,7 @@ class BackgroundTaskServiceTest { when(backgroundTaskRepository.findById(10L)).thenReturn(Optional.of(expired)); when(backgroundTaskRepository.save(any(BackgroundTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); - int recovered = backgroundTaskService.requeueExpiredRunningTasks(); + int recovered = backgroundTaskExecutionService.requeueExpiredRunningTasks(); assertThat(recovered).isEqualTo(1); assertThat(expired.getStatus()).isEqualTo(BackgroundTaskStatus.QUEUED); @@ -558,14 +582,67 @@ class BackgroundTaskServiceTest { when(backgroundTaskRepository.findReadyTaskIdsByStatusOrder(eq(BackgroundTaskStatus.QUEUED), any(), any())) .thenReturn(List.of(5L, 6L)); - List result = backgroundTaskService.findQueuedTaskIds(2); + List result = backgroundTaskExecutionService.findQueuedTaskIds(2); assertThat(result).containsExactly(5L, 6L); } @Test void shouldReturnEmptyTaskIdsWhenLimitIsNonPositive() { - List result = backgroundTaskService.findQueuedTaskIds(0); + List result = backgroundTaskExecutionService.findQueuedTaskIds(0); + assertThat(result).isEmpty(); + } + + @Test + void shouldCreateAutoMediaMetadataTaskWhenCorrelationIsNew() { + StoredFile file = createStoredFile(19L, createUser(7L), "/docs", "photo.png", false, "image/png", 18L); + when(backgroundTaskRepository.existsByCorrelationId("media-meta:auto:file:19")).thenReturn(false); + when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(19L, 7L)).thenReturn(Optional.of(file)); + when(backgroundTaskRepository.saveAndFlush(any(BackgroundTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + Optional result = backgroundTaskService.createQueuedAutoMediaMetadataTask( + 7L, + 19L, + "media-meta:auto:file:19" + ); + + assertThat(result).isPresent(); + assertThat(result.orElseThrow().getType()).isEqualTo(BackgroundTaskType.MEDIA_META); + assertThat(result.orElseThrow().getCorrelationId()).isEqualTo("media-meta:auto:file:19"); + assertThat(result.orElseThrow().getPublicStateJson()).contains("\"path\":\"/docs/photo.png\""); + assertThat(result.orElseThrow().getPublicStateJson()).contains("\"phase\":\"queued\""); + verify(distributedLockService).executeWithLock(eq("background-task-correlation:media-meta:auto:file:19"), any(), any()); + } + + @Test + void shouldSkipAutoMediaMetadataTaskWhenCorrelationAlreadyExists() { + when(backgroundTaskRepository.existsByCorrelationId("media-meta:auto:file:20")).thenReturn(true); + + Optional result = backgroundTaskService.createQueuedAutoMediaMetadataTask( + 7L, + 20L, + "media-meta:auto:file:20" + ); + + assertThat(result).isEmpty(); + verify(storedFileRepository, never()).findByIdAndUserIdAndDeletedAtIsNull(20L, 7L); + verify(backgroundTaskRepository, never()).save(any(BackgroundTask.class)); + } + + @Test + void shouldTreatDuplicateCorrelationInsertAsIdempotentNoOp() { + StoredFile file = createStoredFile(21L, createUser(7L), "/docs", "photo.png", false, "image/png", 18L); + when(backgroundTaskRepository.existsByCorrelationId("media-meta:auto:file:21")).thenReturn(false); + when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(21L, 7L)).thenReturn(Optional.of(file)); + when(backgroundTaskRepository.saveAndFlush(any(BackgroundTask.class))) + .thenThrow(new DataIntegrityViolationException("duplicate correlation id")); + + Optional result = backgroundTaskService.createQueuedAutoMediaMetadataTask( + 7L, + 21L, + "media-meta:auto:file:21" + ); + assertThat(result).isEmpty(); } diff --git a/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskStateManagerTest.java b/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskStateManagerTest.java new file mode 100644 index 0000000..9ffc387 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskStateManagerTest.java @@ -0,0 +1,100 @@ +package com.yoyuzh.files.tasks; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class BackgroundTaskStateManagerTest { + + private final BackgroundTaskStateManager stateManager = new BackgroundTaskStateManager(new ObjectMapper()); + + @Test + void shouldBuildCancelledStatePatch() { + BackgroundTask task = createTask(1, 4); + LocalDateTime now = LocalDateTime.of(2026, 4, 11, 21, 0, 0); + + Map patch = stateManager.cancelledStatePatch(task, now); + + assertThat(patch.get(BackgroundTaskStateKeys.PHASE)).isEqualTo("cancelled"); + assertThat(patch.get(BackgroundTaskStateKeys.ATTEMPT_COUNT)).isEqualTo(1); + assertThat(patch.get(BackgroundTaskStateKeys.MAX_ATTEMPTS)).isEqualTo(4); + assertThat(patch.get(BackgroundTaskStateKeys.HEARTBEAT_AT)).isEqualTo(now.toString()); + } + + @Test + void shouldBuildCompletedStatePatchAndKeepCanonicalFields() { + BackgroundTask task = createTask(2, 4); + LocalDateTime now = LocalDateTime.of(2026, 4, 11, 21, 1, 0); + + Map patch = stateManager.completedStatePatch( + task, + now, + Map.of( + "worker", "noop", + BackgroundTaskStateKeys.PHASE, "should-be-overwritten" + ) + ); + + assertThat(patch.get("worker")).isEqualTo("noop"); + assertThat(patch.get(BackgroundTaskStateKeys.PHASE)).isEqualTo("completed"); + assertThat(patch.get(BackgroundTaskStateKeys.ATTEMPT_COUNT)).isEqualTo(2); + assertThat(patch.get(BackgroundTaskStateKeys.MAX_ATTEMPTS)).isEqualTo(4); + assertThat(patch.get(BackgroundTaskStateKeys.HEARTBEAT_AT)).isEqualTo(now.toString()); + } + + @Test + void shouldBuildFailedStatePatch() { + BackgroundTask task = createTask(3, 5); + LocalDateTime now = LocalDateTime.of(2026, 4, 11, 21, 2, 0); + + Map patch = stateManager.failedStatePatch( + task, + "storage timeout", + BackgroundTaskFailureCategory.TRANSIENT_INFRASTRUCTURE, + now + ); + + assertThat(patch.get(BackgroundTaskStateKeys.PHASE)).isEqualTo("failed"); + assertThat(patch.get(BackgroundTaskStateKeys.ATTEMPT_COUNT)).isEqualTo(3); + assertThat(patch.get(BackgroundTaskStateKeys.MAX_ATTEMPTS)).isEqualTo(5); + assertThat(patch.get(BackgroundTaskStateKeys.LAST_FAILURE_MESSAGE)).isEqualTo("storage timeout"); + assertThat(patch.get(BackgroundTaskStateKeys.FAILURE_CATEGORY)).isEqualTo("TRANSIENT_INFRASTRUCTURE"); + assertThat(patch.get(BackgroundTaskStateKeys.HEARTBEAT_AT)).isEqualTo(now.toString()); + } + + @Test + void shouldBuildRetryQueuedStatePatch() { + BackgroundTask task = createTask(2, 4); + LocalDateTime now = LocalDateTime.of(2026, 4, 11, 21, 3, 0); + LocalDateTime nextRetryAt = now.plusSeconds(30); + + Map patch = stateManager.retryQueuedStatePatch( + task, + "temporary unavailable", + BackgroundTaskFailureCategory.TRANSIENT_INFRASTRUCTURE, + nextRetryAt, + 30, + now + ); + + assertThat(patch.get(BackgroundTaskStateKeys.PHASE)).isEqualTo("queued"); + assertThat(patch.get(BackgroundTaskStateKeys.ATTEMPT_COUNT)).isEqualTo(2); + assertThat(patch.get(BackgroundTaskStateKeys.MAX_ATTEMPTS)).isEqualTo(4); + assertThat(patch.get(BackgroundTaskStateKeys.RETRY_SCHEDULED)).isEqualTo(true); + assertThat(patch.get(BackgroundTaskStateKeys.NEXT_RETRY_AT)).isEqualTo(nextRetryAt.toString()); + assertThat(patch.get(BackgroundTaskStateKeys.RETRY_DELAY_SECONDS)).isEqualTo(30L); + assertThat(patch.get(BackgroundTaskStateKeys.LAST_FAILURE_MESSAGE)).isEqualTo("temporary unavailable"); + assertThat(patch.get(BackgroundTaskStateKeys.FAILURE_CATEGORY)).isEqualTo("TRANSIENT_INFRASTRUCTURE"); + } + + private BackgroundTask createTask(int attemptCount, int maxAttempts) { + BackgroundTask task = new BackgroundTask(); + task.setAttemptCount(attemptCount); + task.setMaxAttempts(maxAttempts); + return task; + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskWorkerTest.java b/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskWorkerTest.java index 2a61bec..0678893 100644 --- a/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskWorkerTest.java +++ b/backend/src/test/java/com/yoyuzh/files/tasks/BackgroundTaskWorkerTest.java @@ -23,7 +23,7 @@ import static org.mockito.Mockito.when; class BackgroundTaskWorkerTest { @Mock - private BackgroundTaskService backgroundTaskService; + private BackgroundTaskExecutionService backgroundTaskExecutionService; @Mock private BackgroundTaskHandler backgroundTaskHandler; @@ -31,14 +31,14 @@ class BackgroundTaskWorkerTest { @BeforeEach void setUp() { - backgroundTaskWorker = new BackgroundTaskWorker(backgroundTaskService, List.of(backgroundTaskHandler)); + backgroundTaskWorker = new BackgroundTaskWorker(backgroundTaskExecutionService, List.of(backgroundTaskHandler)); } @Test void shouldClaimAndCompleteQueuedTaskThroughNoopHandler() { BackgroundTask task = createTask(1L, BackgroundTaskType.ARCHIVE, BackgroundTaskStatus.RUNNING); - when(backgroundTaskService.findQueuedTaskIds(5)).thenReturn(List.of(1L)); - when(backgroundTaskService.claimQueuedTask(eq(1L), anyString(), anyLong())).thenReturn(Optional.of(task)); + when(backgroundTaskExecutionService.findQueuedTaskIds(5)).thenReturn(List.of(1L)); + when(backgroundTaskExecutionService.claimQueuedTask(eq(1L), anyString(), anyLong())).thenReturn(Optional.of(task)); when(backgroundTaskHandler.supports(BackgroundTaskType.ARCHIVE)).thenReturn(true); when(backgroundTaskHandler.handle(eq(task), any(BackgroundTaskProgressReporter.class))) .thenReturn(new BackgroundTaskHandlerResult(Map.of("worker", "noop"))); @@ -46,15 +46,15 @@ class BackgroundTaskWorkerTest { int processedCount = backgroundTaskWorker.processQueuedTasks(5); assertThat(processedCount).isEqualTo(1); - verify(backgroundTaskService).markWorkerTaskProgress(eq(1L), anyString(), eq(Map.of("phase", "archiving")), anyLong()); + verify(backgroundTaskExecutionService).markWorkerTaskProgress(eq(1L), anyString(), eq(Map.of("phase", "archiving")), anyLong()); verify(backgroundTaskHandler).handle(eq(task), any(BackgroundTaskProgressReporter.class)); - verify(backgroundTaskService).markWorkerTaskCompleted(eq(1L), anyString(), eq(Map.of("worker", "noop")), anyLong()); + verify(backgroundTaskExecutionService).markWorkerTaskCompleted(eq(1L), anyString(), eq(Map.of("worker", "noop")), anyLong()); } @Test void shouldSkipTaskThatWasNotClaimed() { - when(backgroundTaskService.findQueuedTaskIds(5)).thenReturn(List.of(1L)); - when(backgroundTaskService.claimQueuedTask(eq(1L), anyString(), anyLong())).thenReturn(Optional.empty()); + when(backgroundTaskExecutionService.findQueuedTaskIds(5)).thenReturn(List.of(1L)); + when(backgroundTaskExecutionService.claimQueuedTask(eq(1L), anyString(), anyLong())).thenReturn(Optional.empty()); int processedCount = backgroundTaskWorker.processQueuedTasks(5); @@ -65,8 +65,8 @@ class BackgroundTaskWorkerTest { @Test void shouldMarkTaskFailedWhenHandlerThrows() { BackgroundTask task = createTask(2L, BackgroundTaskType.MEDIA_META, BackgroundTaskStatus.RUNNING); - when(backgroundTaskService.findQueuedTaskIds(5)).thenReturn(List.of(2L)); - when(backgroundTaskService.claimQueuedTask(eq(2L), anyString(), anyLong())).thenReturn(Optional.of(task)); + when(backgroundTaskExecutionService.findQueuedTaskIds(5)).thenReturn(List.of(2L)); + when(backgroundTaskExecutionService.claimQueuedTask(eq(2L), anyString(), anyLong())).thenReturn(Optional.of(task)); when(backgroundTaskHandler.supports(BackgroundTaskType.MEDIA_META)).thenReturn(true); when(backgroundTaskHandler.handle(eq(task), any(BackgroundTaskProgressReporter.class))) .thenThrow(new IllegalStateException("media parser unavailable")); @@ -74,8 +74,8 @@ class BackgroundTaskWorkerTest { int processedCount = backgroundTaskWorker.processQueuedTasks(5); assertThat(processedCount).isEqualTo(1); - verify(backgroundTaskService).markWorkerTaskProgress(eq(2L), anyString(), eq(Map.of("phase", "extracting-metadata")), anyLong()); - verify(backgroundTaskService).markWorkerTaskFailed( + verify(backgroundTaskExecutionService).markWorkerTaskProgress(eq(2L), anyString(), eq(Map.of("phase", "extracting-metadata")), anyLong()); + verify(backgroundTaskExecutionService).markWorkerTaskFailed( eq(2L), anyString(), eq("media parser unavailable"), @@ -87,8 +87,8 @@ class BackgroundTaskWorkerTest { @Test void shouldAutoRetryUnexpectedWorkerFailure() { BackgroundTask task = createTask(3L, BackgroundTaskType.ARCHIVE, BackgroundTaskStatus.RUNNING); - when(backgroundTaskService.findQueuedTaskIds(5)).thenReturn(List.of(3L)); - when(backgroundTaskService.claimQueuedTask(eq(3L), anyString(), anyLong())).thenReturn(Optional.of(task)); + when(backgroundTaskExecutionService.findQueuedTaskIds(5)).thenReturn(List.of(3L)); + when(backgroundTaskExecutionService.claimQueuedTask(eq(3L), anyString(), anyLong())).thenReturn(Optional.of(task)); when(backgroundTaskHandler.supports(BackgroundTaskType.ARCHIVE)).thenReturn(true); when(backgroundTaskHandler.handle(eq(task), any(BackgroundTaskProgressReporter.class))) .thenThrow(new RuntimeException("storage timeout")); @@ -96,7 +96,7 @@ class BackgroundTaskWorkerTest { int processedCount = backgroundTaskWorker.processQueuedTasks(5); assertThat(processedCount).isEqualTo(1); - verify(backgroundTaskService).markWorkerTaskFailed( + verify(backgroundTaskExecutionService).markWorkerTaskFailed( eq(3L), anyString(), eq("storage timeout"), @@ -108,8 +108,8 @@ class BackgroundTaskWorkerTest { @Test void shouldClassifyRateLimitedFailureSeparately() { BackgroundTask task = createTask(4L, BackgroundTaskType.EXTRACT, BackgroundTaskStatus.RUNNING); - when(backgroundTaskService.findQueuedTaskIds(5)).thenReturn(List.of(4L)); - when(backgroundTaskService.claimQueuedTask(eq(4L), anyString(), anyLong())).thenReturn(Optional.of(task)); + when(backgroundTaskExecutionService.findQueuedTaskIds(5)).thenReturn(List.of(4L)); + when(backgroundTaskExecutionService.claimQueuedTask(eq(4L), anyString(), anyLong())).thenReturn(Optional.of(task)); when(backgroundTaskHandler.supports(BackgroundTaskType.EXTRACT)).thenReturn(true); when(backgroundTaskHandler.handle(eq(task), any(BackgroundTaskProgressReporter.class))) .thenThrow(new RuntimeException("429 Too Many Requests")); @@ -117,7 +117,7 @@ class BackgroundTaskWorkerTest { int processedCount = backgroundTaskWorker.processQueuedTasks(5); assertThat(processedCount).isEqualTo(1); - verify(backgroundTaskService).markWorkerTaskFailed( + verify(backgroundTaskExecutionService).markWorkerTaskFailed( eq(4L), anyString(), eq("429 Too Many Requests"), diff --git a/backend/src/test/java/com/yoyuzh/files/tasks/ExtractBackgroundTaskHandlerTest.java b/backend/src/test/java/com/yoyuzh/files/tasks/ExtractBackgroundTaskHandlerTest.java index 16636b9..3c2c892 100644 --- a/backend/src/test/java/com/yoyuzh/files/tasks/ExtractBackgroundTaskHandlerTest.java +++ b/backend/src/test/java/com/yoyuzh/files/tasks/ExtractBackgroundTaskHandlerTest.java @@ -46,7 +46,7 @@ class ExtractBackgroundTaskHandlerTest { storedFileRepository, userRepository, fileService, - new ObjectMapper() + new BackgroundTaskStateManager(new ObjectMapper()) ); } diff --git a/backend/src/test/java/com/yoyuzh/files/tasks/MediaMetadataBackgroundTaskHandlerTest.java b/backend/src/test/java/com/yoyuzh/files/tasks/MediaMetadataBackgroundTaskHandlerTest.java index a0aa533..7b6c117 100644 --- a/backend/src/test/java/com/yoyuzh/files/tasks/MediaMetadataBackgroundTaskHandlerTest.java +++ b/backend/src/test/java/com/yoyuzh/files/tasks/MediaMetadataBackgroundTaskHandlerTest.java @@ -46,7 +46,7 @@ class MediaMetadataBackgroundTaskHandlerTest { storedFileRepository, fileMetadataRepository, fileContentStorage, - new ObjectMapper() + new BackgroundTaskStateManager(new ObjectMapper()) ); } diff --git a/backend/src/test/java/com/yoyuzh/files/tasks/MediaMetadataTaskBrokerConsumerTest.java b/backend/src/test/java/com/yoyuzh/files/tasks/MediaMetadataTaskBrokerConsumerTest.java new file mode 100644 index 0000000..83af38e --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/tasks/MediaMetadataTaskBrokerConsumerTest.java @@ -0,0 +1,87 @@ +package com.yoyuzh.files.tasks; + +import com.yoyuzh.common.broker.LightweightBrokerService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MediaMetadataTaskBrokerConsumerTest { + + @Mock + private LightweightBrokerService lightweightBrokerService; + + @Mock + private BackgroundTaskCommandService backgroundTaskCommandService; + + private MediaMetadataTaskBrokerConsumer consumer; + + @BeforeEach + void setUp() { + consumer = new MediaMetadataTaskBrokerConsumer(lightweightBrokerService, backgroundTaskCommandService); + } + + @Test + void shouldDrainQueuedBrokerMessageIntoAutoMediaMetadataTask() { + when(lightweightBrokerService.poll(MediaMetadataTaskBrokerPublisher.TOPIC)) + .thenReturn(Optional.of(Map.of( + "userId", 7L, + "fileId", 11L, + "correlationId", "media-meta:auto:file:11" + ))) + .thenReturn(Optional.empty()); + when(backgroundTaskCommandService.createQueuedAutoMediaMetadataTask(7L, 11L, "media-meta:auto:file:11")) + .thenReturn(Optional.of(new BackgroundTask())); + + int processed = consumer.drainQueuedMessages(5); + + assertThat(processed).isEqualTo(1); + verify(backgroundTaskCommandService).createQueuedAutoMediaMetadataTask(7L, 11L, "media-meta:auto:file:11"); + } + + @Test + void shouldRequeuePayloadWhenTaskCreationFails() { + Map payload = Map.of( + "userId", 7L, + "fileId", 11L, + "correlationId", "media-meta:auto:file:11" + ); + when(lightweightBrokerService.poll(MediaMetadataTaskBrokerPublisher.TOPIC)) + .thenReturn(Optional.of(payload)); + doThrow(new IllegalStateException("db unavailable")) + .when(backgroundTaskCommandService) + .createQueuedAutoMediaMetadataTask(7L, 11L, "media-meta:auto:file:11"); + + int processed = consumer.drainQueuedMessages(1); + + assertThat(processed).isEqualTo(0); + verify(lightweightBrokerService).requeue(MediaMetadataTaskBrokerPublisher.TOPIC, payload); + } + + @Test + void shouldDropMalformedPayloadWithoutRequeue() { + when(lightweightBrokerService.poll(MediaMetadataTaskBrokerPublisher.TOPIC)) + .thenReturn(Optional.of(Map.of( + "userId", "bad-user-id", + "fileId", 11L + ))) + .thenReturn(Optional.empty()); + + int processed = consumer.drainQueuedMessages(2); + + assertThat(processed).isEqualTo(0); + verify(backgroundTaskCommandService, never()).createQueuedAutoMediaMetadataTask(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any()); + verify(lightweightBrokerService, never()).requeue(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/tasks/StoragePolicyMigrationBackgroundTaskHandlerTest.java b/backend/src/test/java/com/yoyuzh/files/tasks/StoragePolicyMigrationBackgroundTaskHandlerTest.java index 567be8d..c105fa2 100644 --- a/backend/src/test/java/com/yoyuzh/files/tasks/StoragePolicyMigrationBackgroundTaskHandlerTest.java +++ b/backend/src/test/java/com/yoyuzh/files/tasks/StoragePolicyMigrationBackgroundTaskHandlerTest.java @@ -53,7 +53,7 @@ class StoragePolicyMigrationBackgroundTaskHandlerTest { fileBlobRepository, storedFileRepository, fileContentStorage, - new ObjectMapper() + new BackgroundTaskStateManager(new ObjectMapper()) ); } diff --git a/backend/src/test/java/com/yoyuzh/files/upload/UploadSessionServiceTest.java b/backend/src/test/java/com/yoyuzh/files/upload/UploadSessionServiceTest.java index 6dc2214..0bc439e 100644 --- a/backend/src/test/java/com/yoyuzh/files/upload/UploadSessionServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/files/upload/UploadSessionServiceTest.java @@ -11,13 +11,14 @@ import com.yoyuzh.files.policy.StoragePolicyService; import com.yoyuzh.files.policy.StoragePolicyType; import com.yoyuzh.files.storage.FileContentStorage; import com.yoyuzh.files.storage.PreparedUpload; -import org.springframework.mock.web.MockMultipartFile; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; import java.time.Clock; import java.time.Instant; @@ -48,6 +49,8 @@ class UploadSessionServiceTest { private FileContentStorage fileContentStorage; @Mock private StoragePolicyService storagePolicyService; + @Mock + private UploadSessionRuntimeStateService uploadSessionRuntimeStateService; private UploadSessionService uploadSessionService; @@ -64,6 +67,7 @@ class UploadSessionServiceTest { properties, Clock.fixed(Instant.parse("2026-04-08T06:00:00Z"), ZoneOffset.UTC) ); + ReflectionTestUtils.setField(uploadSessionService, "uploadSessionRuntimeStateService", uploadSessionRuntimeStateService); } @Test @@ -103,6 +107,7 @@ class UploadSessionServiceTest { assertThat(session.getChunkSize()).isEqualTo(8L * 1024 * 1024); assertThat(session.getChunkCount()).isEqualTo(3); assertThat(session.getExpiresAt()).isEqualTo(LocalDateTime.of(2026, 4, 9, 6, 0)); + verify(uploadSessionRuntimeStateService).markCreated(session); } @Test @@ -308,6 +313,7 @@ class UploadSessionServiceTest { assertThat(requestCaptor.getValue().storageName()).isEqualTo("blobs/session-1"); assertThat(requestCaptor.getValue().contentType()).isEqualTo("video/mp4"); assertThat(requestCaptor.getValue().size()).isEqualTo(20L); + verify(uploadSessionRuntimeStateService).markCompleted(result, LocalDateTime.of(2026, 4, 8, 6, 0)); } @Test @@ -353,6 +359,8 @@ class UploadSessionServiceTest { assertThat(secondResult.getUploadedPartsJson()).contains("\"partIndex\":1"); assertThat(secondResult.getUploadedPartsJson()).contains("\"partIndex\":2"); assertThat(secondResult.getUploadedPartsJson()).contains("\"etag\":\"etag-2\""); + verify(uploadSessionRuntimeStateService).markUploading(result, 8L * 1024 * 1024, 1, LocalDateTime.of(2026, 4, 8, 6, 0)); + verify(uploadSessionRuntimeStateService).markUploading(secondResult, 8L * 1024 * 1024 + 4L, 2, LocalDateTime.of(2026, 4, 8, 6, 0)); } @Test diff --git a/backend/src/test/java/com/yoyuzh/files/upload/UploadSessionStateMachineTest.java b/backend/src/test/java/com/yoyuzh/files/upload/UploadSessionStateMachineTest.java new file mode 100644 index 0000000..687c0ac --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/upload/UploadSessionStateMachineTest.java @@ -0,0 +1,52 @@ +package com.yoyuzh.files.upload; + +import com.yoyuzh.common.BusinessException; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class UploadSessionStateMachineTest { + + private final UploadSessionStateMachine stateMachine = new UploadSessionStateMachine(); + + @Test + void shouldMarkExpiredSessionAsExpired() { + UploadSession session = createSession(UploadSessionStatus.UPLOADING); + LocalDateTime now = LocalDateTime.of(2026, 4, 11, 12, 0); + + stateMachine.markExpired(session, now); + + assertThat(session.getStatus()).isEqualTo(UploadSessionStatus.EXPIRED); + assertThat(session.getUpdatedAt()).isEqualTo(now); + } + + @Test + void shouldRejectFurtherMultipartContentUploadWhenSessionAlreadyUploading() { + UploadSession session = createSession(UploadSessionStatus.UPLOADING); + + assertThatThrownBy(() -> stateMachine.ensureCanReceiveContent(session, LocalDateTime.now(), true)) + .isInstanceOf(BusinessException.class); + } + + @Test + void shouldMoveCreatedSessionToUploading() { + UploadSession session = createSession(UploadSessionStatus.CREATED); + LocalDateTime now = LocalDateTime.of(2026, 4, 11, 12, 0); + + stateMachine.markUploading(session, now); + + assertThat(session.getStatus()).isEqualTo(UploadSessionStatus.UPLOADING); + assertThat(session.getUpdatedAt()).isEqualTo(now); + } + + private UploadSession createSession(UploadSessionStatus status) { + UploadSession session = new UploadSession(); + session.setStatus(status); + session.setUpdatedAt(LocalDateTime.of(2026, 4, 11, 11, 0)); + session.setExpiresAt(LocalDateTime.of(2026, 4, 12, 11, 0)); + return session; + } +} diff --git a/backend/src/test/java/com/yoyuzh/transfer/OnlineTransferServiceTest.java b/backend/src/test/java/com/yoyuzh/transfer/OnlineTransferServiceTest.java new file mode 100644 index 0000000..0f88a37 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/transfer/OnlineTransferServiceTest.java @@ -0,0 +1,64 @@ +package com.yoyuzh.transfer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OnlineTransferServiceTest { + + private TransferSessionStore sessionStore; + private OfflineTransferSessionRepository offlineTransferSessionRepository; + private OnlineTransferService onlineTransferService; + + @BeforeEach + void setUp() { + sessionStore = mock(TransferSessionStore.class); + offlineTransferSessionRepository = mock(OfflineTransferSessionRepository.class); + onlineTransferService = new OnlineTransferService(sessionStore, offlineTransferSessionRepository); + + when(sessionStore.withSession(any(), any())).thenAnswer(invocation -> { + TransferSession session = onlineSession(); + @SuppressWarnings("unchecked") + Function action = (Function) invocation.getArgument(1); + return action.apply(session); + }); + when(sessionStore.findById(any())).thenReturn(Optional.of(onlineSession())); + } + + @Test + void shouldPersistUpdatedOnlineSessionInsideAtomicJoinOperation() { + TransferSessionResponse response = onlineTransferService.joinSession("session-1"); + + assertThat(response.sessionId()).isEqualTo("session-1"); + verify(sessionStore).withSession(any(), any()); + verify(sessionStore, never()).findById("session-1"); + } + + @Test + void shouldPersistUpdatedOnlineSessionInsideAtomicSignalOperation() { + onlineTransferService.postSignal("session-1", "sender", new TransferSignalRequest("offer", "{\"sdp\":\"demo\"}")); + + verify(sessionStore).withSession(any(), any()); + verify(sessionStore, never()).findById("session-1"); + } + + private TransferSession onlineSession() { + return new TransferSession( + "session-1", + "123456", + Instant.now().plusSeconds(300), + List.of(new TransferFileItem("demo.txt", 12, "text/plain")) + ); + } +} diff --git a/backend/src/test/java/com/yoyuzh/transfer/TransferServiceTest.java b/backend/src/test/java/com/yoyuzh/transfer/TransferServiceTest.java new file mode 100644 index 0000000..76fe669 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/transfer/TransferServiceTest.java @@ -0,0 +1,100 @@ +package com.yoyuzh.transfer; + +import com.yoyuzh.admin.AdminMetricsService; +import com.yoyuzh.auth.User; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TransferServiceTest { + + private OnlineTransferService onlineTransferService; + private OfflineTransferService offlineTransferService; + private TransferImportService transferImportService; + private AdminMetricsService adminMetricsService; + private TransferService transferService; + + @BeforeEach + void setUp() { + onlineTransferService = mock(OnlineTransferService.class); + offlineTransferService = mock(OfflineTransferService.class); + transferImportService = mock(TransferImportService.class); + adminMetricsService = mock(AdminMetricsService.class); + transferService = new TransferService( + onlineTransferService, + offlineTransferService, + transferImportService, + adminMetricsService + ); + } + + @Test + void shouldRouteOnlineSessionCreationToOnlineService() { + CreateTransferSessionRequest request = new CreateTransferSessionRequest( + TransferMode.ONLINE, + List.of(new TransferFileItem("demo.txt", 12L, "text/plain")) + ); + TransferSessionResponse response = new TransferSessionResponse( + "session-1", + "123456", + TransferMode.ONLINE, + Instant.now().plusSeconds(60), + request.files() + ); + when(onlineTransferService.createSession(any(CreateTransferSessionRequest.class))).thenReturn(response); + + TransferSessionResponse actual = transferService.createSession(null, request); + + assertThat(actual.sessionId()).isEqualTo("session-1"); + verify(onlineTransferService).createSession(request); + verify(adminMetricsService).recordTransferUsage(12L); + } + + @Test + void shouldRequireAuthenticatedSenderForOfflineSessionCreation() { + CreateTransferSessionRequest request = new CreateTransferSessionRequest( + TransferMode.OFFLINE, + List.of(new TransferFileItem("demo.txt", 12L, "text/plain")) + ); + + assertThatThrownBy(() -> transferService.createSession(null, request)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCode()) + .isEqualTo(ErrorCode.NOT_LOGGED_IN); + } + + @Test + void shouldRouteOfflineSessionCreationToOfflineService() { + User sender = new User(); + sender.setId(7L); + CreateTransferSessionRequest request = new CreateTransferSessionRequest( + TransferMode.OFFLINE, + List.of(new TransferFileItem("demo.txt", 12L, "text/plain")) + ); + TransferSessionResponse response = new TransferSessionResponse( + "offline-1", + "654321", + TransferMode.OFFLINE, + Instant.now().plusSeconds(60), + request.files() + ); + when(offlineTransferService.createSession(any(User.class), any(CreateTransferSessionRequest.class))).thenReturn(response); + + TransferSessionResponse actual = transferService.createSession(sender, request); + + assertThat(actual.sessionId()).isEqualTo("offline-1"); + verify(offlineTransferService).createSession(sender, request); + verify(adminMetricsService).recordTransferUsage(12L); + } +} diff --git a/backend/src/test/java/com/yoyuzh/transfer/TransferSessionStoreTest.java b/backend/src/test/java/com/yoyuzh/transfer/TransferSessionStoreTest.java new file mode 100644 index 0000000..a8c4a8e --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/transfer/TransferSessionStoreTest.java @@ -0,0 +1,63 @@ +package com.yoyuzh.transfer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.common.lock.DistributedLockService; +import com.yoyuzh.config.AppRedisProperties; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TransferSessionStoreTest { + + @Test + void shouldRoundTripSessionThroughRedisWhenEnabled() { + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + AppRedisProperties redisProperties = new AppRedisProperties(); + redisProperties.setEnabled(true); + redisProperties.setTtlBufferSeconds(30); + + TransferSessionStore store = new TransferSessionStore( + redisTemplate, + new ObjectMapper().findAndRegisterModules(), + redisProperties, + DistributedLockService.noOp() + ); + + TransferSession session = new TransferSession( + "session-1", + "123456", + Instant.now().plusSeconds(300), + List.of(new TransferFileItem("demo.txt", 12, "text/plain")) + ); + + store.save(session); + + ArgumentCaptor sessionJson = ArgumentCaptor.forClass(String.class); + verify(valueOperations).set(eq("yoyuzh:transfer-sessions:session:session-1"), sessionJson.capture(), any(Duration.class)); + verify(valueOperations).set(eq("yoyuzh:transfer-sessions:pickup:123456"), eq("session-1"), any(Duration.class)); + + when(valueOperations.get("yoyuzh:transfer-sessions:pickup:123456")).thenReturn("session-1"); + when(valueOperations.get("yoyuzh:transfer-sessions:session:session-1")).thenReturn(sessionJson.getValue()); + + TransferSession reloaded = store.findByPickupCode("123456").orElseThrow(); + + assertThat(reloaded.toSessionResponse().sessionId()).isEqualTo("session-1"); + assertThat(reloaded.toLookupResponse().pickupCode()).isEqualTo("123456"); + } +} diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 81b87a5..d439cd8 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -8,3 +8,4 @@ This directory currently stores implementation plans under `docs/superpowers/pla - Do not introduce placeholder commands such as an imaginary root `npm test`, backend lint script, or standalone frontend typecheck script. - When documenting validation, state gaps explicitly. In this repo, backend lint/typecheck commands are not defined, and frontend type checking currently happens through `npm run lint`. - Keep plan or handoff documents tied to actual repo paths like `backend/...`, `front/...`, `scripts/...`, and `docs/...`. +- `docs/architecture.md` is the project architecture document. Do not edit it unless the user explicitly asks for an architecture-document update. diff --git a/docs/api-reference.md b/docs/api-reference.md index 36c04cc..47ada12 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,55 +1,67 @@ -# API 接口文档 +# API 参考文档 -本文档用于快速了解 `yoyuzh.xyz` 当前后端 API 的职责、鉴权方式和主要接口分组。 +本文档是当前后端接口边界的快速参考,不是 OpenAPI 替代品。 +目标是帮助开发、重构、测试和 Code Review 快速回答三个问题: -## 1. 基本约定 +- 这个能力现在走哪组接口 +- 这组接口是否需要登录或管理员身份 +- 这组接口当前属于稳定主线、兼容保留,还是正在迁移中的边界 -### 基础路径 +如果接口细节和本文档冲突,以当前控制器和安全配置为准: -- 后端接口统一以 `/api` 开头 -- 本地开发默认地址:`http://localhost:8080` +- `backend/src/main/java/com/yoyuzh/config/SecurityConfig.java` +- `backend/src/main/java/com/yoyuzh/**/**/*Controller.java` -### 返回格式 +## 1. 接口分层总览 -大部分接口返回统一结构: +### 1.1 公开接口 -```json -{ - "code": 0, - "msg": "success", - "data": {} -} -``` +无需登录即可访问: -常见含义: +- `/api/auth/**` +- `/api/app/android/**` +- `GET /api/v2/site/ping` +- `GET /api/v2/shares/{token}` +- `GET /api/v2/shares/{token}?download` +- `POST /api/v2/shares/{token}/verify-password` +- `/api/transfer/**` +- `GET /api/files/share-links/{token}` +- `/` -- `code = 0`:成功 -- `code = 1000`:参数校验失败 -- `code = 1001`:未登录 -- `code = 1002`:权限不足 -- `code = 1003`:业务对象不存在、邀请码错误、取件码失效等业务失败 +注意: -### 鉴权方式 +- `/api/transfer/**` 在安全层面是公开的,但其中“发送离线快传”“查看自己的离线快传”“导入到网盘”等操作会在控制器或业务层再要求登录。 -- 采用 `Authorization: Bearer ` -- `refreshToken` 通过 `/api/auth/refresh` 换取新的登录态 -- 当前实现为“按客户端类型拆分会话” - - 桌面端与移动端可以同时在线 - - 同一端类型再次登录后,该端旧 access token 会失效 - - `/api/auth/register`、`/api/auth/login`、`/api/auth/refresh` 与开发环境 `/api/auth/dev-login` 支持可选请求头 `X-Yoyuzh-Client: desktop|mobile` +### 1.2 登录后接口 -### 权限分层 +需要 Bearer Token: -- 公开接口: - - `/api/auth/**` - - `/api/transfer/**` - - `GET /api/files/share-links/{token}` -- 登录后接口: - - `/api/user/**` - - `/api/files/**` - - `/api/admin/**` +- `/api/user/**` +- `/api/files/**` +- `/api/v2/files/**` +- `/api/v2/tasks/**` +- `POST /api/v2/shares` +- `GET /api/v2/shares/mine` +- `DELETE /api/v2/shares/{id}` +- `POST /api/v2/shares/{token}/import` -## 2. 认证模块 +### 1.3 管理接口 + +- `/api/admin/**` + +要求: + +- 已登录 +- 通过 `@adminAccessEvaluator.isAdmin(authentication)` 校验 + +当前管理员事实源: + +- 当前由 `@adminAccessEvaluator.isAdmin(authentication)` 统一判定。 +- 实际判定基于认证里的角色 authority,而不是用户名白名单。 +- 当前允许进入 `/api/admin/**` 的角色是 `ROLE_MODERATOR` 与 `ROLE_ADMIN`。 +- 因此这里已经不再依赖 `app.admin.usernames` 作为管理权限来源。 + +## 2. 认证与用户模块 控制器: @@ -57,196 +69,264 @@ - `backend/src/main/java/com/yoyuzh/auth/DevAuthController.java` - `backend/src/main/java/com/yoyuzh/auth/UserController.java` -### 2.1 注册 +### 2.1 `POST /api/auth/register` -`POST /api/auth/register` +用途: -说明: +- 注册并直接签发登录态 -- 使用邀请码注册 -- 注册成功后直接返回登录态 -- 邀请码成功使用后会自动刷新 -- 若请求未显式带 `X-Yoyuzh-Client`,后端默认按 `desktop` 处理 +关键规则: -请求重点字段: +- 需要邀请码 +- 用户名、邮箱、手机号必须唯一 +- 成功注册后自动创建默认目录 +- 支持可选请求头 `X-Yoyuzh-Client: desktop|mobile` -- `username` -- `email` -- `phoneNumber` -- `password` -- `confirmPassword` -- `inviteCode` +### 2.2 `POST /api/auth/login` -### 2.2 登录 +用途: -`POST /api/auth/login` +- 普通登录 -请求字段: +关键规则: -- `username` -- `password` +- 支持可选 `X-Yoyuzh-Client` +- 同一用户桌面端和移动端可以同时在线 +- 同一客户端类型再次登录会挤掉旧会话 -返回字段: +### 2.3 `POST /api/auth/refresh` -- `token` -- `accessToken` -- `refreshToken` -- `user` +用途: -补充说明: +- 刷新 access token 和 refresh token -- 可选请求头 `X-Yoyuzh-Client` 用于声明当前登录来自桌面端还是移动端 -- 同账号桌面端与移动端可同时保持登录,但同类型端再次登录会顶掉旧会话 +关键规则: -### 2.3 刷新登录态 +- refresh token 会旋转,旧 token 失效 +- 默认按 `desktop` 兜底 +- 若 refresh token 本身带有客户端类型,则沿用原客户端类型 -`POST /api/auth/refresh` +### 2.4 `POST /api/auth/dev-login` -请求字段: +用途: -- `refreshToken` +- 开发环境免密登录 -说明: +约束: -- 刷新后会返回新的 access token 与 refresh token -- 当前系统会让旧 refresh token 失效 -- 刷新会沿用该 refresh token 原本所属的客户端类型;请求头缺省时仍按 `desktop` 兜底 +- 仅 `dev` profile 下可用 +- 支持可选 `X-Yoyuzh-Client` -### 2.4 开发环境登录 +### 2.5 `GET /api/user/profile` -`POST /api/auth/dev-login` +用途: -说明: +- 获取当前登录用户资料 -- 仅用于开发联调 -- 是否可用取决于当前环境配置 -- 同样支持可选请求头 `X-Yoyuzh-Client: desktop|mobile` +### 2.6 `PUT /api/user/profile` -### 2.5 Android 客户端更新信息 +用途: -`GET /api/app/android/latest` +- 修改个人资料 -说明: +关键规则: -- 公开接口,不需要登录 -- 返回当前 Android 安装包下载地址、文件名和最新发布时间 -- 后端会先读取文件桶中的 `android/releases/latest.json` 元数据,再返回当前 APK 对应的后端下载地址 -- 安卓端原生壳应通过该接口检查更新 +- 邮箱、手机号仍需保持唯一 -### 2.6 Android 客户端下载入口 +### 2.7 `POST /api/user/password` -`GET /api/app/android/download` +用途: -说明: +- 用户自行修改密码 -- 公开接口,不需要登录 -- 该接口会直接回传当前最新 APK 的字节流,并通过 `Content-Disposition` 指定带版本号的文件名 -- Web 端总览页应直接使用这个公开下载入口,而不是直接访问对象存储路径 +关键规则: -### 2.7 获取用户资料 +- 旧密码必须正确 +- 修改后旧会话和旧 refresh token 失效 +- 会重新签发新的登录态 -`GET /api/user/profile` - -### 2.8 更新用户资料 - -`PUT /api/user/profile` - -### 2.9 修改密码 - -`POST /api/user/password` - -说明: - -- 成功后会重新签发新的登录态 -- 同时会顶掉旧设备会话 - -### 2.10 头像相关 +### 2.8 头像接口 - `POST /api/user/avatar/upload/initiate` - `POST /api/user/avatar/upload` - `POST /api/user/avatar/upload/complete` - `GET /api/user/avatar/content` -说明: +用途: -- 支持初始化直传 -- 支持代理上传 -- 最终通过完成接口落库 +- 用户头像上传与读取 -## 3. 网盘模块 +关键规则: + +- 头像属于资料域,不属于普通网盘目录 +- 支持直传初始化和代理上传 +- 完成上传时会替换旧头像引用 + +## 3. Android 发布接口 + +控制器: + +- `backend/src/main/java/com/yoyuzh/config/AndroidReleaseController.java` + +### 3.1 `GET /api/app/android/latest` + +用途: + +- 获取最新 Android 安装包元信息 + +特点: + +- 公开接口 +- 读取后端当前发布元数据 + +### 3.2 下载接口 + +- `GET /api/app/android/download` +- `GET /api/app/android/download/{fileName}` + +用途: + +- 下载当前或指定名称的 Android 安装包 + +特点: + +- 公开接口 + +## 4. 网盘主接口(旧主线,仍在使用) 控制器: - `backend/src/main/java/com/yoyuzh/files/core/FileController.java` -### 3.1 上传相关 +这组接口仍然是当前网盘主业务入口,尤其是: + +- 列表 +- 目录创建 +- 文件操作 +- 下载 +- 回收站 +- 旧分享接口 + +### 4.1 上传接口 - `POST /api/files/upload` - `POST /api/files/upload/initiate` - `POST /api/files/upload/complete` -说明: +用途: -- 兼容普通上传和 OSS 直传 -- 前端会优先尝试“初始化上传 -> 直传/代理 -> 完成上传” -- `upload/initiate` 返回的 `storageName` 现在是一次上传对应的 opaque blob object key;新文件会落到全局 `blobs/...` key,而不是用户目录路径 key -- `upload/complete` 必须回传这个 opaque blob key,后端会据此创建 `FileBlob` 并把新 `StoredFile` 绑定到该 blob +- 兼容旧上传流程 -### 3.2 目录与列表 +当前定位: + +- 仍可用 +- 但新上传主线已经转向 v2 上传会话 + +关键规则: + +- 路径和文件名必须合法 +- 同目录禁止重名 +- 受系统限制、用户限制、默认存储策略限制共同约束 + +### 4.2 目录与列表 - `POST /api/files/mkdir` - `GET /api/files/list` - `GET /api/files/recent` -说明: +用途: -- `list` 支持 `path`、`page`、`size` -- 当前前端会在网盘页缓存目录内容和最后访问路径 +- 创建目录 +- 分页列目录 +- 最近文件 -### 3.3 下载 +关键规则: + +- `path` 必须是合法目录路径 +- 创建目录时根目录 `/` 不能再次创建 + +### 4.3 下载 - `GET /api/files/download/{fileId}` - `GET /api/files/download/{fileId}/url` -说明: +用途: -- 普通文件优先获取下载 URL -- 文件夹可走 ZIP 下载 -- 私有 `apk/ipa` 下载会返回一个短时有效的 `https://api.yoyuzh.xyz/_dl/...` URL;该 URL 由 Nginx 按签名和过期时间校验后代理到对象存储自定义下载域名,不是长期可复用的公开直链 +- 下载文件或目录压缩包 +- 获取可直接下载的 URL -### 3.4 文件操作 +关键规则: + +- 目录下载会被压缩为 zip +- 普通文件优先返回直链或重定向 +- 特定公开安装包走专用下载逻辑 + +### 4.4 文件操作 - `PATCH /api/files/{fileId}/rename` - `PATCH /api/files/{fileId}/move` - `POST /api/files/{fileId}/copy` - `DELETE /api/files/{fileId}` + +用途: + +- 重命名 +- 移动 +- 复制 +- 删除 + +关键规则: + +- `DELETE` 语义是“移入回收站”,不是立即物理删除 +- 目录不能移动或复制到自己或自己的子目录 +- 所有写操作都要经过同名冲突和配额判断 + +### 4.5 回收站 + - `GET /api/files/recycle-bin` - `POST /api/files/recycle-bin/{fileId}/restore` -说明: +用途: -- `move` 用于移动到目标路径 -- `copy` 用于复制到目标路径 -- 文件和文件夹都支持移动 / 复制 -- 普通文件的 `move` / `rename` / `copy` 只改逻辑元数据;`copy` 会复用原有 `FileBlob`,不会复制底层对象 -- `DELETE /api/files/{fileId}` 现在语义是“移入回收站”,不会立刻物理删除;删除的文件或整个目录树会保留 10 天 -- `GET /api/files/recycle-bin` 返回当前用户回收站根条目分页列表,包含删除时间和预计清理时间 -- `POST /api/files/recycle-bin/{fileId}/restore` 用于把某个回收站根条目恢复到原目录;若原位置已有同名文件,或当前剩余空间不足,则恢复失败 +- 查看回收站根条目 +- 恢复回收站条目 -### 3.5 分享链接 +关键规则: + +- 恢复按整组恢复 +- 恢复前检查原路径冲突和配额 + +### 4.6 旧分享接口 - `POST /api/files/{fileId}/share-links` - `GET /api/files/share-links/{token}` - `POST /api/files/share-links/{token}/import` -说明: +用途: -- 已登录用户可为自己的文件或文件夹创建分享链接 -- 公开访客可查看分享详情 -- 登录用户可将分享内容导入自己的网盘 -- 普通文件导入时会新建自己的 `StoredFile` 并复用源 `FileBlob`,不会再次写入物理文件 +- 旧版分享创建、查看、导入 -### 3.6 v2 上传会话 +当前定位: + +- 仍存在 +- 但新分享主线已经转向 `/api/v2/shares/**` + +注意: + +- 旧接口和 v2 分享接口能力并不完全一致 +- 后续开发应优先确认是否继续沿用这套接口 + +## 5. v2 文件接口 + +控制器: + +- `backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Controller.java` +- `backend/src/main/java/com/yoyuzh/api/v2/files/FileSearchV2Controller.java` +- `backend/src/main/java/com/yoyuzh/api/v2/files/FileEventsV2Controller.java` + +这组接口当前承担的是“新能力”而不是完整替代旧 `/api/files/**`。 + +### 5.1 v2 上传会话 - `POST /api/v2/files/upload-sessions` - `GET /api/v2/files/upload-sessions/{sessionId}` @@ -257,156 +337,282 @@ - `PUT /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}` - `POST /api/v2/files/upload-sessions/{sessionId}/complete` -说明: +用途: -- 需要登录,只允许操作当前用户自己的上传会话 -- 会话响应返回 `sessionId`、`objectKey`、`directUpload`、`multipartUpload`、`uploadMode`、`path`、`filename`、`contentType`、`size`、`storagePolicyId`、`status`、`chunkSize`、`chunkCount`、`expiresAt`、`createdAt`、`updatedAt`,以及一个面向前端消费的 `strategy` 对象 -- `uploadMode` 目前有三种:`PROXY`、`DIRECT_SINGLE`、`DIRECT_MULTIPART` -- 默认 S3 存储策略下,创建会话时会立即初始化 multipart upload,并把 `directUpload=true`、`multipartUpload=true`、`uploadMode=DIRECT_MULTIPART` 返回给客户端;若默认策略 `directUpload=true` 但 `multipartUpload=false`,会返回 `DIRECT_SINGLE`;若 `directUpload=false`,则返回 `PROXY` -- `strategy` 会把当前会话下一步该调用的后端入口显式返回出来:`DIRECT_SINGLE` 返回 `prepareUrl` + `completeUrl`,`PROXY` 返回 `proxyContentUrl` + `proxyFormField=file` + `completeUrl`,`DIRECT_MULTIPART` 返回 `partPrepareUrlTemplate`、`partRecordUrlTemplate` 和 `completeUrl` -- `GET /{sessionId}/prepare` 仅用于 `DIRECT_SINGLE`,返回整文件直传所需的 `direct/uploadUrl/method/headers/storageName` -- `POST /{sessionId}/content` 仅用于 `PROXY`,以 multipart 表单上传整文件内容到当前 upload session 绑定的 `objectKey` -- `GET /parts/{partIndex}/prepare` 会返回当前分片的直传信息:`direct`、`uploadUrl`、`method`、`headers`、`storageName` -- `PUT /parts/{partIndex}` 请求体仍为 `{ "etag": "...", "size": 8388608 }`,只负责记录 part 元数据,不直接接收字节流 -- `POST /complete` 会先按已记录的 part 元数据提交 multipart complete,再复用旧上传完成链路写入 `FileBlob + StoredFile + FileEntity.VERSION` -- 后端每小时清理过期且未完成的会话;若会话已绑定 multipart upload,会优先向对象存储发送 abort -- 当前前端网盘上传主链路已经消费这套 v2 接口:桌面/移动文件页和“存入网盘”入口都会按 `uploadMode + strategy` 自动选择代理上传、单请求直传或 multipart 分片上传 +- 统一上传会话生命周期 -## 4. 快传模块 +上传模式: + +- `PROXY` +- `DIRECT_SINGLE` +- `DIRECT_MULTIPART` + +关键规则: + +- 上传模式由默认存储策略能力决定 +- 会话只属于创建者 +- 已完成、已取消、已过期、已失败会话不能继续上传 +- multipart 必须先准备分片上传,再记录分片,再完成会话 + +### 5.2 文件搜索 + +- `GET /api/v2/files/search` + +用途: + +- 组合条件搜索当前用户文件 + +支持条件: + +- 名称 +- 类型 `file|directory|all` +- 大小上下界 +- 创建时间上下界 +- 更新时间上下界 +- 分页 + +### 5.3 文件事件流 + +- `GET /api/v2/files/events` + +用途: + +- SSE 订阅文件变更事件 + +关键参数: + +- `path`,默认 `/` +- 可选请求头 `X-Yoyuzh-Client-Id` + +## 6. v2 分享接口 + +控制器: + +- `backend/src/main/java/com/yoyuzh/api/v2/shares/ShareV2Controller.java` + +这是当前更完整的分享接口组。 + +### 6.1 创建与管理 + +- `POST /api/v2/shares` +- `GET /api/v2/shares/mine` +- `DELETE /api/v2/shares/{id}` + +用途: + +- 创建分享 +- 查看自己创建的分享 +- 删除自己的分享 + +关键规则: + +- 当前只支持文件,不支持目录 +- 可设置密码、过期时间、最大次数、是否允许下载、是否允许导入 + +### 6.2 公共访问 + +- `GET /api/v2/shares/{token}` +- `GET /api/v2/shares/{token}?download` +- `POST /api/v2/shares/{token}/verify-password` + +用途: + +- 查看分享 +- 下载分享 +- 验证密码 + +关键规则: + +- 公开可访问 +- 过期或次数耗尽后失效 +- 有密码时需要先验密,或下载时携带密码 + +### 6.3 导入分享 + +- `POST /api/v2/shares/{token}/import` + +用途: + +- 把分享文件导入自己的网盘 + +关键规则: + +- 必须登录 +- 受 `allowImport` 开关控制 +- 当前实现中会消耗同一个分享次数额度 + +## 7. 快传接口 控制器: - `backend/src/main/java/com/yoyuzh/transfer/TransferController.java` -### 4.1 创建会话 +### 7.1 会话创建与查询 -`POST /api/transfer/sessions` +- `POST /api/transfer/sessions` +- `GET /api/transfer/sessions/lookup` +- `POST /api/transfer/sessions/{sessionId}/join` -说明: +用途: -- 在线快传会话允许未登录用户创建 -- 离线快传会话仍要求发送端登录 -- 请求体必须区分 `mode` - - `ONLINE`: 在线快传,15 分钟有效,只能被接收一次 - - `OFFLINE`: 离线快传,7 天有效,文件会落到站点存储并可被重复接收 -- 返回会话 ID、取件码、模式、过期时间和文件清单 +- 创建在线或离线快传会话 +- 通过取件码查找会话 +- 加入在线会话或确认离线会话 -### 4.2 通过取件码查找会话 +关键规则: -`GET /api/transfer/sessions/lookup?pickupCode=xxxxxx` +- 创建在线快传允许匿名 +- 创建离线快传要求登录 +- 在线快传只允许首个接收者加入 -说明: +### 7.2 离线快传文件 -- 接收端通过 6 位取件码查找会话 -- 在线快传和离线快传都允许未登录用户查找 +- `GET /api/transfer/sessions/offline/mine` +- `POST /api/transfer/sessions/{sessionId}/files/{fileId}/content` +- `GET /api/transfer/sessions/{sessionId}/files/{fileId}/download` +- `POST /api/transfer/sessions/{sessionId}/files/{fileId}/import` -### 4.3 加入会话 +用途: -`POST /api/transfer/sessions/{sessionId}/join` +- 查看自己的离线快传记录 +- 上传离线快传文件 +- 下载离线快传文件 +- 导入离线快传文件到网盘 -说明: +关键规则: -- 在线快传会占用一次性会话 -- 离线快传返回可下载文件清单,不需要建立 P2P 通道 -- 在线快传和离线快传都允许未登录用户加入 +- 查看自己的离线快传记录需要登录 +- 上传离线快传文件需要登录且必须是发送者本人 +- 下载可匿名,但会话必须已 `ready` +- 导入需要登录 -### 4.4 信令交换 +### 7.3 在线快传信令 - `POST /api/transfer/sessions/{sessionId}/signals` - `GET /api/transfer/sessions/{sessionId}/signals` -说明: +用途: -- 后端负责 WebRTC 信令交换 -- 文件内容本身不经过后端 -- 实际文件通过浏览器 DataChannel 进行 P2P 传输 -- 该组接口仅用于 `ONLINE` 模式 +- WebRTC/在线快传信令交换 -### 4.5 查看我的离线快传记录 +特点: -`GET /api/transfer/sessions/offline/mine` +- 公开访问 +- 仅对在线会话有意义 -说明: +## 8. v2 后台任务接口 -- 需要登录 -- 返回当前用户未过期的离线快传会话列表 -- 每个会话包含取件码、有效期和文件清单,前端可据此重新展示二维码与分享链接 +控制器: -### 4.6 上传离线快传文件 +- `backend/src/main/java/com/yoyuzh/api/v2/tasks/BackgroundTaskV2Controller.java` -`POST /api/transfer/sessions/{sessionId}/files/{fileId}/content` +### 8.1 列表与详情 -说明: +- `GET /api/v2/tasks` +- `GET /api/v2/tasks/{id}` -- 需要发送端登录 -- 发送端把离线文件内容上传到站点存储 -- 线上环境会把离线文件落到对象存储 +用途: -### 4.6 下载离线快传文件 +- 查看当前用户自己的后台任务 -`GET /api/transfer/sessions/{sessionId}/files/{fileId}/download` +### 8.2 取消与重试 -说明: +- `DELETE /api/v2/tasks/{id}` +- `POST /api/v2/tasks/{id}/retry` -- 不需要登录 -- 离线文件在有效期内可以被重复下载 +用途: -### 4.7 存入网盘 +- 取消自己的任务 +- 重试失败任务 -`POST /api/transfer/sessions/{sessionId}/files/{fileId}/import` +关键规则: -说明: +- 只有失败任务可以重试 -- 需要登录 -- 把离线快传文件导入到当前用户网盘 +### 8.3 创建任务 -## 5. 管理台模块 +- `POST /api/v2/tasks/archive` +- `POST /api/v2/tasks/extract` +- `POST /api/v2/tasks/media-metadata` + +用途: + +- 为当前用户文件创建归档、解压、媒体元数据任务 + +关键规则: + +- 任务目标文件必须属于当前用户 +- 提交的 `path` 必须与文件当前逻辑路径一致 +- 不同任务类型对文件类型有额外要求 + +## 9. 管理接口 控制器: - `backend/src/main/java/com/yoyuzh/admin/AdminController.java` -### 5.1 总览 +当前管理端覆盖的是真实治理能力,不只是统计看板。 -`GET /api/admin/summary` +### 9.1 概览与系统设置 -返回内容包括: +- `GET /api/admin/summary` +- `GET /api/admin/settings` +- `PATCH /api/admin/settings/registration/invite-code` +- `POST /api/admin/settings/registration/invite-code/rotate` +- `GET /api/admin/filesystem` +- `PATCH /api/admin/settings/offline-transfer-storage-limit` -- 用户总数 -- 文件总数 -- 当前邀请码 -- 今日请求次数 -- 今日按小时请求折线图 -- 最近 7 天每日上线人数和用户名单 -- 当前离线快传占用与上限 +用途: -补充说明: +- 查看全局指标 +- 查看系统设置快照 +- 修改邀请码 +- 轮换邀请码 +- 查看文件系统与存储能力 +- 修改离线快传总容量上限 -- `requestTimeline` 现在只返回当天已经过去的小时,例如当天只到 `07:xx` 时只会返回 `00:00` 到 `07:00` -- `dailyActiveUsers` 固定返回最近 7 天,按日期升序排列;每项包含日期、展示标签、当天去重后的上线人数和用户名列表 -- “上线”定义为用户成功通过 JWT 鉴权访问受保护接口后的当天首次记录 +注意: -### 5.2 用户管理 +- 当前管理台“可写设置”边界很窄,很多设置只是只读快照 + +### 9.2 用户治理 - `GET /api/admin/users` - `PATCH /api/admin/users/{userId}/role` - `PATCH /api/admin/users/{userId}/status` - `PUT /api/admin/users/{userId}/password` +- `PATCH /api/admin/users/{userId}/storage-quota` +- `PATCH /api/admin/users/{userId}/max-upload-size` - `POST /api/admin/users/{userId}/password/reset` -说明: +用途: -- 可调整用户角色 -- 可封禁用户 -- 可重置或直接设置密码 -- 封禁/改密会使原登录态失效 +- 用户查询与治理 -### 5.3 文件管理 +关键规则: + +- 封禁用户会让旧登录态失效 +- 管理员修改用户密码会让旧登录态失效 +- 密码仍需满足密码规则 + +### 9.3 文件、分享、任务、实体 - `GET /api/admin/files` - `DELETE /api/admin/files/{fileId}` +- `GET /api/admin/file-blobs` +- `GET /api/admin/shares` +- `DELETE /api/admin/shares/{shareId}` +- `GET /api/admin/tasks` +- `GET /api/admin/tasks/{taskId}` -### 5.4 存储策略 +用途: + +- 全局文件治理 +- 全局分享治理 +- 全局任务治理 +- 全局文件实体 / blob 视图 + +### 9.4 存储策略 - `GET /api/admin/storage-policies` - `POST /api/admin/storage-policies` @@ -414,341 +620,80 @@ - `PATCH /api/admin/storage-policies/{policyId}/status` - `POST /api/admin/storage-policies/migrations` -说明: +用途: -- 需要管理员登录 -- 返回当前存储策略列表和结构化能力声明 -- 新增/编辑接口当前允许维护名称、类型、bucket/endpoint/region、前缀、凭证模式、最大对象大小、能力声明与启用状态 -- `PATCH /status` 用于启用或停用非默认策略;默认策略不能被停用 -- `POST /migrations` 需要管理员登录,请求体为 `sourcePolicyId`、`targetPolicyId` 与可选 `correlationId`;当前会创建一个 `STORAGE_POLICY_MIGRATION` 后台任务,返回值沿用 `/api/v2/tasks/{id}` 的任务响应形状 -- 当前迁移任务会在“当前活动存储后端”内执行真实对象迁移:复制旧对象到新的 target-policy object key,更新 `FileBlob` 与 `FileEntity.VERSION`,并在事务提交后清理旧对象;如果源/目标策略类型与当前运行时存储后端不匹配,任务会失败 -- 当前仍不支持删除策略、切换默认策略或通过管理接口暴露实际凭证内容 -- `capabilities.multipartUpload` 现在会反映默认策略是否支持 v2 上传会话 multipart;当前默认 S3 策略为 `true`,本地策略为 `false` +- 查询、创建、修改、启停存储策略 +- 创建迁移任务 -## 6. 前端公开路由与接口关系 +关键规则: -前端入口在: +- 默认策略不能被禁用 +- 迁移当前只支持同运行时后端类型 +- 目标策略必须已启用 -- `front/src/App.tsx` +## 10. 站点健康接口 -主要页面: +控制器: -- `/login` -- `/overview` -- `/files` -- `/transfer` -- `/share/:token` -- `/admin/*` +- `backend/src/main/java/com/yoyuzh/api/v2/site/SiteV2Controller.java` +- `backend/src/main/java/com/yoyuzh/config/ApiRootController.java` -接口关系: +### 10.1 `GET /api/v2/site/ping` -- 登录页:调用 `/api/auth/login`、`/api/auth/register` -- 网盘页:调用 `/api/files/**` -- 快传页:调用 `/api/transfer/**` -- 分享页:调用 `/api/files/share-links/{token}` 和导入接口 -- 管理台:调用 `/api/admin/**` +用途: -## 7. 建议阅读顺序 +- v2 站点健康检查 -后续新窗口如果要接手后端功能,建议按这个顺序看: +### 10.2 `GET /` -1. `memory.md` -2. `docs/architecture.md` -3. `docs/api-reference.md` -4. `AGENTS.md` -5. `CLAUDE.md` -6. `backend/src/main/java/com/yoyuzh/config/SecurityConfig.java` -7. 对应业务模块的 `Controller + Service` +用途: -补充说明: +- 根路径可用性检查 -- 根目录 `.env` 现在是本地密钥和部署参数的统一入口 -- 额外的交接背景可查看 `docs/agents/handoff.md` -## 2026-04-08 API v2 阶段 1 补充 +## 11. 当前接口演进状态 -`GET /api/v2/site/ping` +### 11.1 当前稳定主线 -说明: +- 认证与用户资料:`/api/auth/**`、`/api/user/**` +- 网盘主操作:`/api/files/**` +- 快传:`/api/transfer/**` +- 管理台:`/api/admin/**` +- 新分享主线:`/api/v2/shares/**` +- 新上传主线:`/api/v2/files/upload-sessions/**` +- 新任务主线:`/api/v2/tasks/**` -- 公开接口,不需要登录。 -- 当前是 v2 API 的最小边界探针,返回结构为 `{ "code": 0, "msg": "success", "data": { "status": "ok", "apiVersion": "v2" } }`。 -- v2 错误响应开始使用独立 `ApiV2ErrorCode` 范围;旧 `/api/**` 接口暂不迁移。 -- 前端访问 v2 接口时可通过 `apiV2Request()` 自动拼接 `/api/v2/**`,内部请求会携带 `X-Yoyuzh-Client-Id`。 +### 11.2 当前并存边界 -## 2026-04-08 文件实体模型二期第一小步 +- 旧分享:`/api/files/share-links/**` +- 新分享:`/api/v2/shares/**` +- 旧上传:`/api/files/upload/**` +- 新上传:`/api/v2/files/upload-sessions/**` -- 本阶段只新增后端实体和迁移映射,不新增对外 API。 -- 旧 `/api/files/**`、分享、回收站、快传接口继续使用现有 DTO 和响应结构。 -- `StoredFile.primaryEntity` 与 `portal_stored_file_entity` 目前只作为兼容迁移数据,后续阶段稳定后再切换新读写路径。 +这些边界当前是“并存”,不是“完全迁移完成”。 -## 2026-04-08 文件实体模型二期第二小步 +## 12. 接口层已知风险 -- 本阶段不新增对外 API,`/api/files/**`、分享、回收站、快传导入等响应结构保持不变。 -- 后端在旧接口内部开始双写实体模型:上传完成、外部导入、分享导入和网盘复制会继续写 `FileBlob`,同时创建或复用 `FileEntity.VERSION`,并写入 `StoredFile.primaryEntity` 与 `StoredFileEntity(PRIMARY)`。 -- 下载、分享详情、回收站、ZIP 下载仍读取 `StoredFile.blob`;后续阶段稳定后再切换到 `primaryEntity` 读取。 -- 2026-04-08 阶段 3 第一小步 API 补充:新增受保护的 v2 上传会话接口族,`POST /api/v2/files/upload-sessions` 创建会话,`GET /api/v2/files/upload-sessions/{sessionId}` 查询当前用户自己的会话,`DELETE /api/v2/files/upload-sessions/{sessionId}` 取消会话。当前响应会返回 `sessionId`、`objectKey`、`multipartUpload`、路径、文件名、状态、分片大小、分片数量和时间字段。 -- 2026-04-08 阶段 3 第二小步 API 补充:`POST /api/v2/files/upload-sessions/{sessionId}/complete` 用于把当前用户自己的上传会话提交完成。当前默认 S3 策略下,该接口会先完成 multipart 合并,再复用旧上传完成链路落库,成功后返回 `COMPLETED` 状态的 v2 会话响应。 -- 2026-04-08 阶段 3 第三小步 API 补充:`PUT /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}` 请求体为 `{ "etag": "...", "size": 8388608 }`,用于记录当前用户上传会话的 part 元数据并返回 v2 会话响应;`GET /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}/prepare` 则返回该分片的直传地址和请求头。字节流仍直接上传到对象存储,不经过后端转发。 -- 2026-04-08 阶段 3 第四小步 API 补充:本小步没有新增额外资源类型。后端新增上传会话过期清理任务,只处理未完成且已过期的会话,并把它们标记为 `EXPIRED`;若会话绑定了 multipart upload,还会在清理时发起 abort。 -- 2026-04-08 阶段 4 第一小步 API 补充:本小步没有新增存储策略管理 API。v2 上传会话响应新增 `storagePolicyId`,用于标识该会话绑定的默认存储策略;该字段现在也用于区分会话是否应按策略能力走 multipart 上传。 +1. 管理员权限规则不由单一领域字段决定,容易在前后端或测试里误判。 +2. 旧分享和 v2 分享并存,后续加需求时容易加错接口组。 +3. 旧上传与 v2 上传会话并存,规则变更时容易只改一边。 +4. `/api/transfer/**` 安全层公开、业务层局部要求登录,这个边界必须在测试里显式覆盖。 +5. 当前没有一份单独文档明确“哪些接口是兼容保留,哪些接口是后续唯一主线”,后续若继续演进应优先补这条决策。 -## 2026-04-08 阶段 5 文件搜索第一小步 +## 13. 2026-04-11 Admin 审计能力补充 -`GET /api/v2/files/search` +管理台新增审计查询接口(只读): -说明: +- `GET /api/admin/audits` -- 需要登录,且只返回当前用户自己的未删除文件或目录。 -- 返回 v2 envelope,`data` 结构复用 `PageResponse`:`items`、`total`、`page`、`size`。 -- 支持查询参数:`name`、`type`、`sizeGte`、`sizeLte`、`createdGte`、`createdLte`、`updatedGte`、`updatedLte`、`page`、`size`。 -- `type` 支持 `file`、`directory`、`folder`、`all`;时间参数使用 ISO 日期时间格式,例如 `2026-04-08T12:00:00`。 -- 当前搜索只基于 `StoredFile` 固定字段,不启用标签或 metadata 条件过滤;旧 `/api/files/list` 与上传下载分享接口保持不变。 +用途: -## 2026-04-08 阶段 5 文件搜索第二小步 +- 查询管理端治理写操作的审计日志; +- 支持按 `actorQuery`、`actionType`、`targetType`、`targetId` 过滤; +- 返回字段包含 actor、action、target、summary、detailsJson、createdAt。 -- 前端通过 `front/src/lib/file-search.ts` 接入 `GET /api/v2/files/search`,该 helper 会拼接 `name`、`type`、`sizeGte/sizeLte`、`createdGte/createdLte`、`updatedGte/updatedLte`、`page`、`size`,并复用 `apiV2Request()` 的生产端点、认证与 client id 头。 -- `front/src/pages/Files.tsx` 的桌面端文件页新增独立搜索视图,搜索结果不写入 `getFilesListCacheKey(...)`,清空搜索后回到当前目录列表;移动端搜索尚未接入。 +当前接入审计记录的写路径: -## 2026-04-08 阶段 5 分享二期后端最小骨架 - -`POST /api/v2/shares` - -需要登录。 - -- 为当前用户自己的非目录文件创建分享。 -- 请求字段:`fileId`,以及可选的 `password`、`expiresAt`、`maxDownloads`、`allowImport`、`allowDownload`、`shareName`。 -- 密码只保存 hash,不在响应中返回。 - -`GET /api/v2/shares/{token}` - -公开访问。 - -- 返回分享摘要。 -- 如果分享设置了密码,在校验前不返回 `file` 详情。 - -`POST /api/v2/shares/{token}/verify-password` - -公开访问。 - -- 校验分享密码,成功后返回可读分享摘要。 -- 响应永不返回 `passwordHash`。 - -`POST /api/v2/shares/{token}/import` - -需要登录。 - -- 把分享文件导入当前用户网盘。 -- 分享过期、密码错误或未提供、`allowImport=false`、`maxDownloads` 已耗尽时拒绝导入。 - -`GET /api/v2/shares/mine` - -需要登录。 - -- 分页列出当前用户创建的分享。 - -`DELETE /api/v2/shares/{id}` - -需要登录。 - -- 只删除当前用户自己的分享。 - -- 旧 `/api/files/share-links/**` 接口保留兼容;当前 `allowDownload` 已落库并返回,但还没有独立 v2 下载路由消费它。 - -## 2026-04-08 阶段 5 文件事件流最小闭环 - -`GET /api/v2/files/events?path=/` - -说明: -- 需要登录,返回 `text/event-stream` -- 请求头支持 `X-Yoyuzh-Client-Id` -- 首次连接会先推送一个轻量 `READY` 事件 -- 事件写入 `FileEvent` 表,字段包含 `userId`、`eventType`、`fileId`、`fromPath`、`toPath`、`clientId`、`payloadJson`、`createdAt` -- 当前后端已做同用户广播、路径前缀过滤和同 `clientId` 自身事件抑制 -- 前端通过 `front/src/lib/file-events.ts` 以 fetch stream 订阅该 SSE,复用鉴权与 `X-Yoyuzh-Client-Id` 请求头;桌面 `Files` 与移动 `MobileFiles` 收到变更事件后会失效当前目录缓存并刷新当前目录列表 - -## 2026-04-08 阶段 6 任务框架与 worker 后端最小骨架 - -`GET /api/v2/tasks` - -需要登录。分页列出当前用户自己的后台任务。 - -`GET /api/v2/tasks/{id}` - -需要登录。只返回当前用户自己的任务详情。 - -`DELETE /api/v2/tasks/{id}` - -需要登录。取消当前用户自己的任务,`QUEUED` / `RUNNING` 会转为 `CANCELLED` 并写入 `finishedAt`,终态任务保持原样。 - -`POST /api/v2/tasks/{id}/retry` - -需要登录。仅允许当前用户重试自己处于 `FAILED` 的后台任务。 - -补充说明: - -- 成功后任务状态会重置为 `QUEUED` -- `finishedAt` 与 `errorMessage` 会被清空 -- `publicStateJson.phase` 会重置为 `queued` -- `publicStateJson.attemptCount` 会重置为 `0` -- 公开 state 会按服务端保存的 `privateStateJson` 重建,因此失败执行时写入的瞬时字段不会保留 -- 非 `FAILED` 任务调用会返回 `400` - -`POST /api/v2/tasks/archive` - -需要登录。创建 `ARCHIVE` 类型的 `QUEUED` 任务;`fileId` 必须属于当前用户且未删除,`path` 必须匹配服务端派生逻辑路径,暂允许文件和目录;当前 worker 会生成 zip 并把归档结果回写到原文件同级目录。 - -`POST /api/v2/tasks/extract` - -需要登录。创建 `EXTRACT` 类型的 `QUEUED` 任务;`fileId` 必须属于当前用户且未删除,`path` 必须匹配服务端派生逻辑路径,并拒绝目录和非压缩包类文件;当前 worker 只支持 zip-compatible 归档,会剥离共享根目录,并把解压结果恢复到原文件父目录。 - -`POST /api/v2/tasks/media-metadata` - -需要登录。创建 `MEDIA_META` 类型的 `QUEUED` 任务;`fileId` 必须属于当前用户且未删除,`path` 必须匹配服务端派生逻辑路径,并拒绝目录和非媒体类文件;worker 会重新按 `userId + fileId` 加载文件,写入 `media:contentType`、`media:size`,对 ImageIO 可识别图片额外写 `media:width` 和 `media:height`。当前仍不做缩略图、视频时长或前端任务面板。 - -补充说明: - -- worker 会定时领取少量 `QUEUED` 任务并切换为 `RUNNING`,完成后标记 `COMPLETED`,异常时标记 `FAILED` 并写入 `errorMessage`。 -- `publicStateJson.phase` 当前会经历 `queued -> running -> archiving/extracting/extracting-metadata -> completed/failed/cancelled` 这样的最小阶段流转。 -- `publicStateJson` 还会暴露 `attemptCount/maxAttempts`;当前默认预算为 `ARCHIVE=4`、`EXTRACT=3`、`MEDIA_META=2`。 -- 任务进入 `RUNNING` 后,`publicStateJson` 会额外暴露 `workerOwner/heartbeatAt/leaseExpiresAt/startedAt`,用于描述当前 worker 的 lease 和 heartbeat;终态或重排回队列后会移除运行态 owner/lease 字段。 -- `ARCHIVE/EXTRACT` 任务还会在 `publicStateJson` 里暴露 `processedFileCount/totalFileCount`、`processedDirectoryCount/totalDirectoryCount` 与真实 `progressPercent`;`MEDIA_META` 会额外暴露 `metadataStage`。 -- 当 worker 命中失败时,任务会按失败分类写入 `failureCategory`。`TRANSIENT_INFRASTRUCTURE`、`RATE_LIMITED` 与部分 `UNKNOWN` 失败会按任务类型退避自动重排回 `QUEUED`,并在 `publicStateJson` 写入 `retryScheduled=true`、`nextRetryAt`、`retryDelaySeconds`、`lastFailureMessage`、`lastFailureAt`;`UNSUPPORTED_INPUT` 与 `DATA_STATE` 这类确定性失败不会自动重试。 -- 已取消或其他终态任务不会被重新执行。 -- 服务重启后,只有 lease 已过期或历史上没有 lease 的 `RUNNING` 任务会在启动完成时被重置回 `QUEUED`,避免多实例下误抢仍在运行的 worker。 -- 创建成功后的任务 state 使用服务端文件信息,至少包含 `fileId`、`path`、`filename`、`directory`、`contentType`、`size`。 -- 桌面端 `Files` 页面会拉取最近 10 条任务、提供 `QUEUED/RUNNING` 取消按钮,并可为当前选中文件创建 `MEDIA_META` 任务;移动端与 archive/extract 的前端入口暂未接入。 - -## 2026-04-10 Redis Login-State Invalidation - -- 新增可选 Redis 基础设施配置: - - `spring.data.redis.*`:连接参数。 - - `app.redis.*`:业务 key prefix、TTL buffer、cache TTL 与命名空间。 -- 当 `app.redis.enabled=true` 时,认证链路会启用 Redis 驱动的登录态失效层: - - access token 按 `userId + clientType` 记录“在此时间点之前签发的 token 失效”。 - - refresh token 按 hash 写入黑名单,TTL 与剩余有效期对齐。 -- `POST /api/auth/login`、`POST /api/auth/register`、`POST /api/auth/dev-login`:如果是同客户端重新签发登录态,旧 access token 会被写入 Redis 失效层,并继续保留原有 `sid` 会话匹配语义。 -- `POST /api/user/password`、管理员封禁/改密/重置密码相关路径:会同时触发 access token Redis 失效标记与数据库 refresh token 撤销。 -- `POST /api/auth/refresh`:旧 refresh token 在数据库撤销之外,还会同步写入 Redis 黑名单;先命中黑名单的 token 会被直接拒绝。 -- 当 Redis 关闭时,系统会自动回退到原有的数据库 refresh token + `sid` 会话校验语义,不影响本地与 dev 启动。 -## 2026-04-10 Redis Files Cache And Upload Runtime - -- `GET /api/files/list` - - 对外语义不变,仍使用 `path`、`page`、`size` 参数返回当前用户目录分页结果。 - - 当 `app.redis.enabled=true` 时,后端会把热点目录页写入 Redis `files:list` cache,并通过目录版本号在创建、删除、移动、复制、重命名、恢复、上传完成和导入后做精准失效。 - - 搜索结果、回收站列表和后台任务列表不复用这套 key,避免不同语义的分页结果互相污染。 - -- `GET /api/v2/files/upload-sessions/{sessionId}` - - 响应体新增 `runtime` 字段;当 Redis 运行态存在时返回实时上传快照,不存在时返回 `null`,不影响原有会话元数据字段。 - - `runtime` 当前包含 `phase`、`uploadedBytes`、`uploadedPartCount`、`progressPercent`、`lastUpdatedAt`、`expiresAt`。 - - 该运行态由后端在会话创建、分片记录、代理上传、完成、取消、失败和过期时刷新,属于短生命周期缓存,不替代数据库里的最终状态。 - -- `POST /api/files/recycle-bin/{fileId}/restore` - - 外部接口不变,但 Redis 启用时后端会为同一 `fileId` 的恢复流程加分布式锁,避免多实例或并发请求重复恢复同一批条目。 - -## 2026-04-10 Redis Lightweight Broker First Landing - -- 本批次没有新增对外 HTTP API;用户可见接口仍沿用现有 `/api/files/**` 与 `/api/v2/tasks/**`。 -- 媒体文件通过网盘主链路落库后,后端现在会在事务提交后向轻量 broker 发布一次 `media-metadata-trigger`。这条触发只用于异步创建后台任务,不直接暴露为额外接口。 -- broker 当前只承载“自动补一条 `MEDIA_META` 任务”这一类轻量异步触发,最终执行状态、重试与公开结果仍以 `BackgroundTask` 记录和 `/api/v2/tasks/**` 查询结果为准。 -- `POST /api/v2/tasks/media-metadata` - - 用户手动创建任务的接口语义不变。 - - 与此同时,媒体文件成功落库后也可能由后端自动补一条同类任务;系统会按 `correlationId` 去重,避免同一文件被 broker 重复创建多条自动任务。 - -## 2026-04-10 Redis Transfer Session Store - -- 本批次没有新增快传 HTTP API,`/api/transfer/sessions`、`/api/transfer/sessions/lookup`、`/api/transfer/sessions/{sessionId}/join` 与信令轮询接口的对外协议保持不变。 -- 当 `app.redis.enabled=true` 时,在线快传 session 会写入 Redis `transfer-sessions` 命名空间,而不再只保存在当前 JVM 进程内;这让 `lookup/join/postSignal/pollSignals` 在多实例部署下具备共享同一在线会话状态的基础。 -- session 数据在 Redis 中会同时保存: - - `session:{sessionId}`:完整在线快传运行态快照。 - - `pickup:{pickupCode}`:`pickupCode -> sessionId` 映射。 -- Redis 关闭时,系统会自动回退到原有进程内存 store,本地和 dev 环境不需要额外 Redis 也能继续运行。 -- 离线快传不走这套 Redis store,仍继续使用数据库 `OfflineTransferSession` 持久化模型。 -## 2026-04-10 Redis File Event Pub/Sub - -- `GET /api/v2/files/events?path=/` - - 对外 SSE 协议不变,仍要求登录并支持 `X-Yoyuzh-Client-Id`。 - - 首次连接仍先收到 `READY` 事件,订阅路径过滤和同 `clientId` 自抑制语义保持不变。 - - 当 `app.redis.enabled=true` 时,某个实例在事务提交后写入的文件事件会额外通过 Redis pub/sub 广播到其他实例,因此同一用户连到不同后端实例时也能收到变更通知。 - - Redis pub/sub 只传播最小事件快照,不传播 `SseEmitter`、不重写 `FileEvent` 表,也不改变 `FileEvent` 作为审计持久化记录的角色。 - - Redis 关闭时会自动回退为原有单实例本地广播行为。 -## 2026-04-10 Spring Cache Minimal Landing - -- `GET /api/admin/storage-policies` - - 瀵瑰鍗忚涓嶅彉銆? - - 褰?`app.redis.enabled=true` 鏃讹紝鍚庣浼氬皢鏁翠釜瀛樺偍绛栫暐鍒楄〃缂撳瓨鍒?`admin:storage-policies`銆? - - 褰?POST/PUT/PATCH` 瀛樺偍绛栫暐绠$悊鎺ュ彛鍐欏叆鎴愬姛鍚庯紝缂撳瓨浼氱珛鍗宠澶辨晥锛屽悗缁璇锋眰浼氶噸寤烘柊鍒楄〃銆? - -- `GET /api/app/android/latest` - - 瀵瑰鍗忚涓嶅彉锛屼粛鏄叕寮€鎺ュ彛銆? - - 褰?`app.redis.enabled=true` 鏃讹紝鍚庣浼氬皢浠?`android/releases/latest.json` 鏋勫缓鍑虹殑 release metadata 鍝嶅簲缂撳瓨鍒?`android:release`銆? - - 杩欎釜缂撳瓨褰撳墠渚濊禆 TTL 鍒锋柊锛屽洜涓?latest metadata 鐨勬洿鏂版潵鑷?Android 鍙戝竷鑴氭湰鍐欏叆瀵硅薄瀛樺偍锛岃€屼笉鏄悗绔唴閮ㄦ煇涓鐞嗗啓鎺ュ彛銆? - -- `GET /api/admin/summary` - - 褰撳墠鏆備笉鎺ュ叆 Spring Cache銆? - - 鍘熷洜鏄繖涓?summary 鍚屾椂鍚湁 request count銆乧aily active users銆乭ourly timeline 绛夐珮棰戠粺璁″€硷紝鐢ㄦ樉寮忓け鏁堝緢闅惧湪褰撳墠鏋舵瀯涓嬩繚鎸佸共鍑€璇箟銆? -## 2026-04-10 Spring Cache Minimal Landing Clarification - -- `GET /api/admin/storage-policies` - - Response shape is unchanged. - - When `app.redis.enabled=true`, the backend caches the full storage policy list in `admin:storage-policies`. - - Successful storage policy create, update, and status-change writes evict that cache immediately. - -- `GET /api/app/android/latest` - - Response shape is unchanged. - - When `app.redis.enabled=true`, the backend caches the metadata response derived from `android/releases/latest.json` in `android:release`. - - Refresh is TTL-based because the metadata is updated by the Android release publish script rather than an in-app admin write endpoint. - -- `GET /api/admin/summary` - - This endpoint is intentionally not cached at the moment. - - The response mixes high-churn metrics such as request count, daily active users, and hourly request timeline data, so there is not yet a clean explicit invalidation boundary. -## 2026-04-10 DogeCloud Temporary S3 Session Clarification - -- No HTTP API contract changed in this batch. -- The decision for Step 11 is architectural: DogeCloud temporary S3 sessions remain cached per backend instance inside `DogeCloudS3SessionProvider`. -- This does not change upload, download, direct-upload, or multipart endpoint shapes; it only clarifies that cross-instance Redis reuse is intentionally not introduced for these temporary runtime sessions. -## 2026-04-10 Stage 1 Validation Clarification - -- No API response shape changed in Step 12. -- Validation confirmed that all new Redis-backed integrations added in Stage 1 still preserve the existing no-Redis API startup path when `app.redis.enabled=false`. -- Local boot also confirmed that the backend now has one explicit non-Redis prerequisite for runtime startup in both default and `dev` profiles: `app.jwt.secret` must be configured via `APP_JWT_SECRET` and cannot be left empty. -- Cross-instance behavior described by earlier Stage 1 notes remains architecturally valid, but it still needs real-environment verification with Redis plus multiple backend instances before being treated as deployment-proven. - -## 2026-04-10 Manual Redis Validation Addendum - -- No HTTP endpoint shape changed in this addendum either. -- The local two-instance Redis validation did confirm these existing API behaviors in a real runtime flow: -- `POST /api/auth/dev-login` on one instance invalidates the prior access token and refresh token even when the next authenticated read happens on the peer instance. -- `POST /api/transfer/sessions` plus `GET /api/transfer/sessions/lookup` continue to work across instances for online sessions, including after the creating instance is stopped. -- `GET /api/v2/files/events` on instance B receives a `CREATED` event after an authenticated media upload to instance A. -- `GET /api/v2/tasks` on instance B exposes the queued `MEDIA_META` task auto-created by that upload. -- Three backend fixes were internal and did not change API contracts: -- Redis cache serialization/deserialization for file list pages; -- Redis auth revocation cutoff precision; -- non-null `storage_name` persistence for directory creation and normal file upload metadata. - -## 2026-04-11 Admin Backend Surface Addendum - -- `GET /api/admin/file-blobs` - - Auth: admin only. - - Query params: `page`, `size`, `userQuery`, `storagePolicyId`, `objectKey`, `entityType`. - - Response items expose `FileEntity`-centric blob inspection fields including `objectKey`, `entityType`, `storagePolicyId`, `referenceCount`, `linkedStoredFileCount`, `linkedOwnerCount`, `sampleOwnerUsername`, `sampleOwnerEmail`, `createdByUserId`, `createdByUsername`, `blobMissing`, `orphanRisk`, and `referenceMismatch`. - - This endpoint is for admin diagnostics and migration visibility; it does not replace end-user file reads. - -- `GET /api/admin/shares` - - Auth: admin only. - - Query params: `page`, `size`, `userQuery`, `fileName`, `token`, `passwordProtected`, `expired`. - - Response items expose share metadata from `FileShareLink`, plus owner and file summary fields. - -- `DELETE /api/admin/shares/{shareId}` - - Auth: admin only. - - Deletes the target `FileShareLink` immediately. - - Intended for operational cleanup and moderation. - -- `GET /api/admin/tasks` - - Auth: admin only. - - Query params: `page`, `size`, `userQuery`, `type`, `status`, `failureCategory`, `leaseState`. - - Response items expose task owner identity plus parsed task-state helpers: `failureCategory`, `retryScheduled`, `workerOwner`, and derived `leaseState`. - -- `GET /api/admin/tasks/{taskId}` - - Auth: admin only. - - Returns the same admin task response shape as the list endpoint for a single task. +- 设置治理:邀请码更新/轮换、离线快传容量上限更新 +- 用户治理:角色、封禁、密码、配额、上传上限、重置密码 +- 资源治理:删除分享、删除文件 +- 存储治理:策略创建、更新、启停、迁移任务创建 diff --git a/docs/architecture.md b/docs/architecture.md index ebcf120..9e94484 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,645 +1,1164 @@ -# 架构文档 +# 企业级目标业务架构文档 -本文档用于描述 `yoyuzh.xyz` 当前的系统结构、模块边界、关键流程和部署方式,便于后续窗口快速建立整体上下文。 +本文档定义的是本项目后续重构应对齐的**目标态企业级业务架构**。 +如果当前实现与本文档不一致,以本文档作为未来架构和业务规则的基线。 -## 1. 系统概览 +本文档只讨论: -项目是一个前后端分离的全栈站点,核心由三部分组成: +- 业务目标 +- 领域划分 +- 核心业务对象 +- 模块职责 +- 核心流程 +- 统一业务规则 +- 状态定义与状态流转 +- 权限模型 -1. React 前端站点 -2. Spring Boot 后端 API -3. 文件存储层(本地文件系统或 S3 兼容对象存储) +本文档不讨论: -当前前端除了作为 Web 站点发布外,也已支持通过 Capacitor 打包成 Android WebView 壳应用。 +- 实施计划 +- 任务排期 +- 测试清单 +- 当前实现细节的迁移步骤 -业务主线已经从旧教务方向切换为: +## 1. 文档目标与适用范围 -- 账号系统 -- 个人网盘 +### 1.1 文档目标 + +这份文档用于建立一个可长期演进的企业级业务架构基线,使后续开发、重构、测试设计和 Code Review 都围绕同一套业务语义进行,而不是继续围绕局部实现细节做增量修补。 + +### 1.2 适用范围 + +本文适用于以下业务域: + +- 身份与访问控制 +- 用户工作空间 +- 文件内容资产 +- 分享与外部分发 - 快传 -- 管理台 +- 后台任务 +- 存储治理 +- 运营与管理 -## 2. 仓库结构与职责 +## 2. 系统定位 -### 2.1 前端 +系统定位为: -路径: +**以个人和组织成员文件资产为中心的统一文件服务平台** -- `front/` +平台的核心价值不是“存文件”本身,而是: -核心职责: +1. 统一身份 +2. 统一文件资产模型 +3. 统一分享与传输能力 +4. 统一治理、审计和存储策略 -- 页面路由与交互 -- 登录态管理 -- 网盘 UI 与缓存 -- 快传发/收流程 -- 管理台前端 -- 生产环境 API 基址拼装与调用 -- Android WebView 壳的静态资源承载与 Capacitor 同步 +平台面向三类对象: -关键入口: +- 最终用户 +- 运营/支持人员 +- 系统管理员 -- `front/src/App.tsx` -- `front/src/MobileApp.tsx` -- `front/src/main.tsx` -- `front/src/lib/api.ts` -- `front/src/components/layout/Layout.tsx` -- `front/capacitor.config.ts` -- `front/android/` +## 3. 架构原则 -主要页面: +### 3.1 业务规则必须有唯一归属 -- `front/src/pages/Login.tsx` -- `front/src/pages/Overview.tsx` -- `front/src/pages/Files.tsx` -- `front/src/pages/Transfer.tsx` -- `front/src/pages/TransferReceive.tsx` -- `front/src/pages/FileShare.tsx` -- `front/src/mobile-pages/*` +每一条核心业务规则都必须有唯一归属域,不允许在多个 Service、多个控制器或多个前端页面中各自实现一套。 -### 2.2 后端 +### 3.2 逻辑文件与物理内容分离 -路径: +用户看到的文件、目录、分享、回收站等都属于**逻辑资产层**。 +对象存储中的字节内容、版本、缩略图、转码产物等属于**内容资产层**。 -- `backend/` +### 3.3 权限模型独立于页面和接口 -核心职责: +权限必须由统一授权模型决定,而不是由页面入口、控制器路径或配置白名单临时拼接。 -- 认证与 JWT 鉴权 -- 网盘元数据与文件流转 -- 快传信令与会话状态 -- 管理台 API -- S3 兼容对象存储 / 本地存储抽象 +### 3.4 同类能力统一入口 -后端包结构: +分享、上传、后台任务、管理设置等同类能力必须有统一入口和统一语义,不允许长期双轨并存。 -- `com.yoyuzh.auth` -- `com.yoyuzh.files.core` -- `com.yoyuzh.files.upload` -- `com.yoyuzh.files.share` -- `com.yoyuzh.files.search` -- `com.yoyuzh.files.events` -- `com.yoyuzh.files.tasks` -- `com.yoyuzh.files.storage` -- `com.yoyuzh.files.policy` -- `com.yoyuzh.transfer` -- `com.yoyuzh.admin` -- `com.yoyuzh.config` -- `com.yoyuzh.common` +### 3.5 业务状态优先于技术状态 -启动类: +系统对外表达的状态必须是业务状态,而不是底层实现状态的直接暴露。 -- `backend/src/main/java/com/yoyuzh/PortalBackendApplication.java` +### 3.6 删除是业务动作,不是物理动作 -### 2.3 文档与脚本 +删除、恢复、过期清理必须服从业务生命周期,不允许把物理对象操作直接等同于业务删除。 -- `docs/`: 实现计划与补充文档 -- `docs/agents/`: 补充性的 agent / handoff 文档;根目录 `CLAUDE.md` 与 `AGENTS.md` 仍是入口 -- `scripts/`: 前端静态站发布、对象存储迁移和本地辅助脚本 +## 4. 角色模型 -## 3. 模块划分 +目标态采用四层角色模型: -### 3.1 认证模块 +- `VISITOR` +- `MEMBER` +- `OPERATOR` +- `ADMIN` -核心文件: +### 4.1 `VISITOR` -- `backend/src/main/java/com/yoyuzh/auth/AuthController.java` -- `backend/src/main/java/com/yoyuzh/auth/AuthService.java` -- `backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java` -- `backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java` -- `backend/src/main/java/com/yoyuzh/auth/RefreshTokenService.java` +定义: + +- 未登录访问者 + +可执行: + +- 访问公开分享 +- 接收公开可接收的传输会话 + +不可执行: + +- 访问个人工作空间 +- 创建需要归属主体的业务对象 +- 执行管理操作 + +### 4.2 `MEMBER` + +定义: + +- 普通登录用户 + +可执行: + +- 管理自己的工作空间 +- 上传、下载、删除、恢复、复制、移动、重命名自己的文件 +- 创建和管理自己的分享 +- 创建和管理自己的传输会话 +- 导入外部文件到自己的工作空间 +- 查看和管理自己的后台任务 + +### 4.3 `OPERATOR` + +定义: + +- 运营或支持角色 职责: -- 注册、登录、刷新登录态 -- 用户资料查询和修改 -- 用户自行修改密码 -- 头像上传 -- 按客户端类型拆分的登录会话控制 -- 邀请码消费与轮换 +- 处理支持型治理工作 +- 查看全局业务对象 +- 处理受控的运营动作 -关键实现说明: +限制: -- access token 使用 JWT -- refresh token 持久化到数据库 -- 当前会话通过“客户端类型 + 会话 ID”绑定:JWT 同时携带 `sid` 和 `client` claim -- 用户表分别记录桌面端与移动端活跃会话;桌面端仍同步回写旧的 `activeSessionId` 以兼容存量逻辑 -- 同账号现在允许桌面端与移动端同时在线,但同一端类型再次登录仍会挤掉旧会话 -- 当前密码策略统一为“至少 8 位且包含大写字母” +- 不直接拥有系统级配置权限 +- 不直接替代系统管理员 -### 3.2 网盘模块 +### 4.4 `ADMIN` -核心文件: +定义: -- `backend/src/main/java/com/yoyuzh/files/core/FileController.java` -- `backend/src/main/java/com/yoyuzh/files/core/FileService.java` -- `backend/src/main/java/com/yoyuzh/files/upload/UploadSessionService.java` -- `backend/src/main/java/com/yoyuzh/files/share/ShareV2Service.java` -- `backend/src/main/java/com/yoyuzh/files/search/FileSearchService.java` -- `backend/src/main/java/com/yoyuzh/files/events/FileEventService.java` -- `backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskService.java` -- `backend/src/main/java/com/yoyuzh/files/policy/StoragePolicyService.java` -- `backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Controller.java` -- `backend/src/main/java/com/yoyuzh/files/storage/*` -- `front/src/pages/Files.tsx` +- 系统治理角色 职责: -- 文件/文件夹上传、下载、删除、重命名 -- 目录创建与分页列表 -- 移动、复制 -- 回收站列表、恢复与过期清理 -- 分享链接与导入 -- 前端树状目录导航 +- 管理系统级策略、权限、存储、任务与全局设置 -关键实现说明: +### 4.5 角色模型规则 -- `com.yoyuzh.files` 已按职责拆成 `core/upload/share/search/events/tasks/storage/policy` 八个子包,控制器路径、数据库表结构、接口路径和前端调用方式保持不变;这次调整只做包重组与引用修正,不改业务语义 -- 文件元数据在数据库 -- 文件内容通过独立 `FileBlob` 实体映射到底层对象;`StoredFile` 只负责用户、目录、文件名、路径、分享关系等逻辑元数据 -- 新文件的物理对象 key 使用全局 `blobs/...` 命名,不再把 `userId/path` 编进对象 key -- 支持本地磁盘和 S3 兼容对象存储 -- 分享导入与网盘复制会直接复用源文件的 `FileBlob`,不会再次写入字节内容 -- 文件重命名、移动只更新 `StoredFile` 元数据,不会移动底层对象 -- 删除文件时不会立刻物理删除,而是把 `StoredFile` 及其目录树标记为回收站条目;根条目会记录 `deletedAt`、原始父路径和回收分组 ID,回收站保留期固定为 10 天 -- 回收站恢复会把整组条目恢复到原路径,并在恢复前检查同名冲突和用户剩余配额 -- 定时清理任务会删除超过 10 天的回收站条目;只有当某个 `FileBlob` 的最后一个逻辑引用随之消失时,才真正删除底层对象 -- 应用启动时会把旧 `portal_file.storage_name` 行自动回填到新的 `blob_id` 引用,保证存量数据能继续读取 -- 当前线上网盘文件存储已切到多吉云对象存储,后端先通过多吉云临时密钥 API 换取短期 S3 会话,再访问底层 COS 兼容桶 -- v2 上传会话后端现已按默认策略能力明确区分三种上传模式:`PROXY`、`DIRECT_SINGLE`、`DIRECT_MULTIPART`。默认 S3 策略会走 `DIRECT_MULTIPART`,在创建会话时初始化 `multipartUploadId`,分片上传通过预签名 `UploadPart` 直传对象存储,完成时先提交 multipart complete,再复用旧 `FileService.completeUpload()` 落库;若默认策略 `directUpload=true` 但 `multipartUpload=false`,则通过 `GET /api/v2/files/upload-sessions/{sessionId}/prepare` 返回整文件直传信息;若 `directUpload=false`,则通过 `POST /api/v2/files/upload-sessions/{sessionId}/content` 走代理上传。当前会话响应还会附带 `strategy`,把当前模式下应调用的后续接口模板显式返回给前端,减少前端自己硬编码 `uploadMode -> endpoint` 映射 -- 前端 files 子系统上传入口现已消费这套 v2 upload session:桌面端 `FilesPage`、移动端 `MobileFilesPage` 和 `saveFileToNetdisk()` 统一通过共享 helper 按 `uploadMode + strategy` 自动选路,并在 multipart 模式下逐片调用 `prepare -> direct upload -> record -> complete`;因此网盘上传主链路已经不再依赖旧 `/api/files/upload/**` -- 前端会缓存目录列表和最后访问路径 -- 桌面网盘页在左侧树状目录栏底部固定展示回收站入口;移动端在网盘页顶部提供回收站入口;两端共用独立 `RecycleBin` 页面调用 `/api/files/recycle-bin` 与恢复接口 +- 角色是授权模型的一部分,不是页面装饰字段 +- 所有管理能力必须绑定 `OPERATOR` 或 `ADMIN` +- 工作空间资源的所有权规则优先于角色扩权 +- 高风险管理动作必须具备审计记录 -Android 壳补充说明: +## 5. 领域划分 -- Android 客户端当前使用 Capacitor 直接承载 `front/dist`,不单独维护原生业务页面 -- 当前包名是 `xyz.yoyuzh.portal` -- 前端 API 基址在 Web 与 Android 壳上分开解析:网页继续走相对 `/api`,Capacitor `localhost` 壳在 `http://localhost` 与 `https://localhost` 下都默认改走 `https://api.yoyuzh.xyz/api` -- 后端 CORS 默认放行 `http://localhost`、`https://localhost`、`http://127.0.0.1`、`https://127.0.0.1` 与 `capacitor://localhost`,以兼容 Web 开发环境和 Android WebView 壳 -- Web 端构建完成后,通过 `npx cap sync android` 把静态资源复制到 `front/android/app/src/main/assets/public` -- Android 调试包当前通过 `cd front/android && ./gradlew assembleDebug` 生成,输出路径是 `front/android/app/build/outputs/apk/debug/app-debug.apk` -- 仓库根目录已提供一键脚本 `node scripts/deploy-android-apk.mjs`,会串起前端构建、Capacitor 同步、Gradle 打包、前端静态站发布与 Android 独立发包,并在 `cap sync` 之后自动补回 Android 插件工程里的 Google Maven 镜像配置 -- `node scripts/deploy-android-release.mjs` 会把 APK 和 `android/releases/latest.json` 上传到 Android 独立对象路径;默认复用文件桶 scope,不再写入前端静态桶 -- 前端总览页在 Web 环境下不再直接指向静态桶里的 APK,而是跳到后端公开下载入口 `https://api.yoyuzh.xyz/api/app/android/download` -- Capacitor 原生壳内的移动端总览页会改为“检查更新”入口;前端通过后端 `/api/app/android/latest` 获取更新信息,后端从文件桶里的 `android/releases/latest.json` 读取版本元数据,并返回带版本号的后端下载地址;真正下载时由 `/api/app/android/download` 直接回传 APK 字节流 -- 私有网盘里的 `apk/ipa` 不再直接暴露对象存储默认域名,也不直接暴露长期有效的自定义域名直链;后端会返回短时 `https://api.yoyuzh.xyz/_dl/...` 下载地址,由 `api.yoyuzh.xyz` 上的 Nginx `secure_link` 做签名和过期校验,再代理到 `dl.yoyuzh.xyz` -- 由于当前开发机直连 `dl.google.com` 与 Google Android Maven 仓库存在 TLS 握手失败,本地 Android 构建仓库源已切到可访问镜像;如果后续重新生成 Capacitor 工程,需要重新确认镜像配置仍存在 +目标态系统划分为八个核心业务域。 -### 3.3 快传模块 - -核心文件: - -- `backend/src/main/java/com/yoyuzh/transfer/TransferController.java` -- `backend/src/main/java/com/yoyuzh/transfer/TransferService.java` -- `backend/src/main/java/com/yoyuzh/transfer/TransferSession.java` -- `front/src/pages/Transfer.tsx` -- `front/src/pages/TransferReceive.tsx` -- `front/src/lib/transfer-runtime.ts` -- `front/src/lib/transfer-protocol.ts` +### 5.1 身份与访问控制域 职责: -- 创建快传会话 -- 生成取件码与分享链接 -- WebRTC 信令交换 -- 浏览器端文件发送与接收 -- 接收后下载或存入网盘 +- 注册 +- 登录 +- 会话管理 +- 令牌刷新 +- 账号状态 +- 角色授权 +- 邀请码控制 -关键实现说明: +产出对象: -- 后端只做信令和会话状态,不中转文件内容 -- 文件内容走浏览器 DataChannel -- 接收端支持部分文件选择 -- 多文件或文件夹可走 ZIP 下载 -- 在线快传是一次性浏览器 P2P 传输,首个接收者进入后即占用该会话 -- 离线快传会把文件内容落到站点存储,线上环境使用多吉云对象存储,默认保留 7 天并支持重复接收 -- 登录页提供直达快传入口;匿名用户允许创建在线快传、接收在线快传和接收离线快传,离线快传的发送以及“存入网盘”仍要求登录 -- 已登录发送端可在快传页查看自己未过期的离线快传记录,并重新打开取件码 / 二维码 / 分享链接详情弹层 -- 生产环境当前已经部署 `GET /api/transfer/sessions/offline/mine`,用于驱动“我的离线快传”列表 -- 前端默认内置 STUN 服务器,并支持通过 `VITE_TRANSFER_ICE_SERVERS_JSON` 追加 TURN / ICE 配置;未配置 TURN 时,跨运营商或手机蜂窝网络下的在线 P2P 直连不保证成功 +- 账号 +- 会话 +- 角色 +- 授权上下文 -### 3.4 管理台模块 - -核心文件: - -- `backend/src/main/java/com/yoyuzh/admin/AdminController.java` -- `backend/src/main/java/com/yoyuzh/admin/AdminService.java` -- `front/src/admin/*` +### 5.2 用户工作空间域 职责: -- 管理用户 -- 管理文件 -- 查看邀请码 -- 展示总存储量、下载流量、今日请求次数、快传使用量、离线快传占用、请求折线图和最近 7 天上线记录 -- 调整离线快传总上限 +- 管理用户视角下的目录树 +- 管理逻辑文件节点 +- 管理回收站生命周期 +- 管理工作空间内的复制、移动、重命名、恢复 -关键实现说明: +产出对象: -- 管理台依赖后端 summary/users/files 接口 -- 当前邀请码由后端返回给管理台展示 -- 用户列表会展示每个用户的已用空间 / 配额 -- 管理员修改用户密码后,旧密码应立即失效,新密码可直接重新登录 -- 管理台当前已可查看、新增、编辑并启停非默认 `StoragePolicy`,也可创建 `STORAGE_POLICY_MIGRATION` 后台任务;策略能力继续以结构化 `StoragePolicyCapabilities` 持久化和回显。当前迁移任务会在“当前活动存储后端”内复制对象数据到新的 target-policy object key、更新 `FileBlob/FileEntity.VERSION` 元数据,并在事务提交后清理旧对象;但仍不支持跨不同运行时后端类型的真正 provider 级迁移。默认策略切换和策略删除仍未落地 -- JWT 过滤器在受保护接口鉴权成功后,会把当天首次上线的用户写入管理统计表,只保留最近 7 天 -- 管理台请求折线图只渲染当天已发生的小时,不再为未来小时补空点 +- 工作空间 +- 工作空间节点 +- 回收站记录 -## 4. 关键业务流程 +### 5.3 内容资产域 -补充说明: +职责: -- 前端主入口会在 `main.tsx` 按屏幕宽度选择桌面壳或移动壳 -- 当前规则为:宽度小于 `768px` 时渲染 `MobileApp`,否则渲染桌面 `App` -- 移动端 `MobileFiles` 与 `MobileTransfer` 独立维护页面级动态光晕层,视觉上与桌面端网盘/快传保持同一背景语言 +- 管理物理内容对象 +- 管理内容版本 +- 管理内容引用关系 +- 管理派生产物 -### 4.1 登录流程 +产出对象: -1. 前端登录页调用 `/api/auth/login` -2. 后端鉴权成功后签发 access token + refresh token -3. 前端同时上送 `X-Yoyuzh-Client` 标记当前是 `desktop` 还是 `mobile` -4. 后端按客户端类型刷新对应的活跃会话 ID 与 refresh token 集合 -5. 前端本地存储 `portal-session` -6. 后续请求通过 `Authorization: Bearer ` 访问,并继续带上 `X-Yoyuzh-Client` -7. JWT 过滤器校验 token、用户状态,以及当前客户端类型对应的会话 ID 是否仍匹配 +- 内容资产 +- 内容版本 +- 派生资产 -补充说明: +### 5.4 分享与外部分发域 -- 前端生产构建当前仍会把 API 基址固化为 `https://api.yoyuzh.xyz/api` -- 因此前端登录、刷新、受保护接口访问都依赖 `api.yoyuzh.xyz` 这条独立 API 子域名链路 -- 若该子域名在某些网络环境下 TLS/SNI 不稳定,前端会直接表现为“网络异常”或“登录失败” +职责: -### 4.2 邀请码注册流程 +- 管理公开分享 +- 管理分享口令、额度、有效期、权限范围 +- 管理通过分享进入系统的导入动作 -1. 用户提交注册信息与邀请码 -2. 后端验证用户名、邮箱、手机号唯一性 -3. 邀请码服务校验当前邀请码 -4. 注册成功后自动轮换邀请码 -5. 返回登录态 +产出对象: -### 4.3 网盘上传流程 +- 分享链接 +- 分享策略 +- 分享访问记录 -1. 前端在 `Files` 页面选择文件或文件夹 -2. 前端优先调用 `/api/files/upload/initiate` -3. 后端为新文件预留一个全局 blob object key(`blobs/...`)并返回给前端 -4. 如果存储支持直传,则浏览器直接把字节上传到该 blob key -5. 前端再调用 `/api/files/upload/complete` -6. 如果直传失败,会回退到代理上传接口 `/api/files/upload` -7. 后端创建 `FileBlob`,再创建指向该 blob 的 `StoredFile` +### 5.5 快传域 -### 4.4 文件分享流程 +职责: -1. 登录用户创建分享链接 -2. 后端生成 token -3. 公开用户通过 `/share/:token` 查看详情 -4. 登录用户导入时会新建自己的 `StoredFile` -5. 若源对象是普通文件,则新条目直接复用源 `FileBlob`,不会复制物理内容 +- 管理在线传输会话 +- 管理离线传输会话 +- 管理传输文件清单 +- 管理传输接收、导入与过期 -### 4.5 快传流程 +产出对象: -1. 发送端可在登录后或未登录状态下创建在线快传会话 -2. 若是在线模式,后端返回 `sessionId + pickupCode` 并保留 15 分钟的一次性会话 -3. 接收端通过取件码或分享链接加入在线会话 -4. 双方通过 `/api/transfer/.../signals` 交换 offer / answer / ice -5. DataChannel 建立后传输文件内容 -6. 接收端可直接下载或存入网盘 +- 传输会话 +- 传输条目 +- 取件码 -### 4.6 离线快传流程 +### 5.6 后台任务域 -1. 发送端登录后创建离线快传会话 -2. 后端生成 `sessionId + pickupCode`,并为每个文件创建离线存储槽位 -3. 发送端把文件上传到站点存储 -4. 上传完成后,会话变为可接收状态并保留 7 天 -5. 接收端通过取件码或分享链接打开会话 -6. 接收端可直接下载离线文件,也可登录后存入网盘 -7. 文件在有效期内不会因一次接收而被删除,过期后由后端清理任务自动销毁 +职责: -补充说明: +- 管理异步任务生命周期 +- 管理任务重试 +- 管理租约、心跳、进度 +- 承载需要异步执行的文件处理任务 -- 离线快传只有“创建会话 / 上传文件 / 存入网盘”要求登录;匿名用户可以查找、加入和下载离线快传 -- 匿名用户进入 `/transfer` 时默认落在发送页,但仅会看到在线模式 -- 登录用户可通过 `/api/transfer/sessions/offline/mine` 拉取自己仍在有效期内的离线快传会话,用于在快传页回看历史取件信息 +产出对象: -### 4.7 管理员改密流程 +- 任务 +- 任务执行记录 -1. 管理台调用 `PUT /api/admin/users/{userId}/password` -2. 后端按统一密码规则校验新密码 -3. 后端重算密码哈希并写回用户表 -4. 后端刷新桌面端与移动端全部活跃会话,并撤销该用户全部 refresh token -5. 旧密码后续登录应失败,新密码登录成功 +### 5.7 存储治理域 -## 5. 前端路由架构 +职责: -路由入口: +- 管理存储策略 +- 管理对象落点规则 +- 管理上传能力矩阵 +- 管理存储策略迁移 -- `front/src/App.tsx` +产出对象: -主要路由: +- 存储策略 +- 存储能力 +- 存储迁移批次 -- `/login` -- `/overview` -- `/files` -- `/transfer` -- `/share/:token` -- `/admin/*` +### 5.8 运营与管理域 + +职责: + +- 提供全局治理能力 +- 管理系统配置 +- 提供运营视图与审计入口 + +产出对象: + +- 系统设置 +- 审计记录 +- 管理操作记录 + +## 6. 核心业务对象 + +目标态采用以下统一业务对象模型。 + +### 6.1 `Account` + +表示平台主体账号。 + +关键属性: + +- `accountId` +- `username` +- `email` +- `phoneNumber` +- `status` +- `role` +- `quotaPolicy` + +### 6.2 `Session` + +表示一个被授权的登录会话。 + +关键属性: + +- `sessionId` +- `accountId` +- `clientType` +- `status` +- `issuedAt` +- `expiresAt` + +### 6.3 `Workspace` + +表示用户的逻辑文件空间。 + +关键属性: + +- `workspaceId` +- `ownerAccountId` +- `status` + +### 6.4 `WorkspaceNode` + +表示工作空间中的逻辑节点。 + +节点类型: + +- `DIRECTORY` +- `FILE` + +关键属性: + +- `nodeId` +- `workspaceId` +- `parentNodeId` +- `name` +- `nodeType` +- `lifecycleState` +- `contentBinding` 说明: -- `/transfer` 同时承担发送端和接收端入口 -- `/share/:token` 是公开文件分享页 -- `/admin/*` 为懒加载管理台 +- 目录节点只承担层级结构 +- 文件节点绑定内容资产引用 -## 6. 安全模型 +### 6.5 `ContentAsset` -### 6.1 访问控制 +表示可被逻辑节点引用的内容资产。 -由 `SecurityConfig` 控制: +关键属性: -- `/api/auth/**` 公开 -- `/api/transfer/**` 公开 -- `GET /api/files/share-links/{token}` 公开 -- `/api/files/**`、`/api/user/**`、`/api/admin/**` 需登录 +- `assetId` +- `assetType` +- `currentVersionId` +- `ownerScope` +- `retentionPolicy` -### 6.2 分端单会话登录 +### 6.6 `ContentVersion` -当前实现不是只撤销 refresh token,而是同时控制 access token,并按客户端类型拆分: +表示一份具体内容版本。 -- 前端会在鉴权与上传请求里附带 `X-Yoyuzh-Client: desktop|mobile` -- 用户表记录 `desktopActiveSessionId` 与 `mobileActiveSessionId` -- JWT 里同时包含 `sid` 和 `client` -- 过滤器每次请求都会按 token 里的 `client` 去比对对应端的活跃会话 ID -- 桌面端与移动端可以同时在线,但同一端再次登录成功后,该端旧 token 会失效 +关键属性: -## 7. 存储架构 +- `versionId` +- `assetId` +- `objectKey` +- `storagePolicyId` +- `size` +- `contentType` +- `checksum` +- `versionState` -抽象层: +规则: -- `backend/src/main/java/com/yoyuzh/files/storage/FileContentStorage.java` +- 内容版本一旦完成写入后应视为不可变 -实现方向: +### 6.7 `ShareGrant` -- 本地文件系统 -- S3 兼容对象存储 +表示一条对外分享授权。 -设计目的: +关键属性: -- 让文件元数据逻辑与底层存储解耦 -- 上传、下载、复制、移动都通过统一抽象收口 +- `shareId` +- `ownerAccountId` +- `targetNodeId` +- `accessPolicy` +- `status` +- `expiresAt` -当前线上状态: +### 6.8 `TransferSession` -- 生产环境文件桶已切到多吉云对象存储 -- 后端通过多吉云临时密钥 API 获取短期 `accessKeyId / secretAccessKey / sessionToken` -- 实际对象访问走 S3 兼容协议,底层 endpoint 为 COS 兼容地址 -- 普通文件下载仍采用“后端鉴权后返回签名 URL,浏览器直连对象存储下载”的主链路 -- 私有 `apk/ipa` 下载是例外:后端只负责返回短时签名的 `/_dl` 地址,真正文件流量经过服务器 Nginx 反向代理到 `dl.yoyuzh.xyz`,不经过 Spring Boot 业务进程 +表示一条临时传输会话。 -## 8. 部署架构 +关键属性: -### 8.1 前端 +- `transferId` +- `mode` +- `ownerAccountId` +- `status` +- `pickupCode` +- `expiresAt` -- 构建工具:Vite -- 发布方式:对象存储静态站发布 -- 发布脚本:`node scripts/deploy-front-oss.mjs` +模式: -### 8.2 后端 +- `ONLINE` +- `OFFLINE` -- 打包方式:`mvn package` -- 产物:`backend/target/yoyuzh-portal-backend-0.0.1-SNAPSHOT.jar` -- 线上通常采用 jar + systemd 方式运行 +### 6.9 `TransferItem` -当前已知线上信息: +表示一条传输会话中的文件项。 -- 服务名:`my-site-api.service` -- 运行包路径:`/opt/yoyuzh/yoyuzh-portal-backend.jar` -- 额外配置文件:`/opt/yoyuzh/application-prod.yml` -- 环境变量文件:`/opt/yoyuzh/app.env` -- 2026-04-02 已重新部署,服务状态为 `active (running)` +关键属性: -## 9. 开发注意事项 +- `transferItemId` +- `transferId` +- `name` +- `relativePath` +- `size` +- `contentType` +- `state` -- 仓库根目录没有 `package.json`,不要在根目录执行 `npm` -- 前端命令只从 `front/package.json` 读取 -- 后端命令只从 `backend/pom.xml` 读取 -- 根目录 `.env` 是当前统一的本地密钥与部署配置入口;`.env.example` 是模板,旧 `.env.oss.local` 仅保留兼容回退 -- 前端 `npm run lint` 实际是 `tsc --noEmit` -- 后端没有单独 lint 命令 -- 本仓库大量使用 Lombok,VS Code 若出现“final 字段未初始化”之类误报,优先检查 Lombok 扩展、Java Language Server 和 annotation processor +### 6.10 `AsyncJob` -## 10. 新窗口建议阅读顺序 +表示系统异步任务。 -后续新窗口进入仓库时,建议顺序: +关键属性: -1. `memory.md` -2. `docs/architecture.md` -3. `docs/api-reference.md` -4. `AGENTS.md` -5. `CLAUDE.md` +- `jobId` +- `jobType` +- `ownerAccountId` +- `status` +- `retryPolicy` +- `lease` -如果要继续某个具体功能,再进入对应模块的: +### 6.11 `StoragePolicy` -- 前端页面文件 -- 后端 Controller / Service -- 紧邻测试文件 +表示一类存储落点规则。 -如果需要额外的交接背景,再补读: +关键属性: -- `docs/agents/handoff.md` -## 2026-04-08 API v2 阶段 1 补充 +- `storagePolicyId` +- `policyType` +- `capabilitySet` +- `maxObjectSize` +- `status` +- `isDefault` -- 后端新增 `com.yoyuzh.api.v2` 作为新版 API 的独立边界,当前只暴露公开健康检查 `GET /api/v2/site/ping`。 -- v2 边界使用独立的 `ApiV2Response`、`ApiV2ErrorCode` 和 `ApiV2ExceptionHandler`,暂不替换旧 `com.yoyuzh.common.ApiResponse`。 -- 前端 `front/src/lib/api.ts` 通过 `apiV2Request()` 访问 `/api/v2/**`,并为内部 API 请求附带稳定的 `X-Yoyuzh-Client-Id`,用于后续文件事件流和客户端事件去重。 +### 6.12 `SystemSetting` -## 2026-04-08 文件实体模型二期第一小步 +表示系统级业务配置。 -- `StoredFile` 仍是用户可见文件/目录元数据的主模型,现阶段继续保留 `blob_id` 读取路径。 -- 新增 `FileEntity` 作为更通用的物理实体模型,当前先从 `FileBlob` 回填 `VERSION` 类型实体;后续版本、缩略图、转码、头像等派生对象会挂到同一实体体系。 -- 新增 `StoredFileEntity` 作为逻辑文件和物理/派生实体的关系表;当前只写入 `PRIMARY` 关系,不切换旧业务读写。 -- `FileEntityBackfillService` 在 `FileBlobBackfillService` 之后运行,只处理 `blob` 已存在但 `primaryEntity` 为空的普通文件,保证重复启动不会重复迁移已完成行。 +关键属性: -## 2026-04-08 文件实体模型二期第二小步 +- `settingKey` +- `settingScope` +- `value` +- `mutability` -- 文件写入路径已经从“只写 `FileBlob`”扩展为“继续写 `FileBlob`,同时写 `FileEntity.VERSION` 和 `StoredFileEntity(PRIMARY)`”。覆盖普通上传、直传完成、外部导入、分享导入和网盘复制。 -- `StoredFile.blob` 仍是当前生产读取路径;`StoredFile.primaryEntity` 与关系表暂时只作为兼容迁移数据,不影响旧 `/api/files/**` DTO 和前端调用。 -- `portal_stored_file_entity.stored_file_id` 随 `portal_file` 删除级联清理;`portal_file_entity.created_by` 在用户删除时置空,避免实体审计关系阻塞用户清理。 -- 2026-04-08 阶段 3 第一小步补充:后端新增上传会话二期最小骨架。`UploadSession` 记录用户、目标路径、文件名、对象键、分片大小、分片数量、状态、过期时间和已上传分片占位 JSON;`/api/v2/files/upload-sessions` 目前只提供创建、查询、取消会话,不承接实际分片内容上传,也不替换旧 `/api/files/upload/**` 生产链路。 -- 2026-04-08 阶段 3 第二小步补充:上传会话新增完成状态机。`UploadSessionService.completeOwnedSession()` 会复用旧 `FileService.completeUpload()` 完成对象确认、目录补齐、配额/冲突校验和 `FileBlob + StoredFile + FileEntity.VERSION` 双写落库,然后把会话标记为 `COMPLETED`;失败时标记 `FAILED`,过期时标记 `EXPIRED`。当时仍没有独立 v2 分片内容写入端点。 -- 2026-04-08 阶段 3 第三小步补充:上传会话新增 part 状态记录。`UploadSessionService.recordUploadedPart()` 会校验会话归属、状态、过期时间和 part 范围,把 `etag/size/uploadedAt` 写入 `uploadedPartsJson`,并将新会话推进到 `UPLOADING`。当前实现是会话状态跟踪,不是跨存储驱动的分片内容写入/合并实现。 -- 2026-04-08 阶段 3 第四小步补充:上传会话新增定时过期清理。`UploadSessionService.pruneExpiredSessions()` 每小时扫描未完成且已过期的 `CREATED/UPLOADING/COMPLETING` 会话,尝试删除 `objectKey` 对应的临时 blob,然后标记为 `EXPIRED`。已完成文件不参与清理,避免误删已经落库的生产对象。 -- 2026-04-08 阶段 4 第一小步补充:后端新增存储策略骨架。`StoragePolicyService` 作为 `CommandLineRunner` 在启动时确保存在默认策略,并把当前 `FileStorageProperties` 映射为 `LOCAL` 或 `S3_COMPATIBLE` 策略及 `StoragePolicyCapabilities` JSON;当时能力声明里的 `multipartUpload=false` 用于明确真实对象存储分片写入/合并还没有启用。`UploadSession.storagePolicyId` 开始记录默认策略 ID,但旧 `/api/files/**` 生产路径当时仍不切换。 -- 2026-04-08 `files/storage` 合并补充:S3 存储实现拆出多吉云临时密钥客户端与运行期会话提供器。`S3FileContentStorage` 现在通过 `S3SessionProvider.currentSession()` 获取当前 bucket、`S3Client` 和 `S3Presigner`,避免每次操作重复内联多吉云 token 解析逻辑;测试环境可直接注入 mock S3 client/presigner。当时该改动还没有引入 multipart,仍是单对象 PUT/HEAD/GET/COPY/DELETE 路径。 -- 2026-04-08 阶段 4 第二小步补充:`FileService` 在创建新的 `FileEntity.VERSION` 时会通过 `StoragePolicyService.ensureDefaultPolicy()` 写入默认 `storagePolicyId`;`FileEntityBackfillService` 对历史 `FileBlob` 回填新实体时也写入同一默认策略。复用已有实体时保持原策略字段不变,只增加引用计数,避免在兼容迁移阶段覆盖历史数据。 -- 2026-04-08 阶段 4 第三小步补充:管理台新增只读存储策略列表。`AdminController` 暴露 `GET /api/admin/storage-policies`,`AdminService` 通过白名单 DTO 返回策略基础字段和结构化 `StoragePolicyCapabilities`;前端 `react-admin` 新增 `storagePolicies` 资源展示能力矩阵。该能力只做配置可视化,不改变旧上传下载路径,也不暴露凭证或提供策略编辑能力。 -- 2026-04-09 存储策略管理补充:`AdminController` 现已补 `POST /api/admin/storage-policies`、`PUT /api/admin/storage-policies/{policyId}`、`PATCH /api/admin/storage-policies/{policyId}/status` 和 `POST /api/admin/storage-policies/migrations`。当前允许新增、编辑、启停非默认策略,并沿用 `StoragePolicyCapabilities` 作为强类型能力声明;迁移接口会为管理员创建 `STORAGE_POLICY_MIGRATION` 后台任务,worker 只校验源/目标策略并重算候选 `FileEntity.VERSION` / `StoredFile` 数量,不直接移动对象数据。默认策略仍不能被停用,也还不支持删除策略或切换默认策略。 -- 2026-04-09 存储策略迁移补充:`StoragePolicyMigrationBackgroundTaskHandler` 现在会在当前活动存储后端内执行真实对象迁移。它要求源/目标策略类型一致且与运行时后端匹配,复制旧 object key 的字节内容到新的 `policies/{targetPolicyId}/blobs/...` key,更新 `FileBlob.objectKey` 与 `FileEntity.VERSION.storagePolicyId/objectKey`,并在事务提交后清理旧对象;若中途失败,会删除本轮新写对象,依赖事务回滚数据库状态。 -- 2026-04-09 上传会话二期补充:`FileContentStorage` 抽象已新增 `createMultipartUpload/prepareMultipartPartUpload/completeMultipartUpload/abortMultipartUpload`;`S3FileContentStorage` 基于预签名 `UploadPart` 与 S3 `Complete/AbortMultipartUpload` 实现真实 multipart。`UploadSession` 新增 `multipartUploadId`,`UploadSessionService.createSession()` 会在默认策略声明 `multipartUpload=true` 时初始化 uploadId,并通过 `GET /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}/prepare` 返回单分片直传地址。会话完成时先按 `uploadedPartsJson` 提交 multipart complete,再复用旧上传完成链路落库;过期清理则改为优先 abort 未完成 multipart。 +## 7. 模块职责与协作边界 -## 2026-04-08 阶段 5 文件搜索第一小步 +### 7.1 身份模块 -- 后端新增 `FileMetadata` 与 `FileMetadataRepository`,作为后续标签、缩略图状态、媒体属性和自定义属性的统一扩展表骨架。当前阶段只建表与仓储入口,不迁移现有回收站字段,也不改变旧 `/api/files/**` DTO。 -- 后端新增 `FileSearchService` 与 `GET /api/v2/files/search`,按当前登录用户查询未删除的 `StoredFile`,支持文件名、文件/目录类型、大小、创建时间、更新时间和分页过滤。 -- 搜索结果复用现有 `FileMetadataResponse`,因此旧网盘列表、下载、分享、回收站和上传链路不受影响;前端用户侧搜索 UI 和 metadata/tag 过滤留到后续小步接入。 +负责: -## 2026-04-08 阶段 5 文件搜索第二小步 +- 账号生命周期 +- 会话与令牌 +- 角色与授权上下文 -- 前端桌面端新增独立搜索模式:`front/src/lib/file-search.ts` 复用 `apiV2Request('/files/search', ...)`,并在 `front/src/lib/file-search.test.ts` 覆盖参数编码、空参数跳过和 v2 数据解包。 -- `front/src/pages/Files.tsx` 同时保留目录视图和搜索结果视图,搜索结果不写入 `getFilesListCacheKey(...)`,也不影响原有目录缓存和上传主链路;移动端文件页暂未接入搜索。 +不得负责: + +- 文件配额判断 +- 工作空间结构 +- 存储策略选择 + +### 7.2 工作空间模块 + +负责: + +- 目录树 +- 文件节点生命周期 +- 删除、恢复、移动、复制、重命名 + +不得负责: + +- 内容对象的物理写入 +- 分享规则 +- 传输协议 + +### 7.3 内容资产模块 + +负责: + +- 内容对象写入完成后的登记 +- 版本不可变规则 +- 引用关系 +- 内容级派生产物 + +不得负责: + +- 工作空间路径语义 +- 分享口令和访问额度 + +### 7.4 分享模块 + +负责: + +- 对外访问授权 +- 过期、口令、额度和权限范围 + +不得负责: + +- 目录树变更 +- 内容落库存储 + +### 7.5 快传模块 + +负责: + +- 传输会话组织 +- 在线传输的时效和接收关系 +- 离线传输的文件接收与导入 + +不得负责: + +- 普通分享 +- 工作空间目录治理 + +### 7.6 后台任务模块 + +负责: + +- 长耗时业务处理 +- 重试和失败分类 + +不得负责: + +- 同步主流程判断 + +### 7.7 存储治理模块 + +负责: + +- 定义上传能力 +- 定义内容落点 +- 定义迁移规则 + +不得负责: + +- 页面或客户端自己决定上传模式 + +### 7.8 管理模块 + +负责: + +- 全局治理入口 +- 系统设置和审计入口 + +不得负责: + +- 绕开领域规则直接改写核心业务对象 + +## 8. 核心业务流程 + +### 8.1 账号注册流程 + +1. 用户提交注册信息 +2. 系统校验唯一性与注册策略 +3. 系统验证邀请码 +4. 创建 `Account` +5. 初始化 `Workspace` +6. 签发首个 `Session` + +### 8.2 登录流程 + +1. 用户提交凭证 +2. 系统校验账号状态 +3. 创建或替换对应客户端的 `Session` +4. 发放访问令牌与刷新令牌 + +### 8.3 文件接入流程 + +1. 用户发起上传 +2. 系统依据 `StoragePolicy` 返回可用上传策略 +3. 内容写入完成后登记 `ContentVersion` +4. 创建或绑定 `ContentAsset` +5. 在 `Workspace` 中创建 `WorkspaceNode` + +### 8.4 文件操作流程 + +1. 用户选择目标节点 +2. 工作空间模块校验所有权和路径规则 +3. 执行重命名、移动、复制或删除 +4. 如为复制,仅复制逻辑节点,不复制内容版本 + +### 8.5 删除与恢复流程 + +1. 删除动作只改变 `WorkspaceNode.lifecycleState` +2. 被删除节点进入回收区 +3. 恢复时校验路径冲突和配额 +4. 真正的物理清理由保留策略驱动 + +### 8.6 分享流程 + +1. 用户选择一个文件节点创建 `ShareGrant` +2. 系统记录分享访问策略 +3. 访客通过分享入口访问 +4. 系统按口令、期限、额度和动作权限进行授权判断 +5. 允许时执行下载或导入 + +### 8.7 快传流程 + +#### 在线快传 + +1. 创建 `TransferSession(mode=ONLINE)` +2. 接收方加入 +3. 完成信令交换 +4. 传输完成或会话过期 + +#### 离线快传 + +1. 创建 `TransferSession(mode=OFFLINE)` +2. 写入 `TransferItem` 清单 +3. 上传离线内容 +4. 全部完成后进入可接收状态 +5. 接收方下载或导入 + +### 8.8 异步处理流程 + +1. 主流程触发 `AsyncJob` +2. Worker 认领任务 +3. 执行中上报租约和进度 +4. 成功、失败、重试或取消 + +### 8.9 存储迁移流程 + +1. 创建迁移批次 +2. 读取符合条件的 `ContentVersion` +3. 复制到目标存储策略 +4. 切换引用 +5. 清理旧内容对象 + +## 9. 统一业务规则 + +### 9.1 身份规则 + +- 账号必须有明确状态 +- 被停用账号不得继续生成有效会话 +- 一个客户端类型同一时刻只能有一个活跃会话 +- 会话失效必须是统一规则,而不是接口局部处理 + +### 9.2 工作空间规则 + +- 所有文件和目录都必须存在于某个 `Workspace` +- `WorkspaceNode` 的路径语义由父子关系决定,不允许把完整路径字符串当作唯一真相 +- 同一父节点下名称必须唯一 +- 文件复制复制的是节点,不是内容版本 + +### 9.3 内容规则 + +- 内容版本写入完成后不可变 +- 逻辑节点与物理内容必须解耦 +- 派生产物属于内容资产域,不属于工作空间域 +- 物理对象删除必须晚于业务生命周期结束 + +### 9.4 删除与保留规则 + +- 删除是进入回收区,不是直接物理删除 +- 恢复必须回到原有工作空间语义下 +- 物理清理只能由保留策略和引用关系共同决定 + +### 9.5 分享规则 + +- 分享是对单一目标节点的授权对象 +- 分享权限必须显式区分查看、下载、导入 +- 分享过期、额度耗尽、被撤销后必须立即失效 +- 公开访问规则必须只由分享模块负责 + +### 9.6 传输规则 + +- 分享与快传是两个独立能力,不允许语义混用 +- 在线快传是临时连接能力,不承担长期存储责任 +- 离线快传是临时托管能力,不等于工作空间文件 +- 导入动作必须显式把传输内容转化为工作空间资产 + +### 9.7 上传规则 + +- 上传能力由服务器根据存储策略决定 +- 客户端只能消费上传策略,不自行定义上传模式 +- 上传完成之前不得产生正式工作空间节点 + +### 9.8 任务规则 + +- 所有长耗时业务必须通过 `AsyncJob` 进入统一任务体系 +- 相同语义的自动任务必须具备幂等键 +- 任务状态机必须统一 + +### 9.9 管理规则 + +- 所有管理写操作必须具备审计能力 +- 管理能力应基于角色和权限模型,不基于用户名白名单硬编码 +- 系统设置必须区分可变业务配置和只读运行快照 + +## 10. 状态定义与状态流转 + +## 10.1 `AccountStatus` + +状态: + +- `PENDING` +- `ACTIVE` +- `SUSPENDED` +- `CLOSED` + +流转: + +- `PENDING -> ACTIVE` +- `ACTIVE -> SUSPENDED` +- `SUSPENDED -> ACTIVE` +- `ACTIVE/SUSPENDED -> CLOSED` + +## 10.2 `SessionStatus` + +状态: + +- `ACTIVE` +- `REVOKED` +- `EXPIRED` + +流转: + +- `ACTIVE -> REVOKED` +- `ACTIVE -> EXPIRED` + +## 10.3 `WorkspaceNodeLifecycleState` + +状态: + +- `ACTIVE` +- `RECYCLED` +- `PURGED` + +流转: + +- `ACTIVE -> RECYCLED` +- `RECYCLED -> ACTIVE` +- `RECYCLED -> PURGED` + +## 10.4 `ContentVersionState` + +状态: + +- `PENDING_UPLOAD` +- `AVAILABLE` +- `ARCHIVED` +- `DELETED` + +流转: + +- `PENDING_UPLOAD -> AVAILABLE` +- `AVAILABLE -> ARCHIVED` +- `ARCHIVED -> DELETED` + +## 10.5 `ShareGrantStatus` + +状态: + +- `ACTIVE` +- `LOCKED` +- `EXHAUSTED` +- `EXPIRED` +- `REVOKED` + +流转: + +- `ACTIVE -> LOCKED` +- `ACTIVE -> EXHAUSTED` +- `ACTIVE -> EXPIRED` +- `ACTIVE/LOCKED -> REVOKED` + +## 10.6 `TransferSessionStatus` + +状态: + +- `CREATED` +- `AWAITING_UPLOAD` +- `READY` +- `IN_PROGRESS` +- `COMPLETED` +- `EXPIRED` +- `CANCELLED` + +流转: + +- 在线快传:`CREATED -> IN_PROGRESS -> COMPLETED/EXPIRED/CANCELLED` +- 离线快传:`CREATED -> AWAITING_UPLOAD -> READY -> COMPLETED/EXPIRED/CANCELLED` + +## 10.7 `AsyncJobStatus` + +状态: + +- `QUEUED` +- `RUNNING` +- `RETRY_WAITING` +- `SUCCEEDED` +- `FAILED` +- `CANCELLED` + +流转: + +- `QUEUED -> RUNNING` +- `RUNNING -> SUCCEEDED` +- `RUNNING -> RETRY_WAITING` +- `RETRY_WAITING -> RUNNING` +- `RUNNING/RETRY_WAITING -> FAILED` +- `QUEUED/RUNNING/RETRY_WAITING -> CANCELLED` + +## 11. 权限模型 + +目标态采用三层授权模型: + +### 11.1 第一层:身份层 + +回答: + +- 用户是谁 +- 是否已登录 +- 会话是否有效 + +### 11.2 第二层:角色层 + +回答: + +- 用户具有什么系统级能力 + +例如: + +- `MEMBER` +- `OPERATOR` +- `ADMIN` + +### 11.3 第三层:资源层 + +回答: + +- 用户是否可以对某个具体资源执行某个动作 + +资源授权判断必须同时考虑: + +- 资源所有权 +- 资源状态 +- 角色权限 +- 动作策略 + +### 11.4 授权规则 + +- 公开资源只允许暴露明确可公开的动作 +- 所有者默认拥有自己资源的成员级权限 +- 管理角色拥有治理权限,但不应破坏审计边界 +- 导入、删除、恢复、迁移都属于高风险动作,必须有明确授权检查 + +## 12. 企业级目标模块结构 + +目标态后端应围绕业务域而不是技术层进行组织,建议演进为以下模块边界: + +- `identity-access` +- `workspace` +- `content-asset` +- `sharing` +- `transfer` +- `async-job` +- `storage-governance` +- `operations-admin` +- `common-kernel` + +每个模块内部再区分: + +- domain +- application +- infra +- api + +前端也应围绕业务域组织,而不是单纯围绕页面散落: + +- account +- workspace +- sharing +- transfer +- admin +- common + +## 13. 统一架构结论 + +目标态架构的核心不是“把当前代码整理得更漂亮”,而是把系统重构成以下稳定形态: + +1. 账号、会话、角色、授权是独立的身份域 +2. 工作空间节点与物理内容版本彻底分离 +3. 分享与快传是两个独立分发域 +4. 上传只是内容接入机制,不直接等同于业务文件创建 +5. 后台任务是统一异步处理底座 +6. 存储策略是系统级治理能力,不是控制器附属逻辑 +7. 管理端是治理入口,不是绕过业务规则的特殊通道 + +后续所有重构都应围绕上述七条进行对齐。 + +## 14. 规则判定矩阵 + +本矩阵用于明确“某类规则到底由哪个业务域负责判定”,避免后续继续把同一规则散落在多个模块中。 + +| 规则类别 | 判定主体 | 触发时机 | 输出结果 | 不应由谁判定 | +| --- | --- | --- | --- | --- | +| 是否允许注册 | `identity-access` | 用户提交注册请求时 | 允许注册 / 拒绝注册 | 页面层、管理台聚合层 | +| 邀请码是否有效 | `identity-access` | 注册前 | 有效 / 无效 / 已消费 | Controller 私有分支 | +| 会话是否有效 | `identity-access` | 每次受保护请求进入时 | 允许访问 / 拒绝访问 | 业务 Service 各自重复判断 | +| 用户是否具备系统管理能力 | `identity-access` | 访问管理能力前 | `OPERATOR` / `ADMIN` 是否可执行 | 用户名白名单硬编码 | +| 用户是否可操作某个工作空间节点 | `workspace` + 授权层 | 文件相关动作前 | 允许 / 拒绝 | Controller 层手写所有权判断 | +| 路径是否合法 | `workspace` | 创建、移动、复制、恢复前 | 合法 / 非法 | 上传模块重复实现 | +| 同目录是否允许重名 | `workspace` | 节点写入前 | 允许 / 拒绝 | 分享模块、传输模块各自决定 | +| 是否允许创建正式文件节点 | `workspace` | 内容接入完成后 | 创建 / 拒绝 | 上传接入层直接决定 | +| 内容版本是否可复用 | `content-asset` | 导入、复制、分享导入时 | 复用 / 新建版本 | 工作空间模块 | +| 是否允许物理删除对象 | `content-asset` | 清理流程中 | 删除 / 保留 | 工作空间模块直接删除对象 | +| 上传模式如何选择 | `storage-governance` | 创建上传会话时 | `PROXY` / `DIRECT_SINGLE` / `DIRECT_MULTIPART` | 前端、自定义工具类 | +| 上传大小是否超限 | `storage-governance` + `identity-access` | 上传前 | 允许 / 拒绝 | `FileService` 和 `UploadSessionService` 各自重复算 | +| 分享是否允许访问 | `sharing` | 访问分享链接时 | 允许查看 / 拒绝查看 | 工作空间模块 | +| 分享是否允许下载 | `sharing` | 下载分享文件时 | 允许下载 / 拒绝下载 | Controller 查询参数分支 | +| 分享是否允许导入 | `sharing` | 导入分享文件时 | 允许导入 / 拒绝导入 | 工作空间模块 | +| 分享是否过期或额度耗尽 | `sharing` | 每次分享动作前 | 可用 / 不可用 | 前端缓存判断 | +| 传输会话是否允许加入 | `transfer` | 加入在线快传时 | 允许加入 / 拒绝加入 | 信令基础设施 | +| 离线传输是否可接收 | `transfer` | 下载/导入前 | 可接收 / 不可接收 | 文件接入层 | +| 离线传输是否超出全局容量 | `transfer` + `storage-governance` | 离线上传前 | 允许上传 / 拒绝上传 | 管理指标聚合层 | +| 是否需要进入异步任务 | `async-job` 的调用方域 | 长耗时动作提交时 | 直接处理 / 创建任务 | Controller 层直接排队 | +| 任务是否允许重试 | `async-job` | 任务失败后 | 自动重试 / 人工重试 / 不可重试 | 具体任务 handler 自己决定 | +| 任务是否重复 | `async-job` | 创建任务时 | 新建 / 幂等忽略 | broker 消费者临时判断 | +| 设置是否允许修改 | `operations-admin` + 授权层 | 管理写操作时 | 可写 / 只读 | 聚合 DTO 提示字段单独决定 | + +### 14.1 矩阵使用规则 + +- 所有新业务规则必须先映射到矩阵中的一个判定主体。 +- 如果某条规则找不到唯一判定主体,说明领域边界还没有收敛。 +- 同一规则只能有一个“最终判定主体”,其他模块只能消费判定结果,不能复制规则。 + +## 15. 高风险测试场景清单 + +本清单不是为了堆测试数量,而是定义目标架构下必须被自动化约束的关键业务场景。 + +### 15.1 身份与授权 + +1. 同一客户端类型再次登录后,旧会话失效 +2. 桌面端与移动端会话可并存,但互不覆盖 +3. 改密后所有活跃会话和 refresh token 失效 +4. 账号被停用后无法继续访问受保护资源 +5. `OPERATOR` 与 `ADMIN` 的授权边界按统一角色模型生效 + +### 15.2 工作空间 + +1. 同一父节点下禁止重名文件或目录 +2. 目录不能移动到自身或自身子树 +3. 目录不能复制到自身或自身子树 +4. 删除进入回收区而不是立即物理删除 +5. 恢复时如果原位置冲突则整体拒绝恢复 +6. 恢复时如果超出配额则整体拒绝恢复 + +### 15.3 内容资产 + +1. 复制逻辑节点时不复制物理内容版本 +2. 多个逻辑节点绑定同一内容版本时,删除其中一个不会删除物理对象 +3. 最后一个逻辑引用消失后,物理对象才允许清理 +4. 内容版本一旦 `AVAILABLE` 后不可变 + +### 15.4 上传与存储治理 + +1. 上传模式严格由存储策略能力决定 +2. 非法路径、非法名称、超大小、超配额都会被统一拒绝 +3. 已完成、已取消、已失败、已过期的上传会话不能继续写入 +4. 分片上传必须完整记录分片后才能完成 +5. 上传完成前不创建正式工作空间节点 + +### 15.5 分享 + +1. 有口令的分享必须先验密 +2. 过期分享不可查看、不可下载、不可导入 +3. `allowDownload` 与 `allowImport` 必须分别生效 +4. 分享额度耗尽后所有授权动作失效 +5. 分享被撤销后立即失效 + +### 15.6 快传 + +1. 在线快传只允许有效接收方进入会话 +2. 离线快传只有全部文件上传完成后才可接收 +3. 匿名可接收,但不可导入到工作空间 +4. 离线传输容量达到上限时拒绝继续上传 +5. 过期传输会话不可继续接收或导入 + +### 15.7 异步任务 + +1. 相同业务语义的自动任务不会重复创建 +2. 任务租约过期后能够安全回收 +3. retryable 失败会进入统一重试流程 +4. non-retryable 失败进入终态 +5. 只有允许人工重试的任务才能被人工重试 + +### 15.8 管理与治理 + +1. 只读系统快照不可通过写接口修改 +2. 可写业务设置必须经过统一管理授权 +3. 高风险治理动作必须产生审计记录 +4. 存储迁移只能在合法策略组合下执行 + +## 16. 重构迁移与模块落地顺序 + +本顺序定义的是目标架构的落地路径,不是一次性重写方案。 + +### 16.1 第一阶段:身份与授权先行 + +目标: + +- 统一角色模型 +- 统一会话失效规则 +- 统一管理权限事实源 + +优先原因: + +- 如果权限模型不先收敛,后续所有模块重构都会继续携带错误边界 + +落地模块: + +- `identity-access` +- `operations-admin` 中与授权入口直接相关的部分 + +### 16.2 第二阶段:工作空间与内容资产拆分 + +目标: + +- 把逻辑节点与物理内容彻底拆开 + +优先原因: + +- 这是后续分享、快传、上传、删除、恢复、迁移的共同基础 + +落地模块: + +- `workspace` +- `content-asset` + +### 16.3 第三阶段:上传与存储治理收口 + +目标: + +- 让上传成为内容接入能力 +- 让存储策略成为系统级能力 + +优先原因: + +- 当前上传规则和文件业务耦合过深,不先切开,内容资产域无法稳定 + +落地模块: + +- `storage-governance` +- `content-asset` 接入层 + +### 16.4 第四阶段:分享域收口 + +目标: + +- 统一旧分享和新分享 +- 让分享只围绕 `ShareGrant` 和逻辑节点授权 + +优先原因: + +- 分享对外暴露面大,必须建立在稳定工作空间与内容模型之上 + +落地模块: + +- `sharing` + +### 16.5 第五阶段:快传域拆分 + +目标: + +- 分开在线快传与离线快传 +- 明确临时传输与正式工作空间资产的边界 + +优先原因: + +- 快传与分享的边界只有在工作空间与分享域稳定后才容易落定 + +落地模块: + +- `transfer` + +### 16.6 第六阶段:异步任务统一底座 + +目标: + +- 让 `AsyncJob` 成为跨域底座 + +优先原因: + +- 当工作空间、内容资产、分享、快传都稳定后,异步任务才可以真正服务多个领域,而不是继续附属在文件模块里 + +落地模块: + +- `async-job` + +### 16.7 第七阶段:管理域治理化 + +目标: + +- 管理端只做治理编排 +- 业务规则回归各自领域 + +优先原因: + +- 管理端必须建立在稳定的领域边界之上,否则只会继续膨胀为超级入口 + +落地模块: + +- `operations-admin` + +### 16.8 第八阶段:前端按领域重组 + +目标: + +- 前端与后端围绕同一业务域组织 + +优先原因: + +- 前端结构必须跟随稳定后的后端领域,而不是反向牵引后端设计 + +落地模块: + +- `account` +- `workspace` +- `sharing` +- `transfer` +- `admin` +- `common` + +### 16.9 模块落地顺序总表 + +1. `identity-access` +2. `workspace` +3. `content-asset` +4. `storage-governance` +5. `sharing` +6. `transfer` +7. `async-job` +8. `operations-admin` +9. 前端领域化重组 + +### 16.10 落地顺序约束 + +- 未统一身份与授权前,不进入大规模治理侧重构 +- 未拆开工作空间与内容资产前,不收口分享和快传 +- 未收口上传与存储治理前,不把内容资产域视为稳定 +- 未稳定后端领域边界前,不开始前端大规模域化重组 -## 2026-04-08 阶段 5 分享二期后端最小骨架 - -- 旧分享仍保留在 `/api/files/share-links/**`,用于兼容当前前端公开分享页和旧导入路径。 -- 新 v2 分享位于 `com.yoyuzh.api.v2.shares` 与 `ShareV2Service`;`FileShareLink` 新增 `passwordHash`、`expiresAt`、`maxDownloads`、`downloadCount`、`viewCount`、`allowImport`、`allowDownload`、`shareName` 策略字段。 -- 公开端点包括 `GET /api/v2/shares/{token}`、`POST /api/v2/shares/{token}/verify-password`,以及 `GET /api/v2/shares/{token}?download=1`;创建、导入、我的分享列表和删除仍需要登录。 -- 密码分享在校验前隐藏 `file` 详情;v2 导入会在复用旧导入落库链路前校验过期时间、密码、`allowImport` 和 `maxDownloads`。v2 下载也会统一校验过期时间、密码、`allowDownload` 和 `maxDownloads`,成功后复用现有文件下载链路并递增 `downloadCount`。 - -## 2026-04-08 阶段 5 文件事件流最小闭环 - -- 后端新增 `FileEvent` / `FileEventType` / `FileEventRepository` / `FileEventService`,并暴露受保护的 `GET /api/v2/files/events` SSE 入口。 -- 当前事件流以用户为广播边界,支持 `path` 前缀过滤和 `X-Yoyuzh-Client-Id` 自身事件抑制;首次连接会收到 `READY` 事件。 -- `FileService` 只在上传、导入、复制、移动、重命名、删除、恢复这些核心变更点记录最小事件。 -- 前端新增 `front/src/lib/file-events.ts`,通过 fetch stream 复用鉴权和 `X-Yoyuzh-Client-Id` 请求头,不直接使用原生 `EventSource`;桌面 `Files` 与移动 `MobileFiles` 已在当前目录订阅事件,收到变更后失效当前目录缓存并刷新列表。 - -## 2026-04-08 阶段 6 任务框架与 worker 后端最小骨架 - -- 后端新增 `BackgroundTask` / `BackgroundTaskType` / `BackgroundTaskStatus` / `BackgroundTaskRepository` / `BackgroundTaskService`,用于承载后续压缩、解压、缩略图、媒体元数据和清理类后台工作。 -- 新增受保护的 `/api/v2/tasks/**`:`GET /api/v2/tasks`、`GET /api/v2/tasks/{id}`、`DELETE /api/v2/tasks/{id}`、`POST /api/v2/tasks/{id}/retry`,以及 `POST /api/v2/tasks/archive`、`POST /api/v2/tasks/extract`、`POST /api/v2/tasks/media-metadata` 创建接口。 -- 任务创建入口集中在 `BackgroundTaskService` 校验 `StoredFile`:`fileId` 必须属于当前用户且未删除,请求 `path` 必须匹配由 `StoredFile.path + filename` 派生的真实逻辑路径;`ARCHIVE` 允许文件和目录,`EXTRACT` 当前只允许 zip-compatible 文件(`.zip/.jar/.war` 或 zip/java archive 内容类型),`MEDIA_META` 仅允许媒体类文件。任务 public/private state 使用服务端派生的 `fileId`、`path`、`filename`、`directory`、`contentType`、`size`;其中 `ARCHIVE` 还会写入 `outputPath/outputFilename`,`EXTRACT` 会写入 `outputPath/outputDirectoryName`。 -- 当前实现新增了 worker 调度与多实例 lease:定时先回收 lease 已过期的 `RUNNING` 任务,再扫描少量 `QUEUED` 任务,通过状态条件更新完成 claim,并写入持久化 `leaseOwner/leaseExpiresAt/heartbeatAt` 与公开 `workerOwner/heartbeatAt/leaseExpiresAt/startedAt`。运行中所有 progress/完成/失败更新都要求 owner 匹配,丢失 lease 的旧 worker 不会覆盖新状态。 -- `MEDIA_META` 任务会进入独立 handler 写入基础媒体元数据与图片宽高,并在公开 state 写入 `metadataStage`;`ARCHIVE` 任务会调用 `FileService.buildArchiveBytes(...)` 生成 zip 并回写同级目录;`EXTRACT` 任务会读取 zip-compatible 归档、剥离共享根目录或把单文件直接恢复到父目录,再通过 `FileService.importExternalFilesAtomically(...)` 做预检、批量导入和失败 blob 清理。 -- `BackgroundTaskService` 还会在 `publicStateJson` 里统一维护最小进度阶段 `phase`:创建时是 `queued`,claim 后进入 `running`,worker 开始执行时按任务类型细化成 `archiving` / `extracting` / `extracting-metadata`,完成/失败/取消时再收口为 `completed` / `failed` / `cancelled`。 -- `ARCHIVE` 与 `EXTRACT` 任务现在会在运行和完成阶段暴露真实条目计数:`processedFileCount/totalFileCount`、`processedDirectoryCount/totalDirectoryCount`,并基于真实总量计算 `progressPercent`。其中 `ARCHIVE` 按实际写入 zip entry 推进,`EXTRACT` 按实际创建目录和导入文件推进;`MEDIA_META` 则暴露阶段型 `metadataStage`。 -- 当前 `POST /api/v2/tasks/{id}/retry` 已支持最小手动重试:只有 `FAILED` 任务可以被当前用户重置回 `QUEUED`,并清空 `finishedAt/errorMessage`,按 `privateStateJson` 重建公开 state,同时把 `attemptCount` 重置回 0。 -- `BackgroundTaskStartupRecovery` 现在只会在服务启动完成后回收 lease 已过期或历史上缺少 lease 的 `RUNNING` 任务,恢复时按 `privateStateJson` 重建公开 state;不会再无条件重排所有 `RUNNING` 任务。 -- worker 现在会按失败分类和任务类型做自动重试:失败会归到 `UNSUPPORTED_INPUT`、`DATA_STATE`、`TRANSIENT_INFRASTRUCTURE`、`RATE_LIMITED`、`UNKNOWN`;其中 `ARCHIVE` 默认最多 4 次、`EXTRACT` 最多 3 次、`MEDIA_META` 最多 2 次,公开 state 会暴露 `attemptCount/maxAttempts/retryScheduled/nextRetryAt/retryDelaySeconds/lastFailureMessage/lastFailureAt/failureCategory`。 -- 当前仍不包含非 zip 解压格式、缩略图/视频时长任务,以及 archive/extract 的前端入口。 -- 桌面端 `front/src/pages/Files.tsx` 已接入最近 10 条后台任务查看与取消入口,并可为当前选中文件创建 `MEDIA_META` 任务;移动端与 archive/extract 的前端入口仍未接入。 -## 11. UI 视觉系统与主题引擎 (2026-04-10 升级) - -### 11.1 设计语言:Stitch Glassmorphism - -全站视觉系统已全面转向“Stitch”玻璃拟态 (Glassmorphism) 风格,其核心特征包括: - -- **全局背景 (Aurora)**:在 `index.css` 中定义了 `bg-aurora`,结合颜色渐变与动态光晕产生深邃的底色。 -- **玻璃面板 (.glass-panel)**:核心 UI 容器均使用半透明背景 (`bg-white/40` 或 `bg-black/40`)、高饱和背景模糊 (`backdrop-blur-xl`) 和细腻的白色线条边框 (`border-white/20`)。 -- **浮动质感**:通过 `rounded-3xl` 或 `rounded-[2rem]` 的大圆角和外阴影增强层叠感。 - -### 11.2 主题管理 (Theme Engine) - -系统内建了一套完整的主题上下文,主要路径为: - -- `front/src/components/ThemeProvider.tsx`:提供 `light | dark | system` 主题状态切换与持久化,通过操作 `html` 根节点的 `class` 实现。 -- `front/src/components/ThemeToggle.tsx`:全局主题切换按钮组件。 -- `front/src/lib/utils.ts`:提供 `cn()` 工具函数,用于处理 Tailwind 类的动态组合与主题适配。 - -### 11.3 模块适配情况 - -- **用户侧**:网盘、快传、分享详情、任务列表、回收站均已完成适配。所有表格、卡片和导航栏均已升级为玻璃态。 -- **移动端**:`MobileLayout` 实现了一套悬浮式玻璃顶部标题栏与底部导航栏,并保持与桌面端一致的光晕背景。 -- **管理侧**:Dashboard 大盘指标卡片、用户列表、文件审计列表和存储策略列表均已同步升级。 - -## 12. Redis Foundation (2026-04-10) - -- 后端已引入 Spring Data Redis 与 Spring Cache,但 Redis 仍是可选基础设施:`app.redis.enabled=false` 时,应用会回退到 no-op token 失效服务与 `NoOpCacheManager`,本地与 dev 环境不需要外部 Redis 也能正常启动与测试。 -- Redis 配置拆成两层: - - `spring.data.redis.*`:连接参数。 - - `app.redis.*`:业务 key prefix、TTL buffer、cache TTL 与命名空间。 -- 当前声明的 Redis 命名空间包括:`cache`、`auth`、`transfer-sessions`、`upload-state`、`locks`、`file-events`、`broker`。本轮真正落地使用的是 `auth`,其余属于后续 Stage 1 边界预留。 -- 当前声明的 Spring Cache 名称包括:`files:list`、`admin:summary`、`admin:storage-policies`、`android:release`。本轮只完成了缓存边界与 TTL 骨架,尚未把具体读路径接到这些 cache。 -- 认证链路新增 Redis 失效层: - - access token:按 `userId + clientType` 记录“在此时间点之前签发的 token 失效”。 - - refresh token:按 token hash 写入黑名单,TTL 与剩余有效期对齐。 -- `JwtAuthenticationFilter` 现在会先检查 access token 是否已被 Redis 失效层拒绝,再继续执行原有的 JWT 校验、用户加载与 `sid` 会话匹配。 -- `AuthService` 与 `AdminService` 的同端重登、改密、封禁、管理员重置密码路径,现已统一调用这层服务;`RefreshTokenService` 在轮换、过期拒绝与批量撤销时也会同步刷新 refresh token 黑名单。 -## 12.1 Redis Foundation Batch 2 (2026-04-10) - -- `FileService.list(...)` 现已通过 `FileListDirectoryCacheService` 接入可选 Redis 热目录缓存,当前只缓存 `/api/files/list` 的目录分页结果,不混入搜索、回收站或后台任务列表。 -- 热目录缓存使用 `files:list` Spring Cache 命名空间,真实缓存 key 由 `userId + normalized path + page + size + fixed sort context + directory version` 组成;目录版本存放在 Redis KV 中,按目录粒度增量失效,避免全局清空。 -- 目录列表失效点已经覆盖 `mkdir`、上传完成、外部导入、回收站删除、回收站恢复、重命名、移动、复制与默认目录补齐,所有变更最终都归一到 `touchDirectoryListings(...)`。 -- 分布式锁新增 `DistributedLockService` 抽象与 Redis 实现,当前第一批只落在 `FileService.restoreFromRecycleBin(...)`,锁 key 为 `files:recycle-restore:{fileId}`,通过 `SETNX + TTL + owner token` 获取并用 Lua compare-and-delete 释放。 -- 上传会话运行态新增 `UploadSessionRuntimeStateService` 抽象与 Redis 实现,短生命周期状态写入 `upload-state` 命名空间;数据库里的 `UploadSession` 继续承担最终事实,Redis 只承载创建中、上传中、完成中这类运行态快照。 -- `UploadSessionV2Controller` 已把运行态映射到响应体 `runtime` 字段,便于前端轮询时直接读取 phase、已上传字节数、分片数、进度百分比与过期时间,而不需要额外拼装临时状态。 - -## 12.2 Redis Foundation Batch 3 (2026-04-10) - -- 轻量 broker 已新增 `LightweightBrokerService` 抽象:Redis 启用时使用 `RedisLightweightBrokerService` 把消息写入 Redis list;Redis 关闭时回退到 `InMemoryLightweightBrokerService`,继续支持本地单实例开发与测试。 -- 这层 broker 明确只服务“小规模、低成本、可接受保守语义”的异步触发,不承担高可靠消息系统职责;任务最终状态、重试、幂等与用户可见结果仍以数据库 `BackgroundTask` 为准。 -- 当前 broker 使用 `app.redis.namespaces.broker` 命名空间,首个 topic 为 `media-metadata-trigger`,消息体只携带最小触发上下文:`userId`、`fileId`、`correlationId`。 -- `FileService.saveFileMetadata(...)` 现在会在媒体文件元数据落库后通过 `MediaMetadataTaskBrokerPublisher` 做 after-commit 发布;非媒体文件、目录、缺少必要主键信息的条目不会进入 broker。 -- `MediaMetadataTaskBrokerConsumer` 通过定时 drain 方式消费 broker 消息,并调用 `BackgroundTaskService.createQueuedAutoMediaMetadataTask(...)` 创建 `MEDIA_META` 任务;该入口会先按 `correlationId` 去重,再校验文件仍存在、未删除且仍属于媒体文件,避免重复建任务。 -- 这批实现的目标是“让轻量 broker 先承担一类真实异步触发”,而不是替代现有 `BackgroundTask` worker,也不覆盖文件事件跨实例广播;后者仍归 Stage 1 Step 9 处理。 - -## 12.3 Redis Foundation Batch 4 (2026-04-10) - -- 在线快传 session 已从进程内 `ConcurrentHashMap` 提升为可选 Redis 支撑:`TransferSessionStore` 在 Redis 启用时把 session JSON 与 `pickupCode -> sessionId` 映射写入 `transfer-sessions` 命名空间,关闭时自动回退到原有内存模式。 -- Redis key 当前按 `session:{sessionId}` 与 `pickup:{pickupCode}` 组织,TTL 与 session `expiresAt` 对齐并附带 `app.redis.ttlBufferSeconds` 缓冲;因此 Redis 只承载在线快传的短生命周期运行态,不替代离线快传数据库模型。 -- `TransferSession` 新增内部快照序列化形状,用于保留 `receiverJoined`、信令队列、cursor 和文件清单等运行期状态;`joinSession`、`postSignal` 在修改在线 session 后会重新写回 store,避免 Redis 模式下只改内存副本而不持久化。 -- `TransferService.nextPickupCode()` 现在复用 `TransferSessionStore.nextPickupCode()`;Redis 启用时 pickup code 会先在 Redis 映射 key 上做短 TTL 预留,降低多实例并发创建在线快传 session 时的冲突概率。 -- 当前 Step 8 只覆盖在线快传 session 的跨实例 lookup/join 基础能力;离线快传仍继续使用 `OfflineTransferSessionRepository`,文件事件广播也仍留在 Step 9。 -## 12.4 Redis Foundation Batch 5 (2026-04-10) - -- 文件事件跨实例分发现在落地在 Redis pub/sub,而不是把 `SseEmitter` 或订阅状态搬进 Redis。每个实例仍只在本地维护 `userId -> subscriptions` 的内存映射,SSE 过滤逻辑继续由 `FileEventService` 负责。 -- `FileEventService.record(...)` 现在仍然先写 `FileEvent` 表;事务提交后会先向本实例订阅者投递,再通过 `FileEventCrossInstancePublisher` 把最小事件快照发布到 `keyPrefix:file-events:pubsub` topic。 -- Redis 开启时,`RedisFileEventPubSubPublisher` 会附带当前实例 `instanceId`;`RedisFileEventPubSubListener` 在收到消息后会忽略同实例回环消息,只把远端事件重建成 `FileEvent` 并交回 `FileEventService.broadcastReplicatedEvent(...)` 做本地 SSE 投递。 -- 这条链路的目标是“跨实例转发已提交的文件事件”,不是高可靠消息系统:它不重放历史事件,不替代 `FileEvent` 表持久化,也不承担断线补偿;真正的事件审计事实源仍然是数据库。 -- Redis 关闭时,`NoOpFileEventCrossInstancePublisher` 会让行为自动回退为原有单实例本地广播,dev 与本地测试环境不需要额外 Redis 也能继续运行。 -## 12.5 Redis Foundation Batch 6 (2026-04-10) - -- Spring Cache 鍦ㄨ繖涓€鎵规寮忔帴鍏ヤ簡涓ょ被楂樿浣庡啓璇昏矾寰勶細`AdminService.listStoragePolicies()` 浣跨敤 `admin:storage-policies`锛宍AndroidReleaseService.getLatestRelease()` 浣跨敤 `android:release`銆? -- 瀛樺偍绛栫暐鍒楄〃鐨勭紦瀛樺け鏁堢偣鏄槑纭殑绠$悊鍐欒矾寰勶細鍒涘缓銆佺紪杈戙€佸惎鍋滈兘鍦?`AdminService` 涓婄洿鎺?evict锛屼笉鎶婂叾浠栫敤鎴疯矾寰勬垨鏂囦欢璇昏矾寰勬贩杩涘悓涓€ cache銆? -- Android release metadata 鍒欐槸 TTL 椹卞姩鐨勭紦瀛橈細鏁版嵁婧愪粛鏄璞″瓨鍌ㄧ殑 `android/releases/latest.json`锛屽悗绔彧缂撳瓨鏋勫缓鍚庣殑 `AndroidReleaseResponse`锛屼笉缂撳瓨 APK 鍒嗗彂瀛楄妭娴併€? -- `admin summary` 缁忚瘎浼板悗鏆備笉鎺ュ叆缂撳瓨锛屽洜涓鸿繖涓?DTO 鍚屾椂缁勫悎浜嗛珮棰戝彉鍖栫殑 request metrics銆佹瘡鏃ユ椿璺冪敤鎴风粺璁″拰閭€璇风爜绛夊€硷紝鐩墠娌℃湁涓€涓共鍑€鐨勬樉寮忓け鏁堣竟鐣岄€傚悎鎶婂畠鏀惧叆 Spring Cache銆? -## 12.5 Redis Foundation Batch 6 Clarification (2026-04-10) - -- Spring Cache is now active on two high-read, low-write backend read paths. -- `AdminService.listStoragePolicies()` uses cache `admin:storage-policies`. -- `AndroidReleaseService.getLatestRelease()` uses cache `android:release`. -- Storage policy cache invalidation is explicit and tied to admin create, update, and status-change writes. -- Android release metadata uses TTL-based refresh because the source of truth is object storage metadata at `android/releases/latest.json`, updated by the release publish script rather than an in-app write path. -- APK byte streaming remains uncached; only the metadata response is cached. -- `admin summary` remains uncached by design because it mixes several high-churn metrics and does not yet have a clean invalidation boundary. -## 12.6 Redis Foundation Batch 7 Clarification (2026-04-10) - -- `DogeCloudS3SessionProvider` intentionally remains a per-instance in-memory cache instead of moving to Redis. -- The cached object is not just raw temporary credentials; it is a live runtime session containing `S3Client` and `S3Presigner`, both of which have local lifecycle and cleanup semantics. -- Because of that, a Redis-backed shared cache would either have to cache only raw credential material and rebuild SDK clients locally anyway, or attempt to share values that are not meaningful across JVM instances. -- The current design keeps refresh ownership local to each backend instance: if cached credentials are still outside the one-minute refresh window, the existing runtime session is reused; once inside that window, the old runtime session is closed and a fresh one is fetched and rebuilt. -- This leaves some duplicate DogeCloud temporary-token fetches in multi-instance deployments, but the current plan judges that cost lower than the added complexity and secret-handling surface of a Redis shared-credential cache. -## 12.7 Redis Foundation Batch 8 Clarification (2026-04-10) - -- Stage 1 validation closed with two local checks: full backend test regression and a Redis-disabled `dev` boot-path check. -- The local boot-path check matters because Redis integration is optional by design. With `APP_REDIS_ENABLED=false`, the application still starts as a normal single-instance backend once mandatory base config such as `APP_JWT_SECRET` is present. -- In the validated local path, the backend started successfully on an alternate port (`18081`) under the `dev` profile, using H2 and no Redis dependency. -- Therefore the current architecture boundary remains unchanged: Redis augments cache, pub/sub, lock, broker, and short-lived runtime state when enabled, but it is not a required baseline component for local development or single-instance fallback. -- The architecture still has explicit environment-bound gaps that were not closed in-process: real Redis reliability/TTL observation and cross-instance propagation timing for file events, lightweight broker delivery, upload runtime state, and transfer-session sharing. - -## 12.8 Manual Redis Validation Clarification (2026-04-10) - -- The later manual validation pass did exercise real local Redis plus two backend instances, so several Stage 1 architecture claims are now locally runtime-validated rather than only unit/integration-tested. -- Verified runtime behaviors: -- auth token invalidation survives cross-instance login churn; -- online transfer runtime state survives loss of the creating instance; -- file events can cross instances through the SSE path when a real uploaded file triggers a `CREATED` event; -- the lightweight broker can auto-create a queued `MEDIA_META` task after a media upload and that task is visible from the peer instance. -- The Redis file list cache architecture also needed one implementation detail clarified: Spring Cache may hand back generic decoded maps from Redis, so `RedisFileListDirectoryCacheService` now treats cache-value reconstruction as an application concern instead of assuming a strongly typed cache provider result. -- The persistence model also still carries `portal_file.storage_name` as a required column in the live schema, so even after blob/entity migration work the backend must continue writing a non-null legacy storage name for directories and uploaded files until a later schema migration explicitly removes that requirement. -- One environment gap remains: local `redis-cli` key inspection did not reveal the expected keys during probing even while cross-instance behavior proved shared runtime state was active. That means the current architectural confidence comes from observable runtime behavior, not from direct local key-space inspection. - -## Debugging Discipline - -- Use short bounded probes first when validating network, dependency, or startup issues. Prefer commands such as `curl --max-time`, `mvn -q`, `mvn dependency:get`, `apt-get update`, and similar narrow checks before launching long-running downloads or full test runs. -- Do not wait indefinitely on a stalled download or progress indicator. If a command appears stuck, stop and re-check DNS, proxy inheritance, mirror reachability, and direct-vs-proxy routing before retrying. -- For WSL debugging, verify the proxy path and the direct path separately, then choose the shortest working route. Do not assume a mirror problem until the network path has been isolated. -- Use domestic mirrors as a delivery optimization, not as a substitute for diagnosis. First determine whether the failure is caused by DNS, proxy configuration, upstream availability, or the mirror itself. - -## 12.9 Admin Backend Surface Clarification (2026-04-11) - -- The admin module now covers four distinct backend inspection domains: -- user and summary management; -- logical file management; -- storage policy management and migration task creation; -- operational inspection for file blobs, shares, and background tasks. -- `GET /api/admin/file-blobs` is architected around `FileEntity` plus `StoredFileEntity` relations instead of around `StoredFile` rows. This keeps the admin surface aligned with the newer object/entity model and lets operators inspect storage-policy ownership, reference counts, and missing-object anomalies before the legacy read path is retired. -- `GET /api/admin/shares` and `DELETE /api/admin/shares/{shareId}` sit on top of `FileShareLinkRepository` and are intended as operational controls for share hygiene rather than end-user sharing flows. -- `GET /api/admin/tasks` and `GET /api/admin/tasks/{taskId}` sit on top of `BackgroundTaskRepository` and parse structured fields out of `publicStateJson` so the admin UI can inspect failure category, retry scheduling, worker owner, and lease freshness without re-implementing backend parsing rules. -- This batch does not change the current production read-path boundary: download, share detail, recycle-bin, and zip flows still read from `StoredFile.blob`, while `FileEntity` and `StoredFile.primaryEntity` continue to carry migration-oriented metadata for newer admin and storage-policy workflows. diff --git a/docs/superpowers/plans/2026-04-10-cloudreve-gap-next-phase-upgrade.md b/docs/superpowers/plans/2026-04-10-cloudreve-gap-next-phase-upgrade.md index 1dca7d6..84d0953 100644 --- a/docs/superpowers/plans/2026-04-10-cloudreve-gap-next-phase-upgrade.md +++ b/docs/superpowers/plans/2026-04-10-cloudreve-gap-next-phase-upgrade.md @@ -271,7 +271,7 @@ - 媒体处理开关 - Redis / runtime 只读状态 -- [ ] **Step 1: 设计参数设置 DTO 与权限边界** +- [x] **Step 1: 设计参数设置 DTO 与权限边界** - [ ] **Step 2: 先拆站点信息子分组** - 站点名称 - 站点描述 @@ -299,9 +299,9 @@ - 前端品牌化字段 - CDN / 静态资源缓存参数 - 服务器运行信息、Redis 状态、存储后端状态 -- [ ] **Step 2: 暴露管理员参数读取与更新接口** -- [ ] **Step 3: 只允许修改当前可安全热更新的参数** -- [ ] **Step 4: 文档化哪些配置仍需环境变量或重启** +- [x] **Step 2: 暴露管理员参数读取与更新接口** +- [x] **Step 3: 只允许修改当前可安全热更新的参数** +- [x] **Step 4: 文档化哪些配置仍需环境变量或重启** ### Admin-B2: File System @@ -710,3 +710,32 @@ - `cd backend && mvn -Dtest=AdminControllerIntegrationTest,AdminServiceTest,AdminServiceStoragePolicyCacheTest test` - `cd backend && mvn test` - Full backend result after this landing note: 304 tests passed. + +## 2026-04-11 Admin Next-Phase Backend Landing Note 2 + +- Admin-B1 and Admin-B2 have now both started with read-only backend surfaces. +- Implemented: +- `GET /api/admin/settings` +- `GET /api/admin/filesystem` +- `GET /api/admin/settings` currently stops at backend-owned observation: invite-code state, configured admin usernames, JWT/user-session timing, Redis/token-blacklist availability, queue cadence, and storage/Redis runtime mode. +- `GET /api/admin/filesystem` currently stops at operational observation: default policy snapshot, resolved upload-mode matrix, effective max file size, metadata/thumbnail capability flags, cache backend/TTL status, aggregate file/blob/entity counts, and reserved-off `WebDAV` state. +- This batch intentionally does not introduce writable parameter settings yet. Hot-update safety boundaries and persistent admin writes remain part of the next Admin-B1 follow-up. +- Verification passed in WSL with: +- `cd backend && mvn -Dtest=AdminControllerIntegrationTest,AdminServiceTest,AdminServiceStoragePolicyCacheTest test` +- `cd backend && mvn test` + +## 2026-04-11 Admin Next-Phase Backend Landing Note 3 + +- Admin-B1 has now moved beyond read-only snapshots into the first bounded write path. +- Implemented: +- `PATCH /api/admin/settings/registration/invite-code` +- `POST /api/admin/settings/registration/invite-code/rotate` +- `GET /api/admin/settings` now also returns per-section `writeSupported` flags and a `transfer` section exposing the persisted offline-transfer storage limit. +- Current hot-update boundary is explicit: +- writable now: current registration invite code, offline transfer storage limit; +- still read-only/runtime-derived: admin usernames, JWT lifetimes, Redis enablement and TTL policy, queue backend/cadence, storage provider, and other environment-bound server settings. +- The invite-code write path is deliberately backed by the existing `RegistrationInviteState` row instead of introducing a generic mutable config store. +- Verification passed in WSL with: +- `cd backend && mvn -Dtest=AdminControllerIntegrationTest,AdminServiceTest,AdminServiceStoragePolicyCacheTest test` +- `cd backend && mvn test` +- Full backend result after this batch: 310 tests passed. diff --git a/docs/superpowers/plans/2026-04-10-frontend-upgrade-modules-plan.md b/docs/superpowers/plans/2026-04-10-frontend-upgrade-modules-plan.md new file mode 100644 index 0000000..d3d3eb5 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-frontend-upgrade-modules-plan.md @@ -0,0 +1,620 @@ +# Frontend Upgrade Modules Plan + +> **For agentic workers:** REQUIRED: Use `superpowers:executing-plans` or `superpowers:subagent-driven-development` when implementing this plan. Keep checkbox state updated when modules land. + +**Goal:** 为当前项目整理一份独立的前端升级计划书,明确为了配合现有 v2 能力闭环、运行时能力升级、媒体预览和后续 WebDAV/平台化演进,前端还需要新增或重构哪些模块。 + +**Repository:** `C:\Users\yoyuz\Documents\code\my_site` + +## 1. Current Frontend Baseline + +当前前端已经具备: + +- 桌面端主路由:`/overview`、`/files`、`/tasks`、`/shares`、`/recycle-bin`、`/transfer`、`/admin/*` +- 移动端壳与底部导航 +- 文件页基础搜索、上传、分享、删除、回收站入口 +- 任务页和概览页的后台任务消费 +- 分享页与分享列表页 +- 管理台用户、文件、存储策略页面 +- `front/src/lib/*` 下的 API helper 已基本分层 + +当前明显缺的前端模块不是“页面太少”,而是: + +- 缺统一的客户端运行时状态层 +- 缺缓存感知与失效感知 +- 缺跨页面的上传状态中心 +- 缺更完整的任务入口和任务消费组件 +- 缺媒体预览组件体系 +- 缺移动端对应能力补齐 +- 缺面向多实例和运行时状态变化的前端协作层 + +## 2. Upgrade Stage Alignment + +这份前端计划对应的是本轮总升级里的全部前端侧工作。与主升级计划的关系如下: + +### Stage 1: Runtime Foundation + +前端对应模块: + +- Session And Auth Runtime +- Files Data And Directory Cache +- Upload Runtime +- Tasks Runtime +- Realtime Event Bridge +- Admin Operations Surface + +前端目标: + +- 正确消费 token 黑名单和登录态失效 +- 感知热门目录缓存与失效 +- 消费上传状态缓存 +- 为异步任务和多实例事件刷新预留前端运行时 + +### Stage 2: Close Existing v2 Gaps + +前端对应模块: + +- Files Data And Directory Cache +- Upload Runtime +- Tasks Runtime +- Mobile Capability Parity + +前端目标: + +- 移动端搜索补齐 +- archive/extract 前端入口补齐 +- 移动端任务入口补齐 +- 旧读取链路切换后的 UI 消费收口 + +### Stage 3: Thumbnail And Rich Media Pipeline + +前端对应模块: + +- Media Preview System +- Tasks Runtime +- Mobile Capability Parity + +前端目标: + +- 文件列表缩略图 +- 详情侧栏 / 弹层预览 +- 视频时长、poster、媒体属性展示 +- 缩略图与媒体任务状态反馈 + +### Stage 4: Metadata, Labels, And Search Expansion + +前端对应模块: + +- Files Data And Directory Cache +- Media Preview System +- Mobile Capability Parity + +前端目标: + +- 高级搜索 +- 标签与 metadata 筛选 +- 搜索条件与目录视图解耦 + +### Stage 5: WebDAV Minimum Viable Support + +前端对应模块: + +- Session And Auth Runtime +- Admin Operations Surface + +前端目标: + +- 如后端采用应用密码 / 专用凭据,则前端需要提供管理入口 +- 管理台提供 WebDAV / 外部客户端配置展示位 + +## 3. Frontend Module Map + +建议把后续前端升级拆成 8 个模块域: + +1. Session & Auth Runtime +2. Files Data & Directory Cache +3. Upload Runtime +4. Tasks Runtime +5. Realtime Event Bridge +6. Media Preview System +7. Admin Operations Surface +8. Mobile Capability Parity + +## 4. Admin IA Alignment + +参考成熟项目后台目录,当前前端管理台后续不应只保留: + +- Dashboard +- Users +- Files +- Storage Policies + +而应逐步演进为如下信息架构: + +1. 面板首页 +2. 参数设置 +3. 文件系统 +4. 存储策略 +5. 节点 +6. 用户组 +7. 用户 +8. 文件 +9. 文件 Blob +10. 分享 +11. 后台任务 +12. 订单 +13. 事件 +14. 滥用举报 +15. OAuth 应用 + +当前项目前端建议分三层推进: + +### Layer A: 当前阶段必须补齐 + +- 面板首页 +- 参数设置 +- 文件系统 +- 存储策略 +- 用户 +- 文件 +- 文件 Blob +- 分享 +- 后台任务 + +### Layer B: 预留导航位但可先只读或隐藏 + +- OAuth 应用 +- 节点 +- 用户组 + +### Layer C: 暂不实现 + +- 订单 +- 事件独立中心 +- 滥用举报 + +前端管理台路由最终建议朝这个结构靠拢: + +- `/admin/dashboard` +- `/admin/settings` +- `/admin/filesystem` +- `/admin/storage-policies` +- `/admin/users` +- `/admin/files` +- `/admin/file-blobs` +- `/admin/shares` +- `/admin/tasks` +- `/admin/oauth-apps` + +其中两个最重要的二级结构要先定下来: + +### 参数设置页内部结构 + +建议采用顶部 tabs: + +1. 站点信息 +2. 用户会话 +3. 验证码 +4. 媒体处理 +5. 增值服务 +6. 邮件 +7. 队列 +8. 外观 +9. 事件 +10. 服务器 + +当前项目建议: + +- 先做可编辑: + - 站点信息 + - 用户会话 + - 媒体处理 + - 队列 + - 外观 +- 先做只读: + - 服务器 +- 先预留标签但不做深实现: + - 验证码 + - 邮件 + - 事件 +- 当前不做: + - 增值服务 + +### 文件系统页内部结构 + +建议采用顶部 tabs: + +1. 参数设置 +2. 全文搜索 +3. 文件图标 +4. 文件浏览应用 +5. 自定义属性 + +当前项目建议: + +- 先做: + - 参数设置 + - 文件图标 + - 自定义属性 +- 先做只读壳: + - 文件浏览应用 +- 当前延后: + - 全文搜索 + +--- + +## 5. Module A: Session And Auth Runtime + +**Goal:** 让前端能正确消费后端登录态策略升级后的失效语义,而不是继续把登录态仅看成本地 `portal-session`。 + +**Files likely involved:** + +- `front/src/lib/api.ts` +- `front/src/lib/auth.ts` 或新增对应模块 +- `front/src/App.tsx` +- `front/src/pages/Login.tsx` +- `front/src/components/layout/Layout.tsx` +- `front/src/mobile-components/MobileLayout.tsx` +- `front/src/hooks/*` + +- [ ] **Step 1: 新增统一 session runtime 模块** + - 负责 access token、refresh token、client id、client type、当前用户信息 + - 不让多个页面各自处理 401 / refresh / logout + +- [ ] **Step 2: 补 token 撤销与强制失效感知** + - 后端返回“token 已失效 / 已撤销 / 会话被顶掉”时统一退出 + - 区分普通 401 与“需要强制重新登录”的 401 + +- [ ] **Step 3: 新增全局 auth error handler** + - 处理改密、管理员重置密码、封禁、同端挤下线 + - 避免文件页、任务页、分享页分别弹自己的错误 + +- [ ] **Step 4: 增加用户可见的会话状态提示组件** + - 例如顶部 banner、toast 或 modal + - 明确告诉用户“当前登录已失效,需要重新登录” + +**Deliverables:** + +- `session-runtime.ts` +- `use-session-runtime.ts` +- `AuthBoundary` 或同类组件 +- 全局 auth failure UI + +--- + +## 6. Module B: Files Data And Directory Cache + +**Goal:** 让前端能真正利用后端“热门目录缓存”,同时保持目录缓存、搜索结果、回收站和任务列表的边界清晰。 + +**Files likely involved:** + +- `front/src/pages/files/FilesPage.tsx` +- `front/src/mobile-pages/MobileFiles.tsx` +- `front/src/lib/files.ts` +- `front/src/lib/file-search.ts` +- `front/src/lib/file-events.ts` +- `front/src/lib/types.ts` +- 可新增 `front/src/lib/files-cache.ts` + +- [ ] **Step 1: 抽出统一的 files query key / cache key 生成模块** + - 覆盖 `path + page + size + view mode + sort` + - 不和搜索结果共用 + +- [ ] **Step 2: 新增目录数据缓存协调层** + - 负责首屏读取、命中缓存、后台刷新、失效 + - 与 SSE 事件和上传完成后的本地刷新统一 + +- [ ] **Step 3: 把搜索视图和目录视图彻底解耦** + - 搜索结果永不写回目录缓存 + - 清空搜索后才能回到目录态 + +- [ ] **Step 4: 增加“缓存命中但后台刷新中”的 UI 状态** + - 避免用户误以为界面卡死 + - 尤其适合热门目录 + +**Deliverables:** + +- `files-cache.ts` +- `use-directory-data.ts` +- 目录刷新状态 UI + +--- + +## 7. Module C: Upload Runtime + +**Goal:** 为后端上传状态能力做前端消费层,把上传队列从“局部页面状态”升级成“全局上传运行时”。 + +**Files likely involved:** + +- `front/src/lib/upload-session.ts` +- `front/src/pages/files/FilesPage.tsx` +- `front/src/mobile-pages/MobileFiles.tsx` +- `front/src/components/**/*` +- 可新增 `front/src/lib/upload-runtime.ts` +- 可新增 `front/src/components/upload/*` + +- [ ] **Step 1: 新增全局 upload runtime store** + - 管理上传项、会话 ID、进度、速度、错误、重试、取消 + - 桌面和移动共用 + +- [ ] **Step 2: 新增上传状态 polling / subscribe 模块** + - 消费后端暴露的短生命周期上传状态 + - 保持与数据库最终状态分离 + +- [ ] **Step 3: 新增上传中心 UI** + - 顶部悬浮面板或独立抽屉 + - 显示进行中、失败、完成 + +- [ ] **Step 4: 统一上传入口** + - 文件页上传 + - “保存到网盘” + - 后续 WebDAV 或外部导入触发后的状态展示 + +- [ ] **Step 5: 错误与恢复语义统一** + - 会话过期 + - 分片失败 + - 容量不足 + - 锁冲突 / 重复提交 + +**Deliverables:** + +- `upload-runtime.ts` +- `use-upload-runtime.ts` +- `UploadCenter` +- `UploadItemCard` + +--- + +## 8. Module D: Tasks Runtime + +**Goal:** 把当前任务页和概览页里分散的后台任务消费统一成一个任务运行时模块,并补齐 archive/extract 的前端入口。 + +**Files likely involved:** + +- `front/src/lib/background-tasks.ts` +- `front/src/pages/Tasks.tsx` +- `front/src/pages/Overview.tsx` +- `front/src/pages/files/FilesPage.tsx` +- `front/src/mobile-pages/MobileFiles.tsx` +- 可新增 `front/src/lib/task-runtime.ts` +- 可新增 `front/src/components/tasks/*` + +- [ ] **Step 1: 抽出统一 task state parser** + - 现在 `publicStateJson` 的解析不应只留在 `Tasks.tsx` + +- [ ] **Step 2: 新增任务类型组件化展示** + - `MEDIA_META` + - `ARCHIVE` + - `EXTRACT` + - 后续 `THUMBNAIL` + +- [ ] **Step 3: 为文件页增加任务快捷操作区** + - 创建压缩 + - 创建解压 + - 提取媒体信息 + +- [ ] **Step 4: 为移动端增加最小任务能力** + - 最近任务 + - 取消运行中任务 + - 查看失败原因 + +- [ ] **Step 5: 为异步任务 / 多实例任务更新预留刷新机制** + - 支持轮询或事件驱动刷新 + +**Deliverables:** + +- `task-runtime.ts` +- `TaskSummaryPanel` +- `TaskActionMenu` +- `TaskStatusBadge` + +--- + +## 9. Module E: Realtime Event Bridge + +**Goal:** 把当前 SSE 文件事件消费升级为真正的“前端实时桥接层”,适配跨实例事件分发后的实时语义。 + +**Files likely involved:** + +- `front/src/lib/file-events.ts` +- `front/src/pages/files/FilesPage.tsx` +- `front/src/mobile-pages/MobileFiles.tsx` +- `front/src/pages/RecycleBin.tsx` +- 可新增 `front/src/lib/realtime-runtime.ts` + +- [ ] **Step 1: 把文件事件订阅从页面内逻辑抽到 runtime** + - 管理连接、重连、暂停、恢复 + +- [ ] **Step 2: 增加事件去重与防抖层** + - 防止事件总线广播 + 本地更新重复触发刷新 + +- [ ] **Step 3: 让目录页、回收站、任务页可订阅不同事件域** + - 文件目录变更 + - 回收站恢复/删除 + - 任务状态刷新 + +- [ ] **Step 4: 增加连接质量 UI** + - 如“实时同步中 / 已断开,正在重连” + +**Deliverables:** + +- `realtime-runtime.ts` +- `use-realtime-status.ts` +- `RealtimeStatusChip` + +--- + +## 10. Module F: Media Preview System + +**Goal:** 为缩略图、poster、视频时长和 richer preview 提供统一前端消费层。 + +**Files likely involved:** + +- `front/src/pages/files/FilesPage.tsx` +- `front/src/mobile-pages/MobileFiles.tsx` +- `front/src/pages/FileShare.tsx` +- `front/src/lib/files.ts` +- `front/src/lib/types.ts` +- 可新增 `front/src/components/media/*` + +- [ ] **Step 1: 新增文件缩略图组件** + - 图片缩略图 + - 视频封面 + - 加载中和失败 fallback + +- [ ] **Step 2: 新增媒体元数据展示组件** + - 时长 + - 尺寸 + - 基本编码信息 + +- [ ] **Step 3: 新增详情侧栏或预览弹层** + - 桌面优先 + - 移动端先做简化版 + +- [ ] **Step 4: 为分享页补预览消费能力** + - 不破坏密码保护和权限模型 + +**Deliverables:** + +- `FileThumbnail` +- `MediaMetadataPanel` +- `FilePreviewDialog` 或同类组件 + +--- + +## 11. Module G: Admin Operations Surface + +**Goal:** 让管理台能够消费运行时与平台化能力,而不只是停留在用户/文件/存储策略。 + +**Files likely involved:** + +- `front/src/admin/*` +- `front/src/lib/admin*.ts` +- 可新增 `front/src/admin/runtime-dashboard.tsx` + +- [ ] **Step 1: 重构管理台导航信息架构** + - 从当前 4 个核心资源扩展为 dashboard / settings / filesystem / storage-policies / users / files / file-blobs / shares / tasks / oauth-apps + - 移动端继续对 admin 路由做受限处理,不强行开放完整后台 + +- [ ] **Step 2: 新增参数设置页** + - 展示和编辑当前允许后台维护的系统参数 + - 明确哪些配置只读、哪些可热更新 + - 使用 tabs 承载:站点信息 / 用户会话 / 验证码 / 媒体处理 / 增值服务 / 邮件 / 队列 / 外观 / 事件 / 服务器 + +- [ ] **Step 3: 新增文件系统页** + - 展示上传模式、媒体处理、缓存、WebDAV、运行态能力 + - 使用 tabs 承载:参数设置 / 全文搜索 / 文件图标 / 文件浏览应用 / 自定义属性 + +- [ ] **Step 4: 新增文件 Blob 页** + - 展示 Blob / Entity 列表、引用数、对象 key、策略、实体类型 + +- [ ] **Step 5: 新增分享管理页** + - 展示分享状态、过期、下载数、查看数、密码保护状态 + +- [ ] **Step 6: 新增后台任务管理页** + - 与用户侧任务页不同,这里展示全局任务池、失败分类、worker/lease 状态 + +- [ ] **Step 7: 新增 runtime 观测页或卡片** + - 热门目录缓存命中 + - 队列积压 + - 强制失效 token 规模 + - 事件总线状态 + +- [ ] **Step 8: 新增任务运行态概览** + - 缩略图、媒体处理、迁移任务分布 + +- [ ] **Step 9: 为后续 WebDAV / 外部客户端 / OAuth 应用预留管理入口** + - 当前不必完整实现 + - 但前端信息架构要留位置和路由壳 + +**Deliverables:** + +- `RuntimeDashboard` +- `QueueHealthCard` +- `CacheStatsCard` +- `AdminSettingsPage` +- `AdminSettingsSiteInfoTab` +- `AdminSettingsSessionsTab` +- `AdminSettingsMediaTab` +- `AdminSettingsQueueTab` +- `AdminSettingsAppearanceTab` +- `AdminSettingsServerTab` +- `AdminFilesystemPage` +- `AdminFilesystemSettingsTab` +- `AdminFilesystemIconsTab` +- `AdminFilesystemViewerAppsTab` +- `AdminFilesystemMetadataTab` +- `AdminFileBlobsPage` +- `AdminSharesPage` +- `AdminTasksPage` +- `AdminOAuthAppsPage`(可先空壳) + +--- + +## 12. Module H: Mobile Capability Parity + +**Goal:** 不让桌面端继续越走越远,所有新能力都要同步评估移动端最小实现。 + +**Files likely involved:** + +- `front/src/mobile-pages/*` +- `front/src/mobile-components/*` +- `front/src/MobileApp.tsx` + +- [ ] **Step 1: 移动端搜索能力补齐** +- [ ] **Step 2: 移动端任务面板补齐** +- [ ] **Step 3: 移动端上传中心补齐** +- [ ] **Step 4: 移动端文件预览最小化接入** +- [ ] **Step 5: 移动端实时状态提示补齐** + +**Deliverables:** + +- `MobileSearchBar` +- `MobileTaskDrawer` +- `MobileUploadCenter` +- `MobilePreviewSheet` + +--- + +## 13. Recommended Execution Order + +1. Module A: Session And Auth Runtime +2. Module B: Files Data And Directory Cache +3. Module C: Upload Runtime +4. Module D: Tasks Runtime +5. Module E: Realtime Event Bridge +6. Module H: Mobile Capability Parity +7. Module F: Media Preview System +8. Module G: Admin Operations Surface + +## 14. Verification Rules + +前端阶段验证只使用仓库已有命令: + +- `cd front && npm run test` +- `cd front && npm run lint` +- `cd front && npm run build` + +必要时补充手动验证: + +- 登录失效与重新登录流程 +- 热门目录首屏和失效刷新 +- 上传中心状态变化 +- 任务创建、取消、失败重试 +- SSE/实时状态重连 +- 移动端同能力检查 + +## 15. Documentation Follow-Up + +当前端新增关键模块后,需要同步更新: + +- `memory.md` +- `docs/architecture.md` +- `docs/api-reference.md` + +至少记录: + +- 新前端运行时模块名称和职责 +- 与后端运行时能力的对应关系 +- 是否桌面/移动双端都已接入 +- 当前仍缺的 UI 闭环 diff --git a/docs/superpowers/plans/2026-04-11-backend-refactor-plan.md b/docs/superpowers/plans/2026-04-11-backend-refactor-plan.md new file mode 100644 index 0000000..bdf11bc --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-backend-refactor-plan.md @@ -0,0 +1,328 @@ +# 后端重构方案草案 + +## 一、需要重构的模块 + +1. 在线快传模块 + - 相关代码:`transfer/TransferService`、`transfer/TransferSessionStore`、`transfer/TransferSession` + +2. 离线快传模块 + - 相关代码:`transfer/TransferService`、`OfflineTransferSessionRepository`、存储额度相关 admin 指标逻辑 + +3. 异步任务模块 + - 相关代码:`files/tasks/BackgroundTaskService`、`BackgroundTaskWorker`、`BackgroundTaskRepository`、`BackgroundTask` + +4. Broker 消息模块 + - 相关代码:`common/broker/*`、`MediaMetadataTaskBrokerPublisher`、`MediaMetadataTaskBrokerConsumer` + +5. 文件事件模块 + - 相关代码:`files/events/FileEventService`、`RedisFileEventPubSubPublisher`、`RedisFileEventPubSubListener` + +6. 上传会话模块 + - 相关代码:`files/upload/UploadSessionService`、`UploadSessionRuntimeStateService`、`RedisUploadSessionRuntimeStateService` + +7. 认证会话模块 + - 相关代码:`auth/AuthService`、`RefreshTokenService`、`AuthTokenInvalidationService`、`config/JwtAuthenticationFilter` + +8. Admin 设置模块 + - 相关代码:`admin/AdminService`、settings/filesystem 相关 DTO 和 controller + +## 二、每个模块现在的问题 + +### 1. 在线快传模块 +- 业务目标看起来是“支持 Redis + 多实例 + 分布式锁”。 +- 但当前锁只包读取,不包“读取后修改再保存”整个事务。 +- Redis 模式下是整对象覆盖写,存在并发覆盖和信令丢失问题。 +- “仅允许一个接收方加入”是业务规则,但现在不能稳定保证。 + +### 2. 离线快传模块 +- 会话、文件上传、ready 状态、存储额度检查都耦合在 `TransferService`。 +- 存储额度检查不是原子行为,并发上传可能突破上限。 +- 上传状态推进和额度校验缺少统一状态机。 + +### 3. 异步任务模块 +- 任务创建、claim、lease、heartbeat、retry、manual retry、状态 JSON 解析都混在一个 service。 +- `correlationId` 被当作幂等键使用,但没有数据库级保证。 +- attempt/retry 语义虽然有实现,但规则没有被明确定义和隔离。 +- `public_state_json/private_state_json` 既是业务状态,又承担运行态和展示态,职责混杂。 + +### 4. Broker 消息模块 +- 当前消费语义是“先 pop 再处理”,天然是 at-most-once。 +- 但业务又想靠 `correlationId` 做去重,更像 at-least-once。 +- 消息可靠性、重试、死信、幂等边界都没有单独建模。 +- broker 只是技术实现,但现在承担了业务可靠性语义。 + +### 5. 文件事件模块 +- 本地 SSE 推送、事件持久化、跨实例广播在一个 service 中耦合。 +- listener 对坏消息直接抛异常,容错策略不清晰。 +- 本地订阅过滤规则、跨实例复制规则、事件 payload 规则没有独立边界。 + +### 6. 上传会话模块 +- DB 状态和 Redis runtime 状态并存,但权威关系没有被结构化表达。 +- Redis runtime 读写失败基本是静默处理,观测和告警语义不明确。 +- 上传模式判断、会话状态推进、远端对象清理、运行态刷新都在一个 service 里。 + +### 7. 认证会话模块 +- access token 吊销、refresh token 轮换、active session 切换、封禁/改密后的清理都存在,但没有统一策略层。 +- “立即失效”的定义不够严格,尤其是时间边界处理。 +- 桌面端/移动端的单端活跃规则散落在多个方法中。 + +### 8. Admin 设置模块 +- 可写设置、运行态快照、环境配置读取混在一个聚合响应里。 +- `writeSupported` 只能提示能力,不能从结构上表达“配置源”和“运行态”区别。 +- 业务上已经有“可持久化设置”和“只读快照”两类概念,但代码层还没完全分开。 + +## 三、应该如何拆分职责 + +### 1. 在线快传模块 +建议拆成: +- `OnlineTransferSessionService` + - 负责业务规则:join、postSignal、poll、过期判断 +- `OnlineTransferSessionRepository` + - 负责会话持久化/加载 +- `OnlineTransferSessionLockManager` + - 负责会话级原子执行 +- `OnlineTransferSessionAggregate` + - 负责接收方唯一、信令队列、cursor 等领域行为 + +目标: +- 所有读改写保存通过一个原子入口完成 +- service 不直接操作 Redis 细节 + +### 2. 离线快传模块 +建议拆成: +- `OfflineTransferSessionService` +- `OfflineTransferStorageQuotaService` +- `OfflineTransferFileStorageService` + +目标: +- 会话状态推进和额度规则分离 +- 上传文件、ready 判定、容量扣减有清晰边界 + +### 3. 异步任务模块 +建议拆成: +- `BackgroundTaskCommandService` + - 创建、取消、人工重试 +- `BackgroundTaskExecutionService` + - claim、heartbeat、complete、fail +- `BackgroundTaskRetryPolicy` + - retryable、attempt、delay 计算 +- `BackgroundTaskStateCodec` + - JSON <-> typed state +- `BackgroundTaskIdempotencyService` + - correlationId 规则 + +目标: +- “任务业务规则”和“任务运行机制”分离 +- state JSON 不再到处手写 merge/remove + +### 4. Broker 消息模块 +建议拆成: +- `BrokerMessagePublisher` +- `BrokerMessageConsumer` +- `BrokerDeliveryPolicy` +- `BrokerMessageHandler` + +目标: +- 明确消息语义是 at-least-once +- 可靠性和业务处理解耦 +- 去重不靠消费者临时判断,而靠任务幂等层 + +### 5. 文件事件模块 +建议拆成: +- `FileEventRecorder` + - 落库 +- `FileEventDispatcher` + - 本地 SSE 分发 +- `FileEventReplicationPublisher` + - 跨实例发布 +- `FileEventReplicationConsumer` + - 跨实例消费 +- `FileEventPayloadCodec` + - payload 结构规则 + +目标: +- 本地分发和跨实例同步分离 +- 坏消息只影响当前消息,不影响整体监听 + +### 6. 上传会话模块 +建议拆成: +- `UploadSessionLifecycleService` + - create/cancel/complete/prune +- `UploadSessionContentService` + - prepare/upload/part-record +- `UploadSessionRuntimeViewService` + - 运行态读取 +- `UploadSessionRuntimeStateStore` + - Redis/no-op 存储实现 +- `UploadPolicyResolver` + - 上传模式、大小限制、策略能力决策 + +目标: +- DB 生命周期和 Redis 观测态分离 +- runtime state 明确为辅助层 + +### 7. 认证会话模块 +建议拆成: +- `AuthSessionPolicy` + - 单端活跃、吊销、生效边界 +- `AccessTokenRevocationService` +- `RefreshTokenRotationService` +- `UserSessionRotationService` + +目标: +- 改密/封禁/登录/刷新都走同一套会话规则 +- 避免规则散落在 `AuthService` 和 admin 逻辑里 + +### 8. Admin 设置模块 +建议拆成: +- `AdminConfigSnapshotService` + - 只读运行态 +- `AdminMutableSettingsService` + - 可写设置 +- `AdminSettingsFacade` + - 对外聚合 + +目标: +- 区分“系统当前状态”和“可持久化业务设置” +- 减少 admin service 持续膨胀 + +## 四、哪些逻辑应抽到公共规则层 + +这些不该继续散在各 service 里: + +### 1. 幂等规则层 +- `correlationId` 生成规则 +- 同一业务动作是否允许重复创建任务 +- 幂等冲突时返回什么结果 + +### 2. 状态机规则层 +- 在线快传会话状态推进 +- 离线快传 ready 条件 +- 上传会话状态推进 +- 后台任务状态推进 + +### 3. 并发规则层 +- 会话级锁 +- 额度检查的原子更新 +- 任务 claim/lease 规则 + +### 4. 重试规则层 +- 哪些错误可重试 +- attemptCount 如何累积 +- 自动重试和人工重试的区别 +- backoff 计算规则 + +### 5. 认证会话规则层 +- 单端活跃规则 +- 改密/封禁/重置密码后的清理规则 +- access token / refresh token 失效边界 + +### 6. 事件与消息规则层 +- file event payload 结构 +- listener 遇到坏消息如何处理 +- broker 消息投递语义 + +### 7. 配置语义规则层 +- “运行态快照” +- “持久化设置” +- “环境配置只读项” + +## 五、推荐重构顺序 + +### 第一阶段:先统一规则,再动结构 +1. 先定四个核心语义 + - 在线快传并发语义 + - 异步任务幂等语义 + - broker 投递语义 + - 认证会话失效语义 + +2. 把这些规则抽成独立策略/规则类 + - 先不大改 controller 和 repository + +### 第二阶段:先改最危险的一致性问题 +1. 在线快传 + - 把读改写保存收敛到单个原子入口 +2. 异步任务幂等 + - 让 `correlationId` 成为真正约束 +3. broker 消费可靠性 + - 不再“先丢消息再处理” + +### 第三阶段:收敛状态机和运行机制 +1. 后台任务状态机拆分 +2. 上传会话 lifecycle/runtime 拆分 +3. 文件事件 recorder/dispatcher/replication 拆分 + +### 第四阶段:清理配置和管理侧语义 +1. admin settings 拆成 snapshot + mutable settings +2. auth session 规则统一落到策略层 +3. 去掉 service 中分散的规则分支 + +## 六、建议优先落地的重构任务列表 + +1. 先定义并固定“统一业务规则”接口 + - 不改功能,只固化规则边界 + +2. 重构在线快传 + - 先解决原子性问题 + +3. 重构后台任务幂等和 broker + - 先解决重复任务和消息丢失问题 + +4. 重构后台任务状态机 + - 把 retry/lease/attempt 语义统一 + +5. 重构文件事件模块 + - 提高跨实例容错 + +6. 重构上传会话模块 + - 明确 DB 状态与 Redis runtime 的关系 + +7. 最后整理 auth/admin + - 收口规则,减少横向重复逻辑 + +## 必须先达成一致的核心规则 + +1. 在线快传必须是“原子读改写”,不能接受覆盖写丢信令 +2. 自动媒体任务必须以“任务幂等”保证最终唯一,不能靠先查再插 +3. broker 语义统一为“至少投递一次 + 消费端幂等” +4. 后台任务 `attemptCount`、自动重试、人工重试必须统一定义 +5. 上传会话中 DB 状态是权威状态,Redis 只做辅助观测 +6. 文件事件坏消息必须隔离处理,不能拖垮整个 listener +7. 认证相关的“立即失效”必须有明确且统一的规则 +8. admin 设置必须区分“运行态快照”和“可写业务设置” + +## 2026-04-11 Execution Status + +Completed in the first implementation batch: + +1. Extracted task rule components from `BackgroundTaskService` + - `BackgroundTaskRetryPolicy` + - `BackgroundTaskStateManager` + - `BackgroundTaskStateKeys` + +2. Split file-event orchestration from local dispatch + - `FileEventService` now coordinates persistence and after-commit publishing + - `FileEventDispatcher` owns local SSE subscription and delivery + - `FileEventPayloadCodec` owns payload shaping + - malformed Redis pub/sub payloads are isolated and dropped by the listener + +3. Split upload rules from upload-session orchestration + - `UploadPolicyResolver` owns upload-mode and size/chunk rules + - `UploadSessionStateMachine` owns lifecycle transitions + - `UploadSessionService` now coordinates repository + runtime-state updates around those rules + +4. Extracted auth session rotation rules + - `AuthSessionPolicy` now owns per-client session rotation semantics used by `AuthService` + +Verification: + +- `cd backend && mvn "-Dtest=BackgroundTaskRetryPolicyTest,UploadSessionStateMachineTest,AuthSessionPolicyTest,FileEventServiceTest,RedisFileEventPubSubListenerTest,BackgroundTaskServiceTest,UploadSessionServiceTest,AuthServiceTest" test` +- `cd backend && mvn test` + +Current remaining high-value work from this plan: + +- deeper background-task command/execution separation +- offline-transfer quota atomicity split +- broader file-event replication boundary cleanup +- admin snapshot vs mutable-settings facade split +- further auth/admin rule consolidation diff --git a/docs/superpowers/plans/2026-04-11-enterprise-target-refactor-plan.md b/docs/superpowers/plans/2026-04-11-enterprise-target-refactor-plan.md new file mode 100644 index 0000000..773d8da --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-enterprise-target-refactor-plan.md @@ -0,0 +1,583 @@ +# 2026-04-11 企业级目标架构重构计划 + +## 1. 计划目标 + +本计划用于把当前仓库从“围绕现有功能堆叠的全栈实现”重构到 [architecture.md](C:/Users/yoyuz/Documents/code/my_site/docs/architecture.md) 定义的目标态企业架构。 + +目标不是做一次大爆炸式重写,而是按阶段把当前代码收敛到以下稳定形态: + +1. 统一身份与授权模型 +2. 工作空间与内容资产彻底分离 +3. 分享与快传成为两个独立业务域 +4. 上传成为内容接入机制,不再直接等同于文件业务 +5. 后台任务成为统一异步处理底座 +6. 存储策略成为系统级治理能力 +7. 管理端成为治理入口,不再夹杂业务规则事实源 + +## 2. 当前基线 + +### 2.1 后端当前包结构 + +- `auth` +- `files.core` +- `files.upload` +- `files.share` +- `files.search` +- `files.events` +- `files.tasks` +- `files.storage` +- `files.policy` +- `transfer` +- `admin` +- `common` +- `config` +- `api.v2` + +### 2.2 前端当前结构 + +- `pages` +- `admin` +- `lib` +- `components` +- `hooks` +- `mobile-pages` +- `mobile-components` + +### 2.3 已完成的重构基础 + +根据 `memory.md`,以下基础性工作已经完成,可以作为本计划起点: + +1. `BackgroundTaskRetryPolicy`、`BackgroundTaskStateManager`、`BackgroundTaskStateKeys` 已从 `BackgroundTaskService` 中抽离 +2. `UploadPolicyResolver` 与 `UploadSessionStateMachine` 已从上传会话主服务中抽离 +3. `AuthSessionPolicy` 已从 `AuthService` 中抽离 +4. 文件事件链路已拆出: + - `FileEventService` + - `FileEventDispatcher` + - `FileEventPayloadCodec` +5. 自动媒体元数据任务的 `correlationId` 幂等已经具备数据库级唯一约束 + +这说明当前代码已经有了第一批“规则抽离”的基础,但还没有进入真正的领域边界重组阶段。 + +## 3. 重构范围 + +本计划覆盖: + +- 后端领域重组 +- 后端 API 收口 +- 权限模型统一 +- 内容资产模型统一 +- 管理端权限和设置边界统一 +- 前端按业务域重组 + +本计划不覆盖: + +- UI 视觉重设计 +- Android 原生壳单独重写 +- 全量 API 一次性废弃 +- 数据库从零重建 + +## 4. 目标态模块映射 + +### 4.1 后端目标模块 + +目标态后端按业务域划分为: + +- `identity-access` +- `workspace` +- `content-asset` +- `sharing` +- `transfer` +- `async-job` +- `storage-governance` +- `operations-admin` +- `common-kernel` + +### 4.2 当前到目标的主要映射 + +| 当前模块 | 目标模块 | 说明 | +| --- | --- | --- | +| `auth` | `identity-access` | 保留认证能力,但要升级成统一账号、会话、角色、授权模型 | +| `files.core` | `workspace` + `content-asset` | 当前最需要拆分的模块 | +| `files.upload` | `content-asset` + `storage-governance` | 上传接入和存储策略不应继续混在文件业务里 | +| `files.share` | `sharing` | 需要收口旧分享与 v2 分享语义 | +| `transfer` | `transfer` | 保留域名,但拆分在线/离线子域和导入边界 | +| `files.tasks` | `async-job` | 升级为统一异步底座 | +| `files.events` | `workspace` + `async-job` 支撑层 | 事件不是独立业务域,应下沉为基础能力 | +| `files.policy` + `files.storage` | `storage-governance` | 统一存储策略、能力、迁移和对象落点 | +| `admin` | `operations-admin` | 从“全能 service”重构成治理入口 | +| `api.v2` | 各域 `api` 层 | 最终应按领域归属,而不是平行于领域存在 | + +### 4.3 前端目标模块 + +目标态前端按业务域收口为: + +- `account` +- `workspace` +- `sharing` +- `transfer` +- `admin` +- `common` + +当前的 `pages`、`mobile-pages`、`lib` 需要逐步按领域拆解,而不是继续按“页面+工具库”累计。 + +## 5. 重构总策略 + +### 5.1 先抽规则,再拆边界 + +先把高价值业务规则从胖 Service 中抽成稳定规则对象,再移动目录和模块边界。 +这样可以避免“边拆边改语义”。 + +### 5.2 先统一模型,再收口接口 + +先统一后端领域模型,再决定哪些旧接口保留、哪些 v2 接口成为唯一主线。 +不要先删接口,再倒逼领域重构。 + +### 5.3 先后端领域,后前端跟随 + +这轮重构必须以后端领域边界为主。 +前端模块化重组应跟随后端稳定领域,而不是反过来。 + +### 5.4 每阶段都要可回归验证 + +每个阶段必须能在当前仓库命令范围内验证: + +- `cd backend && mvn test` +- `cd front && npm run lint` +- `cd front && npm run build` + +前端当前没有稳定的测试执行链路,因此阶段验收主要依赖类型检查、构建和关键后端回归。 + +## 6. 阶段计划 + +## 阶段 0:冻结目标语义和兼容边界 + +### 目标 + +在开始大规模结构调整前,先冻结必须保持一致的业务语义和兼容边界。 + +### 产出 + +1. 唯一的角色模型:`VISITOR / MEMBER / OPERATOR / ADMIN` +2. 唯一的资源模型:`WorkspaceNode` 与 `ContentAsset` 分离 +3. 唯一的管理权限事实源 +4. 分享与快传的边界定义 +5. 旧接口与新接口的兼容期说明 + +### 当前代码关注点 + +- `auth/*` +- `admin/AdminAccessEvaluator.java` +- `config/SecurityConfig.java` +- `files/core/FileService.java` +- `files/share/ShareV2Service.java` +- `transfer/TransferService.java` + +### 必须先确认的决策 + +1. 是否废弃“管理员用户名白名单决定权限”这条规则 +2. 是否保留旧 `/api/files/share-links/**` +3. 是否保留旧 `/api/files/upload/**` +4. `maxDownloads` 是否继续同时约束导入和下载 +5. `MODERATOR` 是否升级为 `OPERATOR`,还是直接移除 + +### 验收标准 + +- 目标语义形成一份可执行的决策基线 +- 所有后续代码改动都不再重新定义这些问题 + +## 阶段 1:统一身份与授权域 + +### 目标 + +把当前 `auth + admin 权限入口 + session 规则` 重构为独立的 `identity-access` 域。 + +### 需要解决的问题 + +1. `User.role` 与 admin 白名单的双事实源 +2. access token 吊销、refresh token 旋转、活跃会话切换分散在不同位置 +3. 管理权限和资源权限没有统一授权模型 + +### 主要动作 + +1. 引入统一授权上下文 +2. 抽出角色判断和资源授权入口 +3. 让 `AuthSessionPolicy` 成为唯一会话规则入口 +4. 让 admin 鉴权走角色/权限模型,而不是用户名白名单 +5. 把 `DevAuthController` 对正式授权模型的影响限制在开发环境边界内 + +### 涉及文件 + +- `backend/src/main/java/com/yoyuzh/auth/*` +- `backend/src/main/java/com/yoyuzh/admin/AdminAccessEvaluator.java` +- `backend/src/main/java/com/yoyuzh/config/SecurityConfig.java` + +### 阶段结果 + +- 管理台权限不再依赖用户名硬编码 +- 登录态失效和客户端会话规则由统一策略控制 + +### 验收标准 + +- 所有受保护接口都能通过统一授权入口判断 +- 改密、封禁、重登、刷新不再各写一套会话切换逻辑 +- `cd backend && mvn test` 通过 + +## 阶段 2:拆分工作空间域与内容资产域 + +### 目标 + +把当前 `files.core` 这个胖模块拆成两个稳定业务域: + +- `workspace` +- `content-asset` + +### 需要解决的问题 + +1. `StoredFile` 同时承担路径、逻辑生命周期、物理内容引用 +2. `FileBlob` / `FileEntity` / `StoredFile` 三层模型没有清晰主从边界 +3. 删除、复制、导入、恢复同时跨逻辑层和物理层 + +### 目标模型 + +- `WorkspaceNode` 负责: + - 层级 + - 名称 + - 所有权 + - 回收站生命周期 +- `ContentAsset / ContentVersion` 负责: + - 物理对象 + - 版本 + - 派生资产 + - 内容不可变性 + +### 主要动作 + +1. 给 `files.core` 划清“逻辑节点操作”和“内容对象操作” +2. 让复制、导入、恢复优先操作逻辑节点绑定,而不是直接操作底层对象 +3. 把目录树逻辑从内容对象读写中剥离 +4. 让最终清理只依赖内容引用与保留策略 + +### 涉及文件 + +- `backend/src/main/java/com/yoyuzh/files/core/*` +- `backend/src/main/java/com/yoyuzh/files/storage/*` +- `backend/src/main/java/com/yoyuzh/files/policy/*` + +### 阶段结果 + +- 路径、目录、回收站归工作空间域 +- 内容对象、版本、派生产物归内容资产域 + +### 验收标准 + +- 普通文件操作不再直接驱动物理对象行为 +- 内容版本成为稳定对象,逻辑节点只是引用它 +- `cd backend && mvn test` 通过 + +## 阶段 3:收口上传与存储治理 + +### 目标 + +把上传从“文件业务的一部分”收敛为“内容接入机制”,并把存储策略升级为系统级治理域。 + +### 需要解决的问题 + +1. 旧上传和 v2 上传双轨并存 +2. 上传规则在 `FileService` 和 `UploadSessionService` 中重复 +3. 上传模式由实现细节驱动,而不是标准化能力模型驱动 + +### 主要动作 + +1. 让 `UploadPolicyResolver` 成为唯一上传规则入口 +2. 让上传完成只产生 `ContentVersion` +3. 工作空间节点创建放到上传完成后的业务编排层 +4. 收口旧 `/api/files/upload/**` 到兼容层,新的正式能力只保留 v2 上传会话 +5. 统一 `StoragePolicy`、能力矩阵、对象大小限制和迁移约束 + +### 涉及文件 + +- `backend/src/main/java/com/yoyuzh/files/upload/*` +- `backend/src/main/java/com/yoyuzh/files/policy/*` +- `backend/src/main/java/com/yoyuzh/files/storage/*` +- `backend/src/main/java/com/yoyuzh/files/core/FileService.java` +- `backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Controller.java` + +### 阶段结果 + +- 上传成为统一接入层 +- 存储治理成为独立域 + +### 验收标准 + +- 上传规则只有一套事实源 +- v2 上传成为正式主线 +- `cd backend && mvn test` 通过 + +## 阶段 4:收口分享域 + +### 目标 + +把分享从当前“旧接口 + v2 接口并存”收敛为统一的 `sharing` 域。 + +### 需要解决的问题 + +1. 分享存在旧接口和 v2 接口双轨 +2. 分享额度、密码、导入和下载权限没有形成统一策略模型 +3. 分享导入逻辑过于依赖底层文件实现 + +### 主要动作 + +1. 定义统一的 `ShareGrant` 模型 +2. 把分享的访问、下载、导入权限归到同一个策略对象 +3. 明确“分享对外公开的是逻辑节点,不是底层内容对象” +4. 旧分享接口保留兼容包装层,内部转发到统一分享域 + +### 涉及文件 + +- `backend/src/main/java/com/yoyuzh/files/share/*` +- `backend/src/main/java/com/yoyuzh/api/v2/shares/*` +- `backend/src/main/java/com/yoyuzh/files/core/FileController.java` +- `backend/src/main/java/com/yoyuzh/files/core/FileService.java` + +### 阶段结果 + +- 分享只有一套规则和一套内部模型 + +### 验收标准 + +- 分享创建、访问、下载、导入都通过统一服务入口 +- 旧接口只剩兼容职责 +- `cd backend && mvn test` 通过 + +## 阶段 5:拆分快传域 + +### 目标 + +把当前 `transfer` 模块拆成真正的传输域,而不是继续由 `TransferService` 统管在线、离线、上传、导入、容量判断。 + +### 需要解决的问题 + +1. 在线快传和离线快传当前共用一个胖 Service +2. 离线快传 ready、上传、容量、导入耦合严重 +3. 快传与分享的边界仍然容易混淆 + +### 主要动作 + +1. 拆出: + - `OnlineTransferService` + - `OfflineTransferService` + - `OfflineTransferQuotaService` + - `TransferImportService` +2. 在线快传只保留临时连接语义 +3. 离线快传只保留临时托管语义 +4. 导入动作通过工作空间/内容资产编排层完成 + +### 涉及文件 + +- `backend/src/main/java/com/yoyuzh/transfer/*` +- `front/src/lib/transfer.ts` +- `front/src/pages/Transfer.tsx` + +### 阶段结果 + +- 在线和离线快传成为两个清晰子域 +- 快传导入不再直接绑死旧文件实现 + +### 验收标准 + +- 在线和离线业务规则拆分完成 +- 离线容量和 ready 条件成为独立规则入口 +- `cd backend && mvn test` 通过 + +## 阶段 6:统一异步任务域 + +### 目标 + +把当前 `files.tasks` 从“文件附属模块”升级为平台级 `async-job` 域。 + +### 需要解决的问题 + +1. 任务模型仍然偏文件中心 +2. 任务状态 JSON 和运行机制还没有完全 typed 化 +3. 任务类型与业务域之间的边界仍不清晰 + +### 主要动作 + +1. 把任务分成: + - 命令入口 + - 执行入口 + - 重试策略 + - 状态表示 + - 幂等入口 +2. 用 typed state 逐步替代广泛的 JSON merge +3. 把任务从文件附属实现提升为可服务多个业务域的统一底座 + +### 涉及文件 + +- `backend/src/main/java/com/yoyuzh/files/tasks/*` +- `backend/src/main/java/com/yoyuzh/common/broker/*` +- `backend/src/main/java/com/yoyuzh/api/v2/tasks/*` + +### 阶段结果 + +- 异步任务成为独立业务底座 + +### 验收标准 + +- 新任务类型不再需要复制粘贴状态处理逻辑 +- 任务状态推进和重试规则有唯一实现 +- `cd backend && mvn test` 通过 + +## 阶段 7:重构管理域 + +### 目标 + +把当前 `admin` 从“聚合各种系统能力的大 Service”重构成真正的 `operations-admin` 治理入口。 + +### 需要解决的问题 + +1. 可写设置与运行时快照混在一起 +2. 管理能力和业务领域对象之间缺少稳定的边界 +3. 权限、审计和治理动作没有形成一致模型 + +### 主要动作 + +1. 拆出: + - `AdminConfigSnapshotService` + - `AdminMutableSettingsService` + - `AdminUserGovernanceService` + - `AdminStorageGovernanceService` + - `AdminAuditService` +2. 让管理端只调用各领域的治理接口,而不是直接承载业务规则 +3. 把审计作为显式能力引入 + +### 涉及文件 + +- `backend/src/main/java/com/yoyuzh/admin/*` + +### 阶段结果 + +- 管理域从“胖 service”变成治理编排层 + +### 验收标准 + +- 设置、治理、审计分离 +- 管理端不再承担业务事实源 +- `cd backend && mvn test` 通过 + +## 阶段 8:前端按业务域重组 + +### 目标 + +让前端结构跟随后端领域,而不是继续沿 `pages + lib + mobile-pages` 演进。 + +### 主要动作 + +1. 把 `pages` 和 `mobile-pages` 按域拆成: + - `account` + - `workspace` + - `sharing` + - `transfer` + - `admin` + - `common` +2. 把 `lib` 中的接口调用和状态逻辑迁移到领域内部 +3. 统一桌面端和移动端页面的业务入口,只保留展示层差异 + +### 涉及文件 + +- `front/src/pages/*` +- `front/src/mobile-pages/*` +- `front/src/lib/*` +- `front/src/admin/*` +- `front/src/App.tsx` + +### 阶段结果 + +- 前端与后端都围绕同一领域模型组织 + +### 验收标准 + +- `cd front && npm run lint` 通过 +- `cd front && npm run build` 通过 + +## 7. 先后顺序与依赖 + +严格顺序建议如下: + +1. 阶段 0:冻结语义 +2. 阶段 1:身份与授权 +3. 阶段 2:工作空间 / 内容资产拆分 +4. 阶段 3:上传 / 存储治理收口 +5. 阶段 4:分享域收口 +6. 阶段 5:快传域拆分 +7. 阶段 6:异步任务域统一 +8. 阶段 7:管理域重构 +9. 阶段 8:前端域化重组 + +依赖关系: + +- 身份与授权必须先于管理域重构 +- 工作空间 / 内容资产拆分必须先于分享和快传彻底收口 +- 上传 / 存储治理必须先于内容资产域稳定 +- 前端域化必须放在后端领域边界稳定之后 + +## 8. 每阶段的兼容要求 + +### 后端兼容要求 + +- 每个阶段优先保持现有 API 对外行为稳定 +- 旧接口可以保留兼容层,但内部必须逐步转发到目标域模型 +- 不在同一阶段同时做“领域重组 + 外部 API 大改” + +### 数据兼容要求 + +- 不允许把现有数据迁移风险和领域重构耦合在同一批次 +- 引入新模型时,优先通过双写、映射或兼容读过渡 + +### 前端兼容要求 + +- 路由和核心页面入口在后端领域稳定前尽量保持 +- 先迁移数据访问层,再迁移页面结构 + +## 9. 验收与验证命令 + +每个后端阶段完成后至少执行: + +```powershell +cd backend +mvn test +``` + +每个前端阶段完成后至少执行: + +```powershell +cd front +npm run lint +npm run build +``` + +如果阶段只改后端,不额外要求前端构建。 +如果阶段只改前端,也应确认依赖的 API 契约没有被破坏。 + +## 10. 本轮建议先开做的具体批次 + +如果从当前代码直接开工,建议先做下面三个批次: + +### 批次 A:身份与授权统一 + +- 去掉 admin 白名单作为最终权限事实源 +- 统一 `AuthSessionPolicy` 的会话失效入口 +- 让 `/api/admin/**` 接入统一授权判断 + +### 批次 B:工作空间与内容资产拆分第一刀 + +- 先在 `files.core` 内部分出“工作空间节点规则”和“内容资产规则” +- 不急着改数据库表名,先改代码归属 + +### 批次 C:上传与分享收口 + +- 让上传只产生内容接入结果 +- 让分享只依赖逻辑节点授权 +- 旧上传和旧分享接口退化为兼容层 + +这三个批次完成后,整套系统才真正具备继续深度重构的稳定底盘。 diff --git a/front/src/App.tsx b/front/src/App.tsx index c80af06..f47b9ac 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -4,17 +4,27 @@ import AdminDashboard from './admin/dashboard'; import AdminFilesList from './admin/files-list'; import AdminStoragePoliciesList from './admin/storage-policies-list'; import AdminUsersList from './admin/users-list'; +import AdminLayout from './admin/AdminLayout'; + +// 新增占位页面 +import AdminSettings from './admin/settings'; +import AdminFilesystem from './admin/filesystem'; +import AdminFileBlobs from './admin/fileblobs'; +import AdminShares from './admin/shares'; +import AdminTasks from './admin/tasks'; +import AdminOAuthApps from './admin/oauthapps'; + import Layout from './components/layout/Layout'; import MobileLayout from './mobile-components/MobileLayout'; import { useIsMobile } from './hooks/useIsMobile'; -import Login from './pages/Login'; -import Overview from './pages/Overview'; -import RecycleBin from './pages/RecycleBin'; -import Shares from './pages/Shares'; -import Tasks from './pages/Tasks'; -import Transfer from './pages/Transfer'; -import FileShare from './pages/FileShare'; -import FilesPage from './pages/files/FilesPage'; +import Login from './account/pages/LoginPage'; +import Overview from './workspace/pages/OverviewPage'; +import FilesPage from './workspace/pages/FilesPage'; +import RecycleBin from './workspace/pages/RecycleBinPage'; +import Shares from './sharing/pages/SharesPage'; +import FileShare from './sharing/pages/FileSharePage'; +import Tasks from './common/pages/TasksPage'; +import Transfer from './transfer/pages/TransferPage'; function AnimatedRoutes({ isMobile }: { isMobile: boolean }) { const location = useLocation(); @@ -33,13 +43,22 @@ function AnimatedRoutes({ isMobile }: { isMobile: boolean }) { } /> } /> } /> - - } /> - : } /> - : } /> - : } /> - : } /> + + {/* 管理台路由重构 */} + : }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/front/src/account/pages/LoginPage.tsx b/front/src/account/pages/LoginPage.tsx new file mode 100644 index 0000000..01715a9 --- /dev/null +++ b/front/src/account/pages/LoginPage.tsx @@ -0,0 +1 @@ +export { default } from '@/src/pages/Login'; diff --git a/front/src/admin/AdminLayout.tsx b/front/src/admin/AdminLayout.tsx new file mode 100644 index 0000000..7a74433 --- /dev/null +++ b/front/src/admin/AdminLayout.tsx @@ -0,0 +1,72 @@ +import { NavLink, Outlet } from 'react-router-dom'; +import { + Activity, + Database, + FileBox, + Files, + HardDrive, + Key, + LayoutDashboard, + ListTodo, + Settings, + Share2, + Users +} from 'lucide-react'; +import { cn } from '@/src/lib/utils'; +import { motion } from 'motion/react'; + +export default function AdminLayout() { + const adminNavItems = [ + { to: 'dashboard', icon: LayoutDashboard, label: '总览' }, + { to: 'settings', icon: Settings, label: '系统设置' }, + { to: 'filesystem', icon: HardDrive, label: '文件系统' }, + { to: 'storage-policies', icon: Database, label: '存储策略' }, + { to: 'users', icon: Users, label: '用户管理' }, + { to: 'files', icon: Files, label: '文件审计' }, + { to: 'file-blobs', icon: FileBox, label: '对象实体' }, + { to: 'shares', icon: Share2, label: '分享管理' }, + { to: 'tasks', icon: ListTodo, label: '任务监控' }, + { to: 'oauth-apps', icon: Key, label: '三方应用' }, + ]; + + return ( +
+ {/* Admin Secondary Sidebar */} + + + {/* Admin Content Area */} +
+ + + +
+
+ ); +} diff --git a/front/src/admin/fileblobs.tsx b/front/src/admin/fileblobs.tsx new file mode 100644 index 0000000..6d13864 --- /dev/null +++ b/front/src/admin/fileblobs.tsx @@ -0,0 +1 @@ +export default function AdminFileBlobs() { return

Admin FileBlobs ()

; } diff --git a/front/src/admin/filesystem.tsx b/front/src/admin/filesystem.tsx new file mode 100644 index 0000000..192df82 --- /dev/null +++ b/front/src/admin/filesystem.tsx @@ -0,0 +1 @@ +export default function AdminFilesystem() { return

Admin Filesystem ()

; } diff --git a/front/src/admin/oauthapps.tsx b/front/src/admin/oauthapps.tsx new file mode 100644 index 0000000..3ac9a99 --- /dev/null +++ b/front/src/admin/oauthapps.tsx @@ -0,0 +1 @@ +export default function AdminOAuthApps() { return

Admin OAuthApps ()

; } diff --git a/front/src/admin/settings.tsx b/front/src/admin/settings.tsx new file mode 100644 index 0000000..1c02214 --- /dev/null +++ b/front/src/admin/settings.tsx @@ -0,0 +1 @@ +export default function AdminSettings() { return

Admin Settings ()

; } diff --git a/front/src/admin/shares.tsx b/front/src/admin/shares.tsx new file mode 100644 index 0000000..f2623e2 --- /dev/null +++ b/front/src/admin/shares.tsx @@ -0,0 +1 @@ +export default function AdminShares() { return

Admin Shares ()

; } diff --git a/front/src/admin/tasks.tsx b/front/src/admin/tasks.tsx new file mode 100644 index 0000000..14778af --- /dev/null +++ b/front/src/admin/tasks.tsx @@ -0,0 +1 @@ +export default function AdminTasks() { return

Admin Tasks ()

; } diff --git a/front/src/common/pages/TasksPage.tsx b/front/src/common/pages/TasksPage.tsx new file mode 100644 index 0000000..aa315df --- /dev/null +++ b/front/src/common/pages/TasksPage.tsx @@ -0,0 +1 @@ +export { default } from '@/src/pages/Tasks'; diff --git a/front/src/components/ThemeProvider.tsx b/front/src/components/ThemeProvider.tsx index 408dbef..7333c49 100644 --- a/front/src/components/ThemeProvider.tsx +++ b/front/src/components/ThemeProvider.tsx @@ -20,6 +20,10 @@ const initialState: ThemeProviderState = { const ThemeProviderContext = createContext(initialState); +function resolveSystemTheme(): 'dark' | 'light' { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + export function ThemeProvider({ children, defaultTheme = 'system', @@ -32,20 +36,26 @@ export function ThemeProvider({ useEffect(() => { const root = window.document.documentElement; + const applyTheme = (nextTheme: Theme) => { + const resolved = nextTheme === 'system' ? resolveSystemTheme() : nextTheme; - root.classList.remove('light', 'dark'); + root.classList.remove('dark'); + if (resolved === 'dark') { + root.classList.add('dark'); + } + root.style.colorScheme = resolved; + }; - if (theme === 'system') { - const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') - .matches - ? 'dark' - : 'light'; + applyTheme(theme); - root.classList.add(systemTheme); + if (theme !== 'system') { return; } - root.classList.add(theme); + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = () => applyTheme('system'); + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); }, [theme]); const value = { diff --git a/front/src/components/layout/Layout.tsx b/front/src/components/layout/Layout.tsx index c3aef41..8478ca2 100644 --- a/front/src/components/layout/Layout.tsx +++ b/front/src/components/layout/Layout.tsx @@ -16,28 +16,35 @@ import { cn } from '@/src/lib/utils'; import { logout } from '@/src/lib/auth'; import { getSession, type PortalSession } from '@/src/lib/session'; import { useTheme } from '../ThemeProvider'; +import { useSessionRuntime } from '@/src/hooks/use-session-runtime'; +import { UploadCenter } from '../upload/UploadCenter'; +import { TaskSummaryPanel } from '../tasks/TaskSummaryPanel'; +import { realtimeRuntime } from '@/src/lib/realtime-runtime'; + + + + export default function Layout() { const location = useLocation(); const navigate = useNavigate(); - const [session, setSession] = useState(() => getSession()); + const { session } = useSessionRuntime(); const { theme, setTheme } = useTheme(); - useEffect(() => { - const handleSessionChange = (event: Event) => { - const customEvent = event as CustomEvent; - setSession(customEvent.detail ?? getSession()); - }; - window.addEventListener('portal-session-changed', handleSessionChange); - return () => window.removeEventListener('portal-session-changed', handleSessionChange); - }, []); useEffect(() => { if (!session && location.pathname !== '/transfer') { navigate('/login', { replace: true }); } + if (session) { + realtimeRuntime.start(); + } else { + realtimeRuntime.stop(); + } + return () => realtimeRuntime.stop(); }, [location.pathname, navigate, session]); + const navItems = [ { to: '/overview', icon: LayoutDashboard, label: '概览' }, { to: '/files', icon: HardDrive, label: '网盘' }, @@ -55,7 +62,8 @@ export default function Layout() { {/* Sidebar */}