diff --git a/.codex/agents/tester.toml b/.codex/agents/tester.toml index fb705e0..c2b65ae 100644 --- a/.codex/agents/tester.toml +++ b/.codex/agents/tester.toml @@ -1,5 +1,5 @@ name = "tester" -description = "Verification-only agent. It runs lint, test, build, package, and type-check commands that already exist in this repo, reports failures, and does not edit source files." +description = "Verification-only agent. It runs only repository-backed verification commands that already exist in this repo, reports exact failures, and does not edit source files. Use the `multi-angle-verification` skill as the default verification workflow so command coverage, browser-flow checks, UI review, and coverage-gap reporting stay consistent. Android emulator or device simulation is out of scope for this agent and is handled manually by the user." nickname_candidates = ["tester", "qa", "verify"] sandbox_mode = "workspace-write" include_apply_patch_tool = false diff --git a/.codex/config.toml b/.codex/config.toml index 464a13b..16436b1 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -22,7 +22,7 @@ config_file = ".codex/agents/implementer.toml" nickname_candidates = ["implementer", "impl", "builder"] [agents.tester] -description = "Runs repository-backed verification commands only." +description = "Runs repository-backed verification through the multi-angle-verification workflow, including browser and UI review when the task warrants it." config_file = ".codex/agents/tester.toml" nickname_candidates = ["tester", "qa", "verify"] diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminController.java b/backend/src/main/java/com/yoyuzh/admin/AdminController.java index 7ca8b62..57ed9d6 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminController.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminController.java @@ -1,10 +1,16 @@ package com.yoyuzh.admin; +import com.yoyuzh.api.v2.tasks.BackgroundTaskResponse; +import com.yoyuzh.auth.CustomUserDetailsService; +import com.yoyuzh.auth.User; import com.yoyuzh.common.ApiResponse; import com.yoyuzh.common.PageResponse; +import com.yoyuzh.files.tasks.BackgroundTask; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -25,6 +31,7 @@ import java.util.List; public class AdminController { private final AdminService adminService; + private final CustomUserDetailsService userDetailsService; @GetMapping("/summary") public ApiResponse summary() { @@ -59,6 +66,34 @@ public class AdminController { return ApiResponse.success(adminService.listStoragePolicies()); } + @PostMapping("/storage-policies") + public ApiResponse createStoragePolicy( + @Valid @RequestBody AdminStoragePolicyUpsertRequest request) { + return ApiResponse.success(adminService.createStoragePolicy(request)); + } + + @PutMapping("/storage-policies/{policyId}") + public ApiResponse updateStoragePolicy( + @PathVariable Long policyId, + @Valid @RequestBody AdminStoragePolicyUpsertRequest request) { + return ApiResponse.success(adminService.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())); + } + + @PostMapping("/storage-policies/migrations") + public ApiResponse createStoragePolicyMigrationTask( + @AuthenticationPrincipal UserDetails userDetails, + @Valid @RequestBody AdminStoragePolicyMigrationCreateRequest request) { + User user = userDetailsService.loadDomainUser(userDetails.getUsername()); + return ApiResponse.success(toTaskResponse(adminService.createStoragePolicyMigrationTask(user, request))); + } + @DeleteMapping("/files/{fileId}") public ApiResponse deleteFile(@PathVariable Long fileId) { adminService.deleteFile(fileId); @@ -99,4 +134,19 @@ public class AdminController { public ApiResponse resetUserPassword(@PathVariable Long userId) { return ApiResponse.success(adminService.resetUserPassword(userId)); } + + private BackgroundTaskResponse toTaskResponse(BackgroundTask task) { + return new BackgroundTaskResponse( + task.getId(), + task.getType(), + task.getStatus(), + task.getUserId(), + task.getPublicStateJson(), + task.getCorrelationId(), + task.getErrorMessage(), + task.getCreatedAt(), + task.getUpdatedAt(), + task.getFinishedAt() + ); + } } diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminService.java b/backend/src/main/java/com/yoyuzh/admin/AdminService.java index 331d182..2d702a5 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminService.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminService.java @@ -10,12 +10,18 @@ import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.ErrorCode; import com.yoyuzh.common.PageResponse; import com.yoyuzh.files.core.FileBlobRepository; +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.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.BackgroundTaskService; +import com.yoyuzh.files.tasks.BackgroundTaskType; import com.yoyuzh.transfer.OfflineTransferSessionRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -24,6 +30,7 @@ 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; @@ -45,6 +52,9 @@ public class AdminService { private final AdminMetricsService adminMetricsService; private final StoragePolicyRepository storagePolicyRepository; private final StoragePolicyService storagePolicyService; + private final FileEntityRepository fileEntityRepository; + private final StoredFileEntityRepository storedFileEntityRepository; + private final BackgroundTaskService backgroundTaskService; private final SecureRandom secureRandom = new SecureRandom(); public AdminSummaryResponse getSummary() { @@ -97,6 +107,75 @@ public class AdminService { .toList(); } + @Transactional + public AdminStoragePolicyResponse createStoragePolicy(AdminStoragePolicyUpsertRequest request) { + StoragePolicy policy = new StoragePolicy(); + policy.setDefaultPolicy(false); + applyStoragePolicyUpsert(policy, request); + return toStoragePolicyResponse(storagePolicyRepository.save(policy)); + } + + @Transactional + public AdminStoragePolicyResponse updateStoragePolicy(Long policyId, AdminStoragePolicyUpsertRequest request) { + StoragePolicy policy = getRequiredStoragePolicy(policyId); + applyStoragePolicyUpsert(policy, request); + return toStoragePolicyResponse(storagePolicyRepository.save(policy)); + } + + @Transactional + 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, "目标存储策略必须处于启用状态"); + } + + long candidateEntityCount = fileEntityRepository.countByStoragePolicyIdAndEntityType( + sourcePolicy.getId(), + FileEntityType.VERSION + ); + long candidateStoredFileCount = storedFileEntityRepository.countDistinctStoredFilesByStoragePolicyIdAndEntityType( + sourcePolicy.getId(), + FileEntityType.VERSION + ); + + java.util.Map state = new java.util.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"); + + java.util.Map privateState = new java.util.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) @@ -214,11 +293,34 @@ public class AdminService { ); } + 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, "用户不存在")); } + private StoragePolicy getRequiredStoragePolicy(Long policyId) { + return storagePolicyRepository.findById(policyId) + .orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "存储策略不存在")); + } + private String normalizeQuery(String query) { if (query == null) { return ""; @@ -226,6 +328,31 @@ public class AdminService { 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 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"; diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyMigrationCreateRequest.java b/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyMigrationCreateRequest.java new file mode 100644 index 0000000..986918e --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyMigrationCreateRequest.java @@ -0,0 +1,12 @@ +package com.yoyuzh.admin; + +import jakarta.validation.constraints.NotNull; + +public record AdminStoragePolicyMigrationCreateRequest( + @NotNull(message = "sourcePolicyId 不能为空") + Long sourcePolicyId, + @NotNull(message = "targetPolicyId 不能为空") + Long targetPolicyId, + String correlationId +) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyStatusUpdateRequest.java b/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyStatusUpdateRequest.java new file mode 100644 index 0000000..bc7e47d --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyStatusUpdateRequest.java @@ -0,0 +1,9 @@ +package com.yoyuzh.admin; + +import jakarta.validation.constraints.NotNull; + +public record AdminStoragePolicyStatusUpdateRequest( + @NotNull(message = "enabled 不能为空") + Boolean enabled +) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyUpsertRequest.java b/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyUpsertRequest.java new file mode 100644 index 0000000..fbf8ca0 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyUpsertRequest.java @@ -0,0 +1,28 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.files.policy.StoragePolicyCapabilities; +import com.yoyuzh.files.policy.StoragePolicyCredentialMode; +import com.yoyuzh.files.policy.StoragePolicyType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record AdminStoragePolicyUpsertRequest( + @NotBlank(message = "存储策略名称不能为空") + String name, + @NotNull(message = "存储策略类型不能为空") + StoragePolicyType type, + String bucketName, + String endpoint, + String region, + boolean privateBucket, + String prefix, + @NotNull(message = "凭证模式不能为空") + StoragePolicyCredentialMode credentialMode, + @Positive(message = "最大对象大小必须大于 0") + long maxSizeBytes, + @NotNull(message = "能力声明不能为空") + StoragePolicyCapabilities capabilities, + boolean enabled +) { +} 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 5a8fbe7..d956df5 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 @@ -5,6 +5,7 @@ import com.yoyuzh.auth.CustomUserDetailsService; import com.yoyuzh.auth.User; import com.yoyuzh.files.upload.UploadSession; import com.yoyuzh.files.upload.UploadSessionCreateCommand; +import com.yoyuzh.files.upload.UploadSessionUploadMode; import com.yoyuzh.files.upload.UploadSessionPartCommand; import com.yoyuzh.files.upload.UploadSessionService; import com.yoyuzh.files.storage.PreparedUpload; @@ -19,7 +20,9 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/api/v2/files/upload-sessions") @@ -49,6 +52,20 @@ public class UploadSessionV2Controller { return ApiV2Response.success(toResponse(uploadSessionService.getOwnedSession(user, sessionId))); } + @GetMapping("/{sessionId}/prepare") + public ApiV2Response prepareUpload(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable String sessionId) { + User user = userDetailsService.loadDomainUser(userDetails.getUsername()); + PreparedUpload preparedUpload = uploadSessionService.prepareOwnedUpload(user, sessionId); + return ApiV2Response.success(new PreparedUploadV2Response( + preparedUpload.direct(), + preparedUpload.uploadUrl(), + preparedUpload.method(), + preparedUpload.headers(), + preparedUpload.storageName() + )); + } + @DeleteMapping("/{sessionId}") public ApiV2Response cancelSession(@AuthenticationPrincipal UserDetails userDetails, @PathVariable String sessionId) { @@ -78,6 +95,14 @@ public class UploadSessionV2Controller { return ApiV2Response.success(toResponse(session)); } + @PostMapping("/{sessionId}/content") + public ApiV2Response uploadContent(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable String sessionId, + @RequestPart("file") MultipartFile file) { + User user = userDetailsService.loadDomainUser(userDetails.getUsername()); + return ApiV2Response.success(toResponse(uploadSessionService.uploadOwnedContent(user, sessionId, file))); + } + @GetMapping("/{sessionId}/parts/{partIndex}/prepare") public ApiV2Response preparePartUpload(@AuthenticationPrincipal UserDetails userDetails, @PathVariable String sessionId, @@ -94,10 +119,18 @@ public class UploadSessionV2Controller { } private UploadSessionV2Response toResponse(UploadSession session) { + UploadSessionUploadMode uploadMode = uploadSessionService.resolveUploadMode(session); + if (uploadMode == null) { + uploadMode = session.getMultipartUploadId() != null + ? UploadSessionUploadMode.DIRECT_MULTIPART + : UploadSessionUploadMode.PROXY; + } return new UploadSessionV2Response( session.getSessionId(), session.getObjectKey(), - session.getMultipartUploadId() != null, + uploadMode != UploadSessionUploadMode.PROXY, + uploadMode == UploadSessionUploadMode.DIRECT_MULTIPART, + uploadMode.name(), session.getTargetPath(), session.getFilename(), session.getContentType(), @@ -108,7 +141,38 @@ public class UploadSessionV2Controller { session.getChunkCount(), session.getExpiresAt(), session.getCreatedAt(), - session.getUpdatedAt() + session.getUpdatedAt(), + toStrategyResponse(session.getSessionId(), uploadMode) ); } + + private UploadSessionV2StrategyResponse toStrategyResponse(String sessionId, UploadSessionUploadMode uploadMode) { + String sessionBasePath = "/api/v2/files/upload-sessions/" + sessionId; + return switch (uploadMode) { + case PROXY -> new UploadSessionV2StrategyResponse( + null, + sessionBasePath + "/content", + null, + null, + sessionBasePath + "/complete", + "file" + ); + case DIRECT_SINGLE -> new UploadSessionV2StrategyResponse( + sessionBasePath + "/prepare", + null, + null, + null, + sessionBasePath + "/complete", + null + ); + case DIRECT_MULTIPART -> new UploadSessionV2StrategyResponse( + null, + null, + sessionBasePath + "/parts/{partIndex}/prepare", + sessionBasePath + "/parts/{partIndex}", + sessionBasePath + "/complete", + null + ); + }; + } } 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 bb4aea2..2641dd6 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 @@ -5,7 +5,9 @@ import java.time.LocalDateTime; public record UploadSessionV2Response( String sessionId, String objectKey, + boolean directUpload, boolean multipartUpload, + String uploadMode, String path, String filename, String contentType, @@ -16,6 +18,7 @@ public record UploadSessionV2Response( int chunkCount, LocalDateTime expiresAt, LocalDateTime createdAt, - LocalDateTime updatedAt + LocalDateTime updatedAt, + UploadSessionV2StrategyResponse strategy ) { } diff --git a/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2StrategyResponse.java b/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2StrategyResponse.java new file mode 100644 index 0000000..25a70ea --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2StrategyResponse.java @@ -0,0 +1,11 @@ +package com.yoyuzh.api.v2.files; + +public record UploadSessionV2StrategyResponse( + String prepareUrl, + String proxyContentUrl, + String partPrepareUrlTemplate, + String partRecordUrlTemplate, + String completeUrl, + String proxyFormField +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/core/FileEntityRepository.java b/backend/src/main/java/com/yoyuzh/files/core/FileEntityRepository.java index cf0295c..07924ec 100644 --- a/backend/src/main/java/com/yoyuzh/files/core/FileEntityRepository.java +++ b/backend/src/main/java/com/yoyuzh/files/core/FileEntityRepository.java @@ -2,9 +2,14 @@ package com.yoyuzh.files.core; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface FileEntityRepository extends JpaRepository { Optional findByObjectKeyAndEntityType(String objectKey, FileEntityType entityType); + + long countByStoragePolicyIdAndEntityType(Long storagePolicyId, FileEntityType entityType); + + List findByStoragePolicyIdAndEntityTypeOrderByIdAsc(Long storagePolicyId, FileEntityType entityType); } 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 3ac57f1..a211a4d 100644 --- a/backend/src/main/java/com/yoyuzh/files/core/FileService.java +++ b/backend/src/main/java/com/yoyuzh/files/core/FileService.java @@ -8,6 +8,8 @@ import com.yoyuzh.common.PageResponse; import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.files.events.FileEventService; import com.yoyuzh.files.events.FileEventType; +import com.yoyuzh.files.policy.StoragePolicy; +import com.yoyuzh.files.policy.StoragePolicyCapabilities; import com.yoyuzh.files.policy.StoragePolicyService; import com.yoyuzh.files.share.CreateFileShareLinkResponse; import com.yoyuzh.files.share.FileShareDetailsResponse; @@ -159,6 +161,10 @@ public class FileService { validateUpload(user, normalizedPath, filename, request.size()); String objectKey = createBlobObjectKey(); + StoragePolicyCapabilities capabilities = resolveDefaultStoragePolicyCapabilities(); + if (capabilities != null && !capabilities.directUpload()) { + return new InitiateUploadResponse(false, "", "POST", Map.of(), objectKey); + } PreparedUpload preparedUpload = fileContentStorage.prepareBlobUpload( normalizedPath, filename, @@ -856,6 +862,13 @@ public class FileService { return storagePolicyService.ensureDefaultPolicy().getId(); } + private StoragePolicyCapabilities resolveDefaultStoragePolicyCapabilities() { + if (storagePolicyService == null) { + return null; + } + return storagePolicyService.readCapabilities(storagePolicyService.ensureDefaultPolicy()); + } + private void savePrimaryEntityRelation(StoredFile storedFile, FileEntity primaryEntity) { if (storedFileEntityRepository == null) { return; @@ -927,6 +940,14 @@ public class FileService { private 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, "文件大小超出限制"); } diff --git a/backend/src/main/java/com/yoyuzh/files/core/StoredFileEntityRepository.java b/backend/src/main/java/com/yoyuzh/files/core/StoredFileEntityRepository.java index 85c1149..1796476 100644 --- a/backend/src/main/java/com/yoyuzh/files/core/StoredFileEntityRepository.java +++ b/backend/src/main/java/com/yoyuzh/files/core/StoredFileEntityRepository.java @@ -1,6 +1,17 @@ package com.yoyuzh.files.core; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface StoredFileEntityRepository extends JpaRepository { + + @Query(""" + select count(distinct relation.storedFile.id) + from StoredFileEntity relation + where relation.fileEntity.storagePolicyId = :storagePolicyId + and relation.fileEntity.entityType = :entityType + """) + long countDistinctStoredFilesByStoragePolicyIdAndEntityType(@Param("storagePolicyId") Long storagePolicyId, + @Param("entityType") FileEntityType entityType); } diff --git a/backend/src/main/java/com/yoyuzh/files/policy/StoragePolicyService.java b/backend/src/main/java/com/yoyuzh/files/policy/StoragePolicyService.java index 5df6707..bf51fa5 100644 --- a/backend/src/main/java/com/yoyuzh/files/policy/StoragePolicyService.java +++ b/backend/src/main/java/com/yoyuzh/files/policy/StoragePolicyService.java @@ -1,6 +1,8 @@ package com.yoyuzh.files.policy; import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; import com.yoyuzh.config.FileStorageProperties; import lombok.RequiredArgsConstructor; import org.springframework.boot.CommandLineRunner; @@ -38,6 +40,19 @@ public class StoragePolicyService implements CommandLineRunner { } } + public String writeCapabilities(StoragePolicyCapabilities capabilities) { + try { + return objectMapper.writeValueAsString(capabilities); + } catch (Exception ex) { + throw new IllegalStateException("Storage policy capabilities cannot be serialized", ex); + } + } + + public StoragePolicy getRequiredPolicy(Long policyId) { + return storagePolicyRepository.findById(policyId) + .orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "存储策略不存在")); + } + private StoragePolicy createDefaultPolicy() { if ("s3".equalsIgnoreCase(properties.getProvider())) { return createDefaultS3Policy(); @@ -95,14 +110,6 @@ public class StoragePolicyService implements CommandLineRunner { return policy; } - private String writeCapabilities(StoragePolicyCapabilities capabilities) { - try { - return objectMapper.writeValueAsString(capabilities); - } catch (Exception ex) { - throw new IllegalStateException("Storage policy capabilities cannot be serialized", ex); - } - } - private String extractScopeBucketName(String scope) { if (!StringUtils.hasText(scope)) { return null; diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskType.java b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskType.java index 60c8365..9f9eceb 100644 --- a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskType.java +++ b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskType.java @@ -3,6 +3,7 @@ package com.yoyuzh.files.tasks; public enum BackgroundTaskType { ARCHIVE, EXTRACT, + STORAGE_POLICY_MIGRATION, THUMBNAIL, MEDIA_META, REMOTE_DOWNLOAD, 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 05d42f1..5736ae0 100644 --- a/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskWorker.java +++ b/backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTaskWorker.java @@ -98,6 +98,7 @@ public class BackgroundTaskWorker { case ARCHIVE -> "archiving"; case EXTRACT -> "extracting"; case MEDIA_META -> "extracting-metadata"; + case STORAGE_POLICY_MIGRATION -> "planning-storage-policy-migration"; default -> "running"; }; } diff --git a/backend/src/main/java/com/yoyuzh/files/tasks/StoragePolicyMigrationBackgroundTaskHandler.java b/backend/src/main/java/com/yoyuzh/files/tasks/StoragePolicyMigrationBackgroundTaskHandler.java new file mode 100644 index 0000000..a4fff0d --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/tasks/StoragePolicyMigrationBackgroundTaskHandler.java @@ -0,0 +1,305 @@ +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; +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.StoredFileRepository; +import com.yoyuzh.files.policy.StoragePolicy; +import com.yoyuzh.files.policy.StoragePolicyRepository; +import com.yoyuzh.files.policy.StoragePolicyType; +import com.yoyuzh.files.storage.FileContentStorage; +import com.yoyuzh.files.storage.LocalFileContentStorage; +import com.yoyuzh.files.storage.S3FileContentStorage; +import jakarta.transaction.Transactional; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Component +@Transactional +public class StoragePolicyMigrationBackgroundTaskHandler implements BackgroundTaskHandler { + + private final StoragePolicyRepository storagePolicyRepository; + private final FileEntityRepository fileEntityRepository; + private final FileBlobRepository fileBlobRepository; + private final StoredFileRepository storedFileRepository; + private final FileContentStorage fileContentStorage; + private final ObjectMapper objectMapper; + + public StoragePolicyMigrationBackgroundTaskHandler(StoragePolicyRepository storagePolicyRepository, + FileEntityRepository fileEntityRepository, + FileBlobRepository fileBlobRepository, + StoredFileRepository storedFileRepository, + FileContentStorage fileContentStorage, + ObjectMapper objectMapper) { + this.storagePolicyRepository = storagePolicyRepository; + this.fileEntityRepository = fileEntityRepository; + this.fileBlobRepository = fileBlobRepository; + this.storedFileRepository = storedFileRepository; + this.fileContentStorage = fileContentStorage; + this.objectMapper = objectMapper; + } + + @Override + public boolean supports(BackgroundTaskType type) { + return type == BackgroundTaskType.STORAGE_POLICY_MIGRATION; + } + + @Override + public BackgroundTaskHandlerResult handle(BackgroundTask task) { + return handle(task, publicStatePatch -> { + }); + } + + @Override + public BackgroundTaskHandlerResult handle(BackgroundTask task, BackgroundTaskProgressReporter progressReporter) { + Map state = parseState(task.getPrivateStateJson()); + Long sourcePolicyId = readLong(state.get("sourcePolicyId"), "sourcePolicyId"); + Long targetPolicyId = readLong(state.get("targetPolicyId"), "targetPolicyId"); + + StoragePolicy sourcePolicy = storagePolicyRepository.findById(sourcePolicyId) + .orElseThrow(() -> new IllegalStateException("storage policy migration source policy not found")); + StoragePolicy targetPolicy = storagePolicyRepository.findById(targetPolicyId) + .orElseThrow(() -> new IllegalStateException("storage policy migration target policy not found")); + validatePolicyPair(sourcePolicy, targetPolicy); + + List entities = fileEntityRepository.findByStoragePolicyIdAndEntityTypeOrderByIdAsc( + sourcePolicyId, + FileEntityType.VERSION + ); + long candidateEntityCount = entities.size(); + long candidateStoredFileCount = 0L; + for (FileEntity entity : entities) { + validateTargetCapacity(entity, targetPolicy); + candidateStoredFileCount += storedFileRepository.countByBlobId(getRequiredBlob(entity).getId()); + } + + long processedEntityCount = 0L; + long migratedStoredFileCount = 0L; + List copiedObjectKeys = new ArrayList<>(); + LinkedHashSet staleObjectKeys = new LinkedHashSet<>(); + progressReporter.report(progressPatch( + sourcePolicy, + targetPolicy, + candidateEntityCount, + candidateStoredFileCount, + 0L, + 0L, + 0L, + "copying-object-data", + false + )); + try { + for (FileEntity entity : entities) { + FileBlob blob = getRequiredBlob(entity); + long storedFileCount = storedFileRepository.countByBlobId(blob.getId()); + String oldObjectKey = entity.getObjectKey(); + String newObjectKey = buildTargetObjectKey(targetPolicy.getId()); + String contentType = StringUtils.hasText(entity.getContentType()) ? entity.getContentType() : blob.getContentType(); + + byte[] content = fileContentStorage.readBlob(oldObjectKey); + copiedObjectKeys.add(newObjectKey); + fileContentStorage.storeBlob(newObjectKey, contentType, content); + + entity.setObjectKey(newObjectKey); + entity.setStoragePolicyId(targetPolicy.getId()); + fileEntityRepository.save(entity); + + blob.setObjectKey(newObjectKey); + fileBlobRepository.save(blob); + + staleObjectKeys.add(oldObjectKey); + processedEntityCount += 1; + migratedStoredFileCount += storedFileCount; + progressReporter.report(progressPatch( + sourcePolicy, + targetPolicy, + candidateEntityCount, + candidateStoredFileCount, + processedEntityCount, + processedEntityCount, + migratedStoredFileCount, + "copying-object-data", + false + )); + } + } catch (RuntimeException ex) { + cleanupCopiedObjects(copiedObjectKeys); + throw ex; + } + + scheduleStaleObjectCleanup(staleObjectKeys); + return new BackgroundTaskHandlerResult(progressPatch( + sourcePolicy, + targetPolicy, + candidateEntityCount, + candidateStoredFileCount, + processedEntityCount, + processedEntityCount, + migratedStoredFileCount, + "completed", + true + )); + } + + private void validatePolicyPair(StoragePolicy sourcePolicy, StoragePolicy targetPolicy) { + if (sourcePolicy.getId().equals(targetPolicy.getId())) { + throw new BusinessException(ErrorCode.UNKNOWN, "源存储策略和目标存储策略不能相同"); + } + if (!targetPolicy.isEnabled()) { + throw new BusinessException(ErrorCode.UNKNOWN, "目标存储策略必须处于启用状态"); + } + if (sourcePolicy.getType() != targetPolicy.getType()) { + throw new BusinessException(ErrorCode.UNKNOWN, "当前只支持迁移同类型存储策略"); + } + StoragePolicyType runtimeType = resolveRuntimePolicyType(); + if (runtimeType != null + && (sourcePolicy.getType() != runtimeType || targetPolicy.getType() != runtimeType)) { + throw new BusinessException(ErrorCode.UNKNOWN, "当前运行时只支持迁移同类型活动存储后端的策略"); + } + } + + private StoragePolicyType resolveRuntimePolicyType() { + if (fileContentStorage instanceof LocalFileContentStorage) { + return StoragePolicyType.LOCAL; + } + if (fileContentStorage instanceof S3FileContentStorage) { + return StoragePolicyType.S3_COMPATIBLE; + } + return null; + } + + private void validateTargetCapacity(FileEntity entity, StoragePolicy targetPolicy) { + if (targetPolicy.getMaxSizeBytes() > 0 && entity.getSize() != null && entity.getSize() > targetPolicy.getMaxSizeBytes()) { + throw new BusinessException(ErrorCode.UNKNOWN, "目标存储策略容量上限不足以承载待迁移对象"); + } + } + + private FileBlob getRequiredBlob(FileEntity entity) { + return fileBlobRepository.findByObjectKey(entity.getObjectKey()) + .orElseThrow(() -> new IllegalStateException("storage policy migration blob not found")); + } + + private String buildTargetObjectKey(Long targetPolicyId) { + return "policies/" + targetPolicyId + "/blobs/" + UUID.randomUUID().toString().replace("-", ""); + } + + private Map progressPatch(StoragePolicy sourcePolicy, + StoragePolicy targetPolicy, + long candidateEntityCount, + long candidateStoredFileCount, + long processedEntityCount, + long migratedEntityCount, + long migratedStoredFileCount, + String migrationStage, + boolean migrationPerformed) { + Map patch = new LinkedHashMap<>(); + patch.put(BackgroundTaskService.STATE_PHASE_KEY, "migrating-storage-policy"); + patch.put("worker", "storage-policy-migration"); + patch.put("migrationStage", migrationStage); + patch.put("migrationMode", migrationPerformed ? "executed" : "executing"); + patch.put("migrationPerformed", migrationPerformed); + patch.put("sourcePolicyId", sourcePolicy.getId()); + patch.put("sourcePolicyName", sourcePolicy.getName()); + patch.put("targetPolicyId", targetPolicy.getId()); + patch.put("targetPolicyName", targetPolicy.getName()); + patch.put("candidateEntityCount", candidateEntityCount); + patch.put("candidateStoredFileCount", candidateStoredFileCount); + patch.put("processedEntityCount", processedEntityCount); + patch.put("totalEntityCount", candidateEntityCount); + patch.put("processedStoredFileCount", migratedStoredFileCount); + patch.put("totalStoredFileCount", candidateStoredFileCount); + patch.put("migratedEntityCount", migratedEntityCount); + patch.put("migratedStoredFileCount", migratedStoredFileCount); + patch.put("entityType", FileEntityType.VERSION.name()); + patch.put("plannedAt", LocalDateTime.now().toString()); + patch.put("progressPercent", calculateProgressPercent( + processedEntityCount, + candidateEntityCount, + migratedStoredFileCount, + candidateStoredFileCount + )); + patch.put("message", migrationPerformed + ? "storage policy migration moved object data through the active storage backend and updated metadata references" + : "storage policy migration is copying object data and updating metadata references"); + return patch; + } + + private int calculateProgressPercent(long processedEntityCount, + long totalEntityCount, + long processedStoredFileCount, + long totalStoredFileCount) { + long total = Math.max(0L, totalEntityCount) + Math.max(0L, totalStoredFileCount); + long processed = Math.max(0L, processedEntityCount) + Math.max(0L, processedStoredFileCount); + if (total <= 0L) { + return 100; + } + return (int) Math.min(100L, Math.floor((processed * 100.0d) / total)); + } + + private void scheduleStaleObjectCleanup(LinkedHashSet staleObjectKeys) { + if (staleObjectKeys.isEmpty() || !TransactionSynchronizationManager.isSynchronizationActive()) { + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + for (String staleObjectKey : staleObjectKeys) { + try { + fileContentStorage.deleteBlob(staleObjectKey); + } catch (RuntimeException ignored) { + // Database state already committed; leave old object cleanup as best effort. + } + } + } + }); + } + + private void cleanupCopiedObjects(List copiedObjectKeys) { + for (String copiedObjectKey : copiedObjectKeys) { + try { + fileContentStorage.deleteBlob(copiedObjectKey); + } catch (RuntimeException ignored) { + // Best-effort cleanup while metadata rolls back. + } + } + } + + 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()); + } + throw new IllegalStateException("storage policy migration task missing " + key); + } +} 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 ecd86e3..121d07e 100644 --- a/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionService.java +++ b/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionService.java @@ -9,6 +9,7 @@ import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.files.core.FileService; 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 com.yoyuzh.files.storage.FileContentStorage; import com.yoyuzh.files.storage.MultipartCompletedPart; @@ -18,6 +19,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; import java.time.Clock; import java.time.LocalDateTime; @@ -78,7 +80,10 @@ public class UploadSessionService { public UploadSession createSession(User user, UploadSessionCreateCommand command) { String normalizedPath = normalizeDirectoryPath(command.path()); String filename = normalizeLeafName(command.filename()); - validateTarget(user, normalizedPath, filename, command.size()); + StoragePolicy policy = storagePolicyService.ensureDefaultPolicy(); + StoragePolicyCapabilities capabilities = storagePolicyService.readCapabilities(policy); + validateTarget(user, normalizedPath, filename, command.size(), policy, capabilities); + UploadSessionUploadMode uploadMode = resolveUploadMode(capabilities); UploadSession session = new UploadSession(); session.setSessionId(UUID.randomUUID().toString()); @@ -88,17 +93,18 @@ public class UploadSessionService { session.setContentType(command.contentType()); session.setSize(command.size()); session.setObjectKey(createBlobObjectKey()); - StoragePolicy policy = storagePolicyService.ensureDefaultPolicy(); session.setStoragePolicyId(policy.getId()); session.setChunkSize(DEFAULT_CHUNK_SIZE); - session.setChunkCount(calculateChunkCount(command.size(), DEFAULT_CHUNK_SIZE)); + session.setChunkCount(uploadMode == UploadSessionUploadMode.DIRECT_MULTIPART + ? calculateChunkCount(command.size(), DEFAULT_CHUNK_SIZE) + : 1); session.setUploadedPartsJson("[]"); session.setStatus(UploadSessionStatus.CREATED); LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone()); session.setCreatedAt(now); session.setUpdatedAt(now); session.setExpiresAt(now.plusHours(SESSION_TTL_HOURS)); - if (storagePolicyService.readCapabilities(policy).multipartUpload()) { + if (uploadMode == UploadSessionUploadMode.DIRECT_MULTIPART) { session.setMultipartUploadId(fileContentStorage.createMultipartUpload(session.getObjectKey(), session.getContentType())); } return uploadSessionRepository.save(session); @@ -121,12 +127,30 @@ public class UploadSessionService { return uploadSessionRepository.save(session); } + @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); + if (resolveUploadMode(session) != UploadSessionUploadMode.DIRECT_SINGLE) { + throw new BusinessException(ErrorCode.UNKNOWN, "上传会话未启用单请求直传"); + } + return fileContentStorage.prepareBlobUpload( + session.getTargetPath(), + session.getFilename(), + session.getObjectKey(), + session.getContentType(), + session.getSize() + ); + } + @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); - if (!StringUtils.hasText(session.getMultipartUploadId())) { + if (resolveUploadMode(session) != UploadSessionUploadMode.DIRECT_MULTIPART + || !StringUtils.hasText(session.getMultipartUploadId())) { throw new BusinessException(ErrorCode.UNKNOWN, "上传会话未启用 multipart"); } if (partIndex < 0 || partIndex >= session.getChunkCount()) { @@ -149,6 +173,9 @@ public class UploadSessionService { UploadSession session = getOwnedSession(user, sessionId); LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone()); ensureSessionCanReceivePart(session, now); + if (resolveUploadMode(session) != UploadSessionUploadMode.DIRECT_MULTIPART) { + throw new BusinessException(ErrorCode.UNKNOWN, "上传会话未启用 multipart"); + } if (partIndex < 0 || partIndex >= session.getChunkCount()) { throw new BusinessException(ErrorCode.UNKNOWN, "分片序号不合法"); } @@ -172,6 +199,28 @@ public class UploadSessionService { return uploadSessionRepository.save(session); } + @Transactional + public UploadSession uploadOwnedContent(User user, String sessionId, MultipartFile file) { + UploadSession session = getOwnedSession(user, sessionId); + LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone()); + ensureSessionCanReceiveContent(session, now); + if (resolveUploadMode(session) != UploadSessionUploadMode.PROXY) { + throw new BusinessException(ErrorCode.UNKNOWN, "上传会话未启用代理上传"); + } + if (file == null || file.isEmpty()) { + throw new BusinessException(ErrorCode.UNKNOWN, "上传内容不能为空"); + } + if (file.getSize() != session.getSize()) { + throw new BusinessException(ErrorCode.UNKNOWN, "上传内容大小与会话不一致"); + } + fileContentStorage.uploadBlob(session.getObjectKey(), file); + if (session.getStatus() == UploadSessionStatus.CREATED) { + session.setStatus(UploadSessionStatus.UPLOADING); + } + session.setUpdatedAt(now); + return uploadSessionRepository.save(session); + } + @Transactional public UploadSession completeOwnedSession(User user, String sessionId) { UploadSession session = getOwnedSession(user, sessionId); @@ -194,7 +243,8 @@ public class UploadSessionService { uploadSessionRepository.save(session); try { - if (StringUtils.hasText(session.getMultipartUploadId())) { + if (resolveUploadMode(session) == UploadSessionUploadMode.DIRECT_MULTIPART + && StringUtils.hasText(session.getMultipartUploadId())) { fileContentStorage.completeMultipartUpload( session.getObjectKey(), session.getMultipartUploadId(), @@ -246,8 +296,40 @@ public class UploadSessionService { return expiredSessions.size(); } - private void validateTarget(User user, String normalizedPath, String filename, long size) { + public UploadSessionUploadMode resolveUploadMode(UploadSession session) { + if (session.getStoragePolicyId() == null) { + if (StringUtils.hasText(session.getMultipartUploadId()) || session.getChunkCount() > 1) { + return UploadSessionUploadMode.DIRECT_MULTIPART; + } + 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; + } + + 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, "文件大小超出限制"); } @@ -260,6 +342,13 @@ public class UploadSessionService { } } + 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 上传会话不能走整体内容上传"); + } + } + private void ensureSessionCanReceivePart(UploadSession session, LocalDateTime now) { if (session.getStatus() == UploadSessionStatus.CANCELLED || session.getStatus() == UploadSessionStatus.FAILED diff --git a/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionUploadMode.java b/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionUploadMode.java new file mode 100644 index 0000000..7a8fcb6 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/upload/UploadSessionUploadMode.java @@ -0,0 +1,7 @@ +package com.yoyuzh.files.upload; + +public enum UploadSessionUploadMode { + PROXY, + DIRECT_SINGLE, + DIRECT_MULTIPART +} diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java index 358611d..8f3f231 100644 --- a/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java +++ b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java @@ -8,6 +8,9 @@ import com.yoyuzh.files.core.FileBlob; import com.yoyuzh.files.core.FileBlobRepository; import com.yoyuzh.files.core.StoredFile; import com.yoyuzh.files.core.StoredFileRepository; +import com.yoyuzh.files.policy.StoragePolicy; +import com.yoyuzh.files.policy.StoragePolicyRepository; +import com.yoyuzh.files.policy.StoragePolicyType; import com.yoyuzh.transfer.OfflineTransferSessionRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -41,7 +44,7 @@ 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", + "app.admin.usernames=admin,alice", "app.storage.root-dir=./target/test-storage-admin" } ) @@ -66,6 +69,8 @@ class AdminControllerIntegrationTest { private AdminMetricsStateRepository adminMetricsStateRepository; @Autowired private AdminMetricsService adminMetricsService; + @Autowired + private StoragePolicyRepository storagePolicyRepository; private User portalUser; private User secondaryUser; @@ -338,6 +343,149 @@ class AdminControllerIntegrationTest { .andExpect(jsonPath("$.data[0].maxSizeBytes").isNumber()); } + @Test + @WithMockUser(username = "admin") + void shouldAllowConfiguredAdminToCreateUpdateAndDisableNonDefaultStoragePolicy() throws Exception { + mockMvc.perform(post("/api/admin/storage-policies") + .contentType("application/json") + .content(""" + { + "name": "Archive Bucket", + "type": "S3_COMPATIBLE", + "bucketName": "archive-bucket", + "endpoint": "https://s3.example.com", + "region": "auto", + "privateBucket": true, + "prefix": "archive/", + "credentialMode": "STATIC", + "maxSizeBytes": 20480, + "enabled": true, + "capabilities": { + "directUpload": true, + "multipartUpload": true, + "signedDownloadUrl": true, + "serverProxyDownload": true, + "thumbnailNative": false, + "friendlyDownloadName": true, + "requiresCors": true, + "supportsInternalEndpoint": false, + "maxObjectSize": 20480 + } + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.name").value("Archive Bucket")) + .andExpect(jsonPath("$.data.type").value("S3_COMPATIBLE")) + .andExpect(jsonPath("$.data.defaultPolicy").value(false)); + + Long createdPolicyId = storagePolicyRepository.findAll().stream() + .filter(policy -> "Archive Bucket".equals(policy.getName())) + .map(StoragePolicy::getId) + .findFirst() + .orElseThrow(); + + mockMvc.perform(put("/api/admin/storage-policies/{policyId}", createdPolicyId) + .contentType("application/json") + .content(""" + { + "name": "Hot Bucket", + "type": "S3_COMPATIBLE", + "bucketName": "hot-bucket", + "endpoint": "https://hot.example.com", + "region": "cn-north-1", + "privateBucket": false, + "prefix": "hot/", + "credentialMode": "DOGECLOUD_TEMP", + "maxSizeBytes": 40960, + "enabled": true, + "capabilities": { + "directUpload": true, + "multipartUpload": true, + "signedDownloadUrl": true, + "serverProxyDownload": true, + "thumbnailNative": false, + "friendlyDownloadName": true, + "requiresCors": true, + "supportsInternalEndpoint": false, + "maxObjectSize": 40960 + } + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(createdPolicyId)) + .andExpect(jsonPath("$.data.name").value("Hot Bucket")) + .andExpect(jsonPath("$.data.bucketName").value("hot-bucket")) + .andExpect(jsonPath("$.data.credentialMode").value("DOGECLOUD_TEMP")); + + mockMvc.perform(patch("/api/admin/storage-policies/{policyId}/status", createdPolicyId) + .contentType("application/json") + .content(""" + { + "enabled": false + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(createdPolicyId)) + .andExpect(jsonPath("$.data.enabled").value(false)); + } + + @Test + @WithMockUser(username = "admin") + void shouldRejectDisablingDefaultStoragePolicy() throws Exception { + StoragePolicy defaultPolicy = storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc().orElseThrow(); + + mockMvc.perform(patch("/api/admin/storage-policies/{policyId}/status", defaultPolicy.getId()) + .contentType("application/json") + .content(""" + { + "enabled": false + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value("默认存储策略不能停用")); + } + + @Test + void shouldAllowAdminUserToCreateStoragePolicyMigrationTask() throws Exception { + StoragePolicy sourcePolicy = storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc().orElseThrow(); + + StoragePolicy targetPolicy = new StoragePolicy(); + targetPolicy.setName("Archive Bucket"); + targetPolicy.setType(StoragePolicyType.S3_COMPATIBLE); + targetPolicy.setBucketName("archive-bucket"); + targetPolicy.setEndpoint("https://s3.example.com"); + targetPolicy.setRegion("auto"); + targetPolicy.setPrivateBucket(true); + targetPolicy.setPrefix("archive/"); + targetPolicy.setCredentialMode(com.yoyuzh.files.policy.StoragePolicyCredentialMode.STATIC); + targetPolicy.setMaxSizeBytes(40960L); + targetPolicy.setCapabilitiesJson(""" + {"directUpload":true,"multipartUpload":true,"signedDownloadUrl":true,"serverProxyDownload":true,"thumbnailNative":false,"friendlyDownloadName":true,"requiresCors":true,"supportsInternalEndpoint":false,"maxObjectSize":40960} + """); + targetPolicy.setEnabled(true); + targetPolicy.setDefaultPolicy(false); + targetPolicy = storagePolicyRepository.save(targetPolicy); + + mockMvc.perform(post("/api/admin/storage-policies/migrations") + .with(user("alice")) + .contentType("application/json") + .content(""" + { + "sourcePolicyId": %d, + "targetPolicyId": %d, + "correlationId": "migration-1" + } + """.formatted(sourcePolicy.getId(), targetPolicy.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.type").value("STORAGE_POLICY_MIGRATION")) + .andExpect(jsonPath("$.data.status").value("QUEUED")) + .andExpect(jsonPath("$.data.publicStateJson").value(org.hamcrest.Matchers.containsString("\"sourcePolicyId\":" + sourcePolicy.getId()))) + .andExpect(jsonPath("$.data.publicStateJson").value(org.hamcrest.Matchers.containsString("\"targetPolicyId\":" + targetPolicy.getId()))) + .andExpect(jsonPath("$.data.publicStateJson").value(org.hamcrest.Matchers.containsString("\"migrationPerformed\":false"))); + } + @Test @WithMockUser(username = "portal-user") void shouldRejectNonAdminUser() throws Exception { diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java index d8561d7..cadde0e 100644 --- a/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java @@ -9,11 +9,21 @@ 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.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; @@ -62,6 +72,12 @@ class AdminServiceTest { private StoragePolicyRepository storagePolicyRepository; @Mock private StoragePolicyService storagePolicyService; + @Mock + private FileEntityRepository fileEntityRepository; + @Mock + private StoredFileEntityRepository storedFileEntityRepository; + @Mock + private BackgroundTaskService backgroundTaskService; private AdminService adminService; @@ -71,7 +87,8 @@ class AdminServiceTest { userRepository, storedFileRepository, fileBlobRepository, fileService, passwordEncoder, refreshTokenService, registrationInviteService, offlineTransferSessionRepository, adminMetricsService, - storagePolicyRepository, storagePolicyService); + storagePolicyRepository, storagePolicyService, + fileEntityRepository, storedFileEntityRepository, backgroundTaskService); } // --- getSummary --- @@ -161,6 +178,133 @@ class AdminServiceTest { 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) + .hasMessageContaining("默认存储策略不能停用"); + + 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 @@ -297,4 +441,38 @@ class AdminServiceTest { 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/api/v2/files/UploadSessionV2ControllerTest.java b/backend/src/test/java/com/yoyuzh/api/v2/files/UploadSessionV2ControllerTest.java index ed46490..a4960b4 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 @@ -3,6 +3,7 @@ package com.yoyuzh.api.v2.files; import com.yoyuzh.auth.CustomUserDetailsService; import com.yoyuzh.auth.User; import com.yoyuzh.files.upload.UploadSession; +import com.yoyuzh.files.upload.UploadSessionUploadMode; import com.yoyuzh.files.upload.UploadSessionService; import com.yoyuzh.files.upload.UploadSessionStatus; import org.junit.jupiter.api.BeforeEach; @@ -27,6 +28,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -70,7 +72,12 @@ class UploadSessionV2ControllerTest { .andExpect(jsonPath("$.data.sessionId").value("session-1")) .andExpect(jsonPath("$.data.objectKey").value("blobs/session-1")) .andExpect(jsonPath("$.data.status").value("CREATED")) + .andExpect(jsonPath("$.data.directUpload").value(true)) .andExpect(jsonPath("$.data.multipartUpload").value(true)) + .andExpect(jsonPath("$.data.uploadMode").value("DIRECT_MULTIPART")) + .andExpect(jsonPath("$.data.strategy.partPrepareUrlTemplate").value("/api/v2/files/upload-sessions/session-1/parts/{partIndex}/prepare")) + .andExpect(jsonPath("$.data.strategy.partRecordUrlTemplate").value("/api/v2/files/upload-sessions/session-1/parts/{partIndex}")) + .andExpect(jsonPath("$.data.strategy.completeUrl").value("/api/v2/files/upload-sessions/session-1/complete")) .andExpect(jsonPath("$.data.chunkSize").value(8388608)) .andExpect(jsonPath("$.data.chunkCount").value(3)); } @@ -88,7 +95,76 @@ class UploadSessionV2ControllerTest { .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.sessionId").value("session-1")) .andExpect(jsonPath("$.data.status").value("CREATED")) - .andExpect(jsonPath("$.data.multipartUpload").value(true)); + .andExpect(jsonPath("$.data.directUpload").value(true)) + .andExpect(jsonPath("$.data.uploadMode").value("DIRECT_MULTIPART")) + .andExpect(jsonPath("$.data.multipartUpload").value(true)) + .andExpect(jsonPath("$.data.strategy.partPrepareUrlTemplate").value("/api/v2/files/upload-sessions/session-1/parts/{partIndex}/prepare")) + .andExpect(jsonPath("$.data.strategy.partRecordUrlTemplate").value("/api/v2/files/upload-sessions/session-1/parts/{partIndex}")) + .andExpect(jsonPath("$.data.strategy.completeUrl").value("/api/v2/files/upload-sessions/session-1/complete")); + } + + @Test + void shouldReturnDirectSingleStrategyInSessionResponse() throws Exception { + User user = createUser(7L); + UploadSession session = createSession(user); + session.setMultipartUploadId(null); + session.setChunkCount(1); + when(userDetailsService.loadDomainUser("alice")).thenReturn(user); + when(uploadSessionService.getOwnedSession(user, "session-1")).thenReturn(session); + when(uploadSessionService.resolveUploadMode(session)).thenReturn(UploadSessionUploadMode.DIRECT_SINGLE); + + mockMvc.perform(get("/api/v2/files/upload-sessions/session-1") + .with(user(userDetails()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.uploadMode").value("DIRECT_SINGLE")) + .andExpect(jsonPath("$.data.strategy.prepareUrl").value("/api/v2/files/upload-sessions/session-1/prepare")) + .andExpect(jsonPath("$.data.strategy.completeUrl").value("/api/v2/files/upload-sessions/session-1/complete")); + } + + @Test + void shouldPrepareSingleUploadWithV2Envelope() throws Exception { + User user = createUser(7L); + when(userDetailsService.loadDomainUser("alice")).thenReturn(user); + when(uploadSessionService.prepareOwnedUpload(user, "session-1")) + .thenReturn(new com.yoyuzh.files.storage.PreparedUpload( + true, + "https://upload.example.com/session-1", + "PUT", + Map.of("Content-Type", "video/mp4"), + "blobs/session-1" + )); + + mockMvc.perform(get("/api/v2/files/upload-sessions/session-1/prepare") + .with(user(userDetails()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.direct").value(true)) + .andExpect(jsonPath("$.data.uploadUrl").value("https://upload.example.com/session-1")) + .andExpect(jsonPath("$.data.method").value("PUT")) + .andExpect(jsonPath("$.data.headers['Content-Type']").value("video/mp4")); + } + + @Test + void shouldUploadProxyContentWithV2Envelope() throws Exception { + User user = createUser(7L); + UploadSession session = createSession(user); + session.setStatus(UploadSessionStatus.UPLOADING); + session.setMultipartUploadId(null); + session.setChunkCount(1); + when(userDetailsService.loadDomainUser("alice")).thenReturn(user); + when(uploadSessionService.uploadOwnedContent(eq(user), eq("session-1"), any())).thenReturn(session); + + mockMvc.perform(multipart("/api/v2/files/upload-sessions/session-1/content") + .file("file", "payload".getBytes()) + .with(user(userDetails()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.sessionId").value("session-1")) + .andExpect(jsonPath("$.data.status").value("UPLOADING")) + .andExpect(jsonPath("$.data.uploadMode").value("PROXY")) + .andExpect(jsonPath("$.data.strategy.proxyContentUrl").value("/api/v2/files/upload-sessions/session-1/content")) + .andExpect(jsonPath("$.data.strategy.proxyFormField").value("file")) + .andExpect(jsonPath("$.data.strategy.completeUrl").value("/api/v2/files/upload-sessions/session-1/complete")); } @Test 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 ea8fdf3..3c7f92f 100644 --- a/backend/src/test/java/com/yoyuzh/files/core/FileServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/files/core/FileServiceTest.java @@ -240,6 +240,43 @@ class FileServiceTest { verify(fileContentStorage).prepareBlobUpload(eq("/docs"), eq("movie.zip"), org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq("application/zip"), eq(uploadSize)); } + @Test + void shouldInitiateProxyUploadWhenDefaultPolicyDisablesDirectUpload() { + fileService = new FileService( + storedFileRepository, + fileBlobRepository, + fileEntityRepository, + storedFileEntityRepository, + fileContentStorage, + fileShareLinkRepository, + adminMetricsService, + storagePolicyService, + new FileStorageProperties() + ); + User user = createUser(7L); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false); + StoragePolicy policy = createDefaultStoragePolicy(); + when(storagePolicyService.ensureDefaultPolicy()).thenReturn(policy); + when(storagePolicyService.readCapabilities(policy)).thenReturn(new com.yoyuzh.files.policy.StoragePolicyCapabilities( + false, + false, + false, + true, + false, + true, + false, + false, + 500L * 1024 * 1024 + )); + + InitiateUploadResponse response = fileService.initiateUpload(user, + new InitiateUploadRequest("/docs", "notes.txt", "text/plain", 12L)); + + assertThat(response.direct()).isFalse(); + assertThat(response.storageName()).startsWith("blobs/"); + verify(fileContentStorage, never()).prepareBlobUpload(any(), any(), any(), any(), any(Long.class)); + } + @Test void shouldCompleteDirectUploadAndPersistMetadata() { User user = createUser(7L); diff --git a/backend/src/test/java/com/yoyuzh/files/tasks/StoragePolicyMigrationBackgroundTaskHandlerTest.java b/backend/src/test/java/com/yoyuzh/files/tasks/StoragePolicyMigrationBackgroundTaskHandlerTest.java new file mode 100644 index 0000000..567be8d --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/tasks/StoragePolicyMigrationBackgroundTaskHandlerTest.java @@ -0,0 +1,162 @@ +package com.yoyuzh.files.tasks; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.files.core.FileBlob; +import com.yoyuzh.files.core.FileBlobRepository; +import com.yoyuzh.files.core.FileEntityType; +import com.yoyuzh.files.core.FileEntityRepository; +import com.yoyuzh.files.core.StoredFileRepository; +import com.yoyuzh.files.policy.StoragePolicy; +import com.yoyuzh.files.policy.StoragePolicyRepository; +import com.yoyuzh.files.policy.StoragePolicyType; +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.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.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StoragePolicyMigrationBackgroundTaskHandlerTest { + + @Mock + private StoragePolicyRepository storagePolicyRepository; + @Mock + private FileEntityRepository fileEntityRepository; + @Mock + private FileBlobRepository fileBlobRepository; + @Mock + private StoredFileRepository storedFileRepository; + @Mock + private FileContentStorage fileContentStorage; + + private StoragePolicyMigrationBackgroundTaskHandler handler; + + @BeforeEach + void setUp() { + handler = new StoragePolicyMigrationBackgroundTaskHandler( + storagePolicyRepository, + fileEntityRepository, + fileBlobRepository, + storedFileRepository, + fileContentStorage, + new ObjectMapper() + ); + } + + @Test + void shouldMigrateCandidateEntitiesAndUpdatePolicyCounts() { + StoragePolicy sourcePolicy = createPolicy(3L, "Source Policy"); + StoragePolicy targetPolicy = createPolicy(4L, "Target Policy"); + FileBlob blob = new FileBlob(); + blob.setId(30L); + blob.setObjectKey("blobs/source-1"); + blob.setContentType("video/mp4"); + blob.setSize(12L); + var entity = new com.yoyuzh.files.core.FileEntity(); + entity.setId(21L); + entity.setObjectKey("blobs/source-1"); + entity.setContentType("video/mp4"); + entity.setSize(12L); + entity.setEntityType(FileEntityType.VERSION); + entity.setStoragePolicyId(3L); + when(storagePolicyRepository.findById(3L)).thenReturn(Optional.of(sourcePolicy)); + when(storagePolicyRepository.findById(4L)).thenReturn(Optional.of(targetPolicy)); + when(fileEntityRepository.findByStoragePolicyIdAndEntityTypeOrderByIdAsc(3L, FileEntityType.VERSION)).thenReturn(List.of(entity)); + when(fileBlobRepository.findByObjectKey("blobs/source-1")).thenReturn(Optional.of(blob)); + when(storedFileRepository.countByBlobId(30L)).thenReturn(2L); + when(fileEntityRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(fileBlobRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(fileContentStorage.readBlob("blobs/source-1")).thenReturn("payload".getBytes()); + + BackgroundTask task = new BackgroundTask(); + task.setId(11L); + task.setType(BackgroundTaskType.STORAGE_POLICY_MIGRATION); + task.setStatus(BackgroundTaskStatus.RUNNING); + task.setUserId(99L); + task.setPrivateStateJson(""" + {"sourcePolicyId":3,"targetPolicyId":4,"sourcePolicyName":"Source Policy","targetPolicyName":"Target Policy"} + """); + task.setPublicStateJson("{}"); + + BackgroundTaskHandlerResult result = handler.handle(task); + + assertThat(result.publicStatePatch()).containsEntry("worker", "storage-policy-migration"); + assertThat(result.publicStatePatch()).containsEntry("migrationMode", "executed"); + assertThat(result.publicStatePatch()).containsEntry("migrationPerformed", true); + assertThat(result.publicStatePatch()).containsEntry("candidateEntityCount", 1L); + assertThat(result.publicStatePatch()).containsEntry("candidateStoredFileCount", 2L); + assertThat(result.publicStatePatch()).containsEntry("migratedEntityCount", 1L); + assertThat(result.publicStatePatch()).containsEntry("migratedStoredFileCount", 2L); + assertThat(result.publicStatePatch()).containsEntry("processedEntityCount", 1L); + assertThat(result.publicStatePatch()).containsEntry("progressPercent", 100); + assertThat(entity.getStoragePolicyId()).isEqualTo(4L); + assertThat(entity.getObjectKey()).startsWith("policies/4/blobs/"); + assertThat(blob.getObjectKey()).startsWith("policies/4/blobs/"); + verify(fileContentStorage).storeBlob(startsWith("policies/4/blobs/"), eq("video/mp4"), any()); + } + + @Test + void shouldDeleteCopiedObjectsWhenMigrationFails() { + StoragePolicy sourcePolicy = createPolicy(3L, "Source Policy"); + StoragePolicy targetPolicy = createPolicy(4L, "Target Policy"); + FileBlob blob = new FileBlob(); + blob.setId(30L); + blob.setObjectKey("blobs/source-1"); + blob.setContentType("video/mp4"); + blob.setSize(12L); + var entity = new com.yoyuzh.files.core.FileEntity(); + entity.setId(21L); + entity.setObjectKey("blobs/source-1"); + entity.setContentType("video/mp4"); + entity.setSize(12L); + entity.setEntityType(FileEntityType.VERSION); + entity.setStoragePolicyId(3L); + when(storagePolicyRepository.findById(3L)).thenReturn(Optional.of(sourcePolicy)); + when(storagePolicyRepository.findById(4L)).thenReturn(Optional.of(targetPolicy)); + when(fileEntityRepository.findByStoragePolicyIdAndEntityTypeOrderByIdAsc(3L, FileEntityType.VERSION)).thenReturn(List.of(entity)); + when(fileBlobRepository.findByObjectKey("blobs/source-1")).thenReturn(Optional.of(blob)); + when(storedFileRepository.countByBlobId(30L)).thenReturn(2L); + when(fileContentStorage.readBlob("blobs/source-1")).thenReturn("payload".getBytes()); + doThrow(new IllegalStateException("store failed")).when(fileContentStorage).storeBlob(startsWith("policies/4/blobs/"), eq("video/mp4"), any()); + + BackgroundTask task = new BackgroundTask(); + task.setId(11L); + task.setType(BackgroundTaskType.STORAGE_POLICY_MIGRATION); + task.setStatus(BackgroundTaskStatus.RUNNING); + task.setUserId(99L); + task.setPrivateStateJson(""" + {"sourcePolicyId":3,"targetPolicyId":4,"sourcePolicyName":"Source Policy","targetPolicyName":"Target Policy"} + """); + task.setPublicStateJson("{}"); + + assertThatThrownBy(() -> handler.handle(task)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("store failed"); + verify(fileContentStorage).deleteBlob(startsWith("policies/4/blobs/")); + } + + private StoragePolicy createPolicy(Long id, String name) { + StoragePolicy policy = new StoragePolicy(); + policy.setId(id); + policy.setName(name); + policy.setType(StoragePolicyType.LOCAL); + policy.setEnabled(true); + policy.setCreatedAt(LocalDateTime.now()); + policy.setUpdatedAt(LocalDateTime.now()); + return policy; + } +} 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 c1059df..6dc2214 100644 --- a/backend/src/test/java/com/yoyuzh/files/upload/UploadSessionServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/files/upload/UploadSessionServiceTest.java @@ -11,6 +11,7 @@ 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; @@ -134,6 +135,111 @@ class UploadSessionServiceTest { assertThat(preparedUpload.method()).isEqualTo("PUT"); } + @Test + void shouldPrepareDirectSingleUploadForOwnedSessionWhenPolicyDisablesMultipart() { + User user = createUser(7L); + UploadSession session = createSession(user); + session.setStoragePolicyId(42L); + session.setMultipartUploadId(null); + session.setChunkCount(1); + when(uploadSessionRepository.findBySessionIdAndUserId("session-1", 7L)) + .thenReturn(Optional.of(session)); + StoragePolicy policy = createDefaultStoragePolicy(); + when(storagePolicyService.getRequiredPolicy(42L)).thenReturn(policy); + when(storagePolicyService.readCapabilities(policy)).thenReturn(new StoragePolicyCapabilities( + true, + false, + true, + true, + false, + true, + true, + false, + 500L * 1024 * 1024 + )); + when(fileContentStorage.prepareBlobUpload("/docs", "movie.mp4", "blobs/session-1", "video/mp4", 20L)) + .thenReturn(new PreparedUpload( + true, + "https://upload.example.com/session-1", + "PUT", + Map.of("Content-Type", "video/mp4"), + "blobs/session-1" + )); + + PreparedUpload preparedUpload = uploadSessionService.prepareOwnedUpload(user, "session-1"); + + assertThat(preparedUpload.direct()).isTrue(); + assertThat(preparedUpload.uploadUrl()).isEqualTo("https://upload.example.com/session-1"); + assertThat(preparedUpload.method()).isEqualTo("PUT"); + } + + @Test + void shouldUploadProxyContentForOwnedSessionWhenPolicyDisablesDirectUpload() { + User user = createUser(7L); + UploadSession session = createSession(user); + session.setStoragePolicyId(42L); + session.setMultipartUploadId(null); + session.setChunkCount(1); + session.setSize(7L); + when(uploadSessionRepository.findBySessionIdAndUserId("session-1", 7L)) + .thenReturn(Optional.of(session)); + StoragePolicy policy = createDefaultStoragePolicy(); + when(storagePolicyService.getRequiredPolicy(42L)).thenReturn(policy); + when(storagePolicyService.readCapabilities(policy)).thenReturn(new StoragePolicyCapabilities( + false, + false, + false, + true, + false, + true, + false, + false, + 500L * 1024 * 1024 + )); + when(uploadSessionRepository.save(any(UploadSession.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + UploadSession result = uploadSessionService.uploadOwnedContent( + user, + "session-1", + new MockMultipartFile("file", "movie.mp4", "video/mp4", "payload".getBytes()) + ); + + assertThat(result.getStatus()).isEqualTo(UploadSessionStatus.UPLOADING); + verify(fileContentStorage).uploadBlob(eq("blobs/session-1"), any(MockMultipartFile.class)); + } + + @Test + void shouldCreateProxyUploadSessionWhenPolicyDisablesDirectUpload() { + User user = createUser(7L); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.mp4")).thenReturn(false); + StoragePolicy policy = createDefaultStoragePolicy(); + when(storagePolicyService.ensureDefaultPolicy()).thenReturn(policy); + when(storagePolicyService.readCapabilities(policy)).thenReturn(new StoragePolicyCapabilities( + false, + false, + false, + true, + false, + true, + false, + false, + 500L * 1024 * 1024 + )); + when(uploadSessionRepository.save(any(UploadSession.class))).thenAnswer(invocation -> { + UploadSession saved = invocation.getArgument(0); + saved.setId(100L); + return saved; + }); + + UploadSession session = uploadSessionService.createSession( + user, + new UploadSessionCreateCommand("/docs", "movie.mp4", "video/mp4", 20L) + ); + + assertThat(session.getMultipartUploadId()).isNull(); + assertThat(session.getChunkCount()).isEqualTo(1); + } + @Test void shouldOnlyReturnSessionOwnedByCurrentUser() { User user = createUser(7L); @@ -153,6 +259,19 @@ class UploadSessionServiceTest { void shouldRejectDuplicateTargetWhenCreatingSession() { User user = createUser(7L); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.mp4")).thenReturn(true); + StoragePolicy policy = createDefaultStoragePolicy(); + when(storagePolicyService.ensureDefaultPolicy()).thenReturn(policy); + when(storagePolicyService.readCapabilities(policy)).thenReturn(new StoragePolicyCapabilities( + true, + true, + true, + true, + false, + true, + true, + false, + 500L * 1024 * 1024 + )); assertThatThrownBy(() -> uploadSessionService.createSession( user, diff --git a/docs/agents/unfinished-work.md b/docs/agents/unfinished-work.md index c2531cf..5fbf19e 100644 --- a/docs/agents/unfinished-work.md +++ b/docs/agents/unfinished-work.md @@ -151,27 +151,36 @@ npm run test - v2 upload session 后端已补齐创建、查询、取消、prepare-part、record-part、complete 和过期清理 - `FileContentStorage` 已新增 multipart 抽象;`S3FileContentStorage` 已实现 create/upload-part/complete/abort - 默认 S3 存储策略现在会声明 `multipartUpload=true`;创建会话时会生成 `multipartUploadId`,v2 响应会返回 `multipartUpload` +- v2 会话现在还会返回 `directUpload` 与 `uploadMode=PROXY|DIRECT_SINGLE|DIRECT_MULTIPART` +- v2 会话响应还会返回 `strategy`,显式给出当前模式应该走的 `prepareUrl/proxyContentUrl/partPrepareUrlTemplate/partRecordUrlTemplate/completeUrl` +- `GET /api/v2/files/upload-sessions/{sessionId}/prepare` 已支持 `DIRECT_SINGLE` 模式下的整文件直传 +- `POST /api/v2/files/upload-sessions/{sessionId}/content` 已支持 `PROXY` 模式下的整文件代理上传 - `GET /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}/prepare` 已可返回单分片直传地址;`POST /complete` 会先提交 multipart complete,再复用旧 `FileService.completeUpload()` 落库 - 过期清理已从普通 `deleteBlob` 升级为对未完成 multipart 执行 abort -- 前端上传队列仍未切到 v2 upload session +- 旧 `/api/files/upload/initiate` 现在也会尊重默认策略 `directUpload/maxObjectSize` +- 桌面端 `FilesPage`、移动端 `MobileFilesPage` 和 `saveFileToNetdisk()` 已切到 v2 upload session,并按 `uploadMode + strategy` 自动选择 `PROXY / DIRECT_SINGLE / DIRECT_MULTIPART` +- 前端现已接上 `create/get/cancel/prepare/content/part-prepare/part-record/complete` 全套 v2 upload session helper 后续未完成: -- 前端上传队列切到 v2 session +- 头像上传等非 files 子系统上传入口仍在走旧 `/api/**` 上传链路 ### P2:存储策略继续推进 当前状态: - `StoragePolicy` 和能力声明骨架已落地 -- 管理台只读展示已落地 +- 管理台查看、新增、编辑、启停非默认策略的后端接口已落地 +- `POST /api/admin/storage-policies/migrations` 已可创建 `STORAGE_POLICY_MIGRATION` 后台任务 +- `STORAGE_POLICY_MIGRATION` 现在会在当前活动存储后端内真实复制对象数据到新的 `policies/{targetPolicyId}/blobs/...` key,并更新 `FileBlob` 与 `FileEntity.VERSION` +- 迁移任务会暴露 `migrationStage`、`processedEntityCount/totalEntityCount`、`processedStoredFileCount/totalStoredFileCount`、`migratedEntityCount`、`migratedStoredFileCount` 和真实 `progressPercent` +- 如果迁移过程中失败,handler 会删除本轮新写对象,并依赖事务回滚元数据;旧对象清理则在成功提交后 best-effort 执行 - 物理实体可追踪 `storagePolicyId` 后续未完成: -- 管理台新增/编辑/停用策略 -- 多策略迁移任务 -- 按策略能力决定上传路径与前端上传策略 +- 跨不同运行时后端类型的真正 provider 级迁移 +- 继续把按策略能力的上传体验外扩到其他非 files 子系统上传入口 ## 当前本地运行状态 diff --git a/docs/api-reference.md b/docs/api-reference.md index 37a6cb6..33ac577 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -251,6 +251,8 @@ - `POST /api/v2/files/upload-sessions` - `GET /api/v2/files/upload-sessions/{sessionId}` - `DELETE /api/v2/files/upload-sessions/{sessionId}` +- `GET /api/v2/files/upload-sessions/{sessionId}/prepare` +- `POST /api/v2/files/upload-sessions/{sessionId}/content` - `GET /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}/prepare` - `PUT /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}` - `POST /api/v2/files/upload-sessions/{sessionId}/complete` @@ -258,12 +260,17 @@ 说明: - 需要登录,只允许操作当前用户自己的上传会话 -- 会话响应返回 `sessionId`、`objectKey`、`multipartUpload`、`path`、`filename`、`contentType`、`size`、`storagePolicyId`、`status`、`chunkSize`、`chunkCount`、`expiresAt`、`createdAt`、`updatedAt` -- 默认 S3 存储策略下,创建会话时会立即初始化 multipart upload,并把 `multipartUpload=true` 返回给客户端;本地策略仍会返回 `multipartUpload=false` +- 会话响应返回 `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. 快传模块 @@ -401,13 +408,21 @@ ### 5.4 存储策略 -`GET /api/admin/storage-policies` +- `GET /api/admin/storage-policies` +- `POST /api/admin/storage-policies` +- `PUT /api/admin/storage-policies/{policyId}` +- `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. 前端公开路由与接口关系 diff --git a/docs/architecture.md b/docs/architecture.md index 3e952da..bc4843a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -166,7 +166,8 @@ - 定时清理任务会删除超过 10 天的回收站条目;只有当某个 `FileBlob` 的最后一个逻辑引用随之消失时,才真正删除底层对象 - 应用启动时会把旧 `portal_file.storage_name` 行自动回填到新的 `blob_id` 引用,保证存量数据能继续读取 - 当前线上网盘文件存储已切到多吉云对象存储,后端先通过多吉云临时密钥 API 换取短期 S3 会话,再访问底层 COS 兼容桶 -- v2 上传会话后端现已支持按存储策略能力走真实 multipart:默认 S3 策略会在创建会话时初始化 `multipartUploadId`,分片上传通过预签名 `UploadPart` 直传对象存储,完成时先提交 multipart complete,再复用旧 `FileService.completeUpload()` 落库;本地策略仍保持 `multipartUpload=false` +- 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` 与恢复接口 @@ -240,6 +241,7 @@ Android 壳补充说明: - 当前邀请码由后端返回给管理台展示 - 用户列表会展示每个用户的已用空间 / 配额 - 管理员修改用户密码后,旧密码应立即失效,新密码可直接重新登录 +- 管理台当前已可查看、新增、编辑并启停非默认 `StoragePolicy`,也可创建 `STORAGE_POLICY_MIGRATION` 后台任务;策略能力继续以结构化 `StoragePolicyCapabilities` 持久化和回显。当前迁移任务会在“当前活动存储后端”内复制对象数据到新的 target-policy object key、更新 `FileBlob/FileEntity.VERSION` 元数据,并在事务提交后清理旧对象;但仍不支持跨不同运行时后端类型的真正 provider 级迁移。默认策略切换和策略删除仍未落地 - JWT 过滤器在受保护接口鉴权成功后,会把当天首次上线的用户写入管理统计表,只保留最近 7 天 - 管理台请求折线图只渲染当天已发生的小时,不再为未来小时补空点 @@ -469,6 +471,8 @@ Android 壳补充说明: - 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。 ## 2026-04-08 阶段 5 文件搜索第一小步 diff --git a/docs/superpowers/plans/2026-04-09-multi-user-platform-upgrade-phase-2.md b/docs/superpowers/plans/2026-04-09-multi-user-platform-upgrade-phase-2.md new file mode 100644 index 0000000..0fadf9f --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-multi-user-platform-upgrade-phase-2.md @@ -0,0 +1,446 @@ +# Multi User Platform Upgrade Phase 2 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在当前“邀请制多用户个人盘”稳定后,把系统升级到最小可用的“空间 + 成员角色 + 文件 ACL + 站内共享 + 审计”平台模型,同时不破坏现有个人网盘、公开分享、快传和管理台主链路。 + +**Architecture:** 继续保留现有 `portal_user + StoredFile + FileBlob/FileEntity` 主模型,不推翻个人网盘语义,而是在其上增加 `Space`、`SpaceMember`、`FilePermissionEntry` 和 `AuditLog` 这四层扩展。个人网盘先通过“每个用户自动拥有一个 personal space”兼容到新模型,桌面端和管理台先接入空间与权限入口,移动端只保兼容、不在本阶段新增完整协作 UI。 + +**Tech Stack:** Spring Boot 3.3.8, Spring Data JPA, H2/MySQL, React 19, TypeScript, Vite 6, Tailwind CSS v4, existing `/api/v2` patterns, existing `mvn test`, `npm run test`, `npm run lint`, @test-driven-development. + +--- + +## Scope And Sequencing + +- 本计划默认在当前 `2026-04-08-cloudreve-inspired-upgrade-and-refactor.md` 的既定升级、尤其是阶段 6 和全站前端重设计完成并合入后执行。 +- 本计划只覆盖桌面 Web、现有管理台、后端模型/API、现有测试体系,不在本阶段实现移动端协作页、WebDAV 权限联动、组织/部门层级、团队回收站、第三方 OAuth scope 细化。 +- 所有新能力优先挂在 `/api/v2/**`;旧 `/api/files/**`、`/api/admin/**` 只做必要兼容,不强制一次性重写。 +- 执行中始终使用 @test-driven-development:先写失败测试,再补最小实现,再跑最小验证,最后补全集验证。 + +### Task 1: Add Space Domain Skeleton And Default Personal Space + +**Files:** +- Create: `backend/src/main/java/com/yoyuzh/files/space/Space.java` +- Create: `backend/src/main/java/com/yoyuzh/files/space/SpaceType.java` +- Create: `backend/src/main/java/com/yoyuzh/files/space/SpaceRole.java` +- Create: `backend/src/main/java/com/yoyuzh/files/space/SpaceMember.java` +- Create: `backend/src/main/java/com/yoyuzh/files/space/SpaceRepository.java` +- Create: `backend/src/main/java/com/yoyuzh/files/space/SpaceMemberRepository.java` +- Create: `backend/src/main/java/com/yoyuzh/files/space/SpaceService.java` +- Modify: `backend/src/main/java/com/yoyuzh/auth/User.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFile.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java` +- Create: `backend/src/test/java/com/yoyuzh/files/space/SpaceServiceTest.java` +- Modify: `backend/src/test/java/com/yoyuzh/files/FileServiceTest.java` + +- [ ] **Step 1: Write the failing space-domain tests** + +Run: `cd backend && mvn test -Dtest=SpaceServiceTest,FileServiceTest` +Expected: FAIL because `Space`, `SpaceMember`, and `StoredFile.space` support do not exist yet. + +- [ ] **Step 2: Add the minimal schema and repository layer** + +Implement: +- `Space` with `PERSONAL` and `COLLABORATIVE` types +- `SpaceMember` with `OWNER`, `MANAGER`, `EDITOR`, `VIEWER` +- `StoredFile.space` as nullable-to-required migration target +- repository methods for “default personal space by user” and “space membership by user” + +- [ ] **Step 3: Add default personal-space bootstrap** + +Implement `SpaceService.ensurePersonalSpace(User)` so: +- existing users can lazily receive a personal space +- new users automatically get one after registration/bootstrap +- files created through current private flows can still resolve to a valid space + +- [ ] **Step 4: Re-run the targeted backend tests** + +Run: `cd backend && mvn test -Dtest=SpaceServiceTest,FileServiceTest` +Expected: PASS for personal-space bootstrap and `StoredFile` compatibility assertions. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/main/java/com/yoyuzh/files/space backend/src/main/java/com/yoyuzh/auth/User.java backend/src/main/java/com/yoyuzh/files/StoredFile.java backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java backend/src/test/java/com/yoyuzh/files/space/SpaceServiceTest.java backend/src/test/java/com/yoyuzh/files/FileServiceTest.java +git commit -m "feat(files): add space domain skeleton" +``` + +### Task 2: Route Existing File Ownership Through Spaces Without Breaking Private Disk + +**Files:** +- Modify: `backend/src/main/java/com/yoyuzh/files/FileService.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/FileSearchService.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/ShareV2Service.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/BackgroundTaskService.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java` +- Create: `backend/src/test/java/com/yoyuzh/files/StoredFileSpaceCompatibilityTest.java` +- Modify: `backend/src/test/java/com/yoyuzh/files/FileSearchServiceTest.java` +- Modify: `backend/src/test/java/com/yoyuzh/files/BackgroundTaskServiceTest.java` + +- [ ] **Step 1: Write failing compatibility tests for old personal-disk flows** + +Run: `cd backend && mvn test -Dtest=StoredFileSpaceCompatibilityTest,FileSearchServiceTest,BackgroundTaskServiceTest` +Expected: FAIL because current create/search/task flows still assume “user owns everything directly”. + +- [ ] **Step 2: Thread `spaceId` through internal file creation and lookup** + +Implement the smallest compatibility rules: +- private files default to current user personal space +- current search/list/task flows keep returning the same personal files as before +- share import, archive/extract, upload complete, and metadata tasks preserve the owning space + +- [ ] **Step 3: Keep old API semantics stable** + +Do not change: +- existing personal path semantics +- public share token behavior +- anonymous transfer behavior +- current admin file listing shape + +Only add internal `space` routing needed for future collaborative flows. + +- [ ] **Step 4: Re-run targeted compatibility tests** + +Run: `cd backend && mvn test -Dtest=StoredFileSpaceCompatibilityTest,FileSearchServiceTest,BackgroundTaskServiceTest` +Expected: PASS with no regression on existing personal-disk behavior. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/main/java/com/yoyuzh/files/FileService.java backend/src/main/java/com/yoyuzh/files/FileSearchService.java backend/src/main/java/com/yoyuzh/files/ShareV2Service.java backend/src/main/java/com/yoyuzh/files/BackgroundTaskService.java backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java backend/src/test/java/com/yoyuzh/files/StoredFileSpaceCompatibilityTest.java backend/src/test/java/com/yoyuzh/files/FileSearchServiceTest.java backend/src/test/java/com/yoyuzh/files/BackgroundTaskServiceTest.java +git commit -m "refactor(files): route private disk through personal spaces" +``` + +### Task 3: Add V2 Space APIs And Membership Management + +**Files:** +- Create: `backend/src/main/java/com/yoyuzh/api/v2/spaces/SpaceV2Controller.java` +- Create: `backend/src/main/java/com/yoyuzh/api/v2/spaces/SpaceResponse.java` +- Create: `backend/src/main/java/com/yoyuzh/api/v2/spaces/SpaceMemberResponse.java` +- Create: `backend/src/main/java/com/yoyuzh/api/v2/spaces/CreateSpaceRequest.java` +- Create: `backend/src/main/java/com/yoyuzh/api/v2/spaces/AddSpaceMemberRequest.java` +- Create: `backend/src/main/java/com/yoyuzh/api/v2/spaces/UpdateSpaceMemberRoleRequest.java` +- Modify: `backend/src/main/java/com/yoyuzh/config/SecurityConfig.java` +- Modify: `backend/src/main/java/com/yoyuzh/auth/UserDetailsServiceImpl.java` +- Create: `backend/src/test/java/com/yoyuzh/api/v2/spaces/SpaceV2ControllerIntegrationTest.java` + +- [ ] **Step 1: Write failing API tests for creating and listing spaces** + +Run: `cd backend && mvn test -Dtest=SpaceV2ControllerIntegrationTest` +Expected: FAIL because `/api/v2/spaces/**` endpoints and security wiring do not exist. + +- [ ] **Step 2: Implement the minimal V2 space endpoints** + +Implement: +- `POST /api/v2/spaces` +- `GET /api/v2/spaces` +- `GET /api/v2/spaces/{id}` +- `POST /api/v2/spaces/{id}/members` +- `PATCH /api/v2/spaces/{id}/members/{userId}` +- `DELETE /api/v2/spaces/{id}/members/{userId}` + +Rules: +- only authenticated users can list their spaces +- only `OWNER`/`MANAGER` can manage members +- personal spaces cannot remove the owner or add arbitrary members + +- [ ] **Step 3: Return DTOs only** + +Do not expose JPA entities directly. Keep API outputs small: +- space identity +- type +- display name +- current user role +- member summaries + +- [ ] **Step 4: Re-run targeted V2 API tests** + +Run: `cd backend && mvn test -Dtest=SpaceV2ControllerIntegrationTest` +Expected: PASS for create/list/member add/remove/role change rules. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/main/java/com/yoyuzh/api/v2/spaces backend/src/main/java/com/yoyuzh/config/SecurityConfig.java backend/src/main/java/com/yoyuzh/auth/UserDetailsServiceImpl.java backend/src/test/java/com/yoyuzh/api/v2/spaces/SpaceV2ControllerIntegrationTest.java +git commit -m "feat(api): add v2 space and member management" +``` + +### Task 4: Add File ACL Entries And Permission Evaluation + +**Files:** +- Create: `backend/src/main/java/com/yoyuzh/files/permission/FilePermissionEntry.java` +- Create: `backend/src/main/java/com/yoyuzh/files/permission/FilePermissionRole.java` +- Create: `backend/src/main/java/com/yoyuzh/files/permission/FilePermissionSubjectType.java` +- Create: `backend/src/main/java/com/yoyuzh/files/permission/FilePermissionEntryRepository.java` +- Create: `backend/src/main/java/com/yoyuzh/files/permission/FilePermissionService.java` +- Create: `backend/src/main/java/com/yoyuzh/files/permission/FileAccessEvaluator.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/FileService.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/FileSearchService.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/ShareV2Service.java` +- Create: `backend/src/test/java/com/yoyuzh/files/permission/FilePermissionServiceTest.java` +- Modify: `backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java` + +- [ ] **Step 1: Write failing ACL tests** + +Run: `cd backend && mvn test -Dtest=FilePermissionServiceTest,FileServiceEdgeCaseTest` +Expected: FAIL because file access is still governed only by owner/admin checks. + +- [ ] **Step 2: Implement the minimal ACL model** + +Implement: +- subject types: `USER`, `SPACE` +- permission roles: `VIEWER`, `EDITOR`, `MANAGER` +- inheritance flag for directory-to-descendant lookup + +Rules: +- owner/personal-space owner always has full access +- collaborative space role provides the default baseline +- explicit file ACL can grant additional access to a user + +- [ ] **Step 3: Enforce ACL at service boundaries** + +Apply permission checks to: +- list directory +- upload into folder +- rename/move/copy/delete +- create public share +- import into target folder +- start background tasks on a file + +- [ ] **Step 4: Re-run the ACL and edge-case tests** + +Run: `cd backend && mvn test -Dtest=FilePermissionServiceTest,FileServiceEdgeCaseTest` +Expected: PASS with clear denials for users outside the owning space or without explicit grants. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/main/java/com/yoyuzh/files/permission backend/src/main/java/com/yoyuzh/files/FileService.java backend/src/main/java/com/yoyuzh/files/FileSearchService.java backend/src/main/java/com/yoyuzh/files/ShareV2Service.java backend/src/test/java/com/yoyuzh/files/permission/FilePermissionServiceTest.java backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java +git commit -m "feat(files): add acl-based permission evaluation" +``` + +### Task 5: Add In-App Sharing And “Shared With Me” V2 Views + +**Files:** +- Create: `backend/src/main/java/com/yoyuzh/api/v2/files/ShareFileToUserV2Request.java` +- Create: `backend/src/main/java/com/yoyuzh/api/v2/files/SharedFileV2Response.java` +- Create: `backend/src/main/java/com/yoyuzh/api/v2/files/FilePermissionsV2Controller.java` +- Modify: `backend/src/main/java/com/yoyuzh/api/v2/files/FileSearchV2Controller.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/permission/FilePermissionService.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java` +- Create: `backend/src/test/java/com/yoyuzh/api/v2/files/FilePermissionsV2ControllerIntegrationTest.java` +- Modify: `backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java` + +- [ ] **Step 1: Write failing tests for in-app share and shared-with-me listing** + +Run: `cd backend && mvn test -Dtest=FilePermissionsV2ControllerIntegrationTest,FileShareControllerIntegrationTest` +Expected: FAIL because there is no station-internal share grant endpoint or “shared with me” query. + +- [ ] **Step 2: Add minimal in-app sharing endpoints** + +Implement: +- `GET /api/v2/files/{fileId}/permissions` +- `PUT /api/v2/files/{fileId}/permissions` +- `DELETE /api/v2/files/{fileId}/permissions/{entryId}` +- `GET /api/v2/files/shared-with-me` + +Rules: +- only `MANAGER` or owner can grant/revoke +- station-internal share writes ACL entries, not public share tokens +- `shared-with-me` excludes files already owned through the current user’s own personal space + +- [ ] **Step 3: Keep public sharing separate** + +Do not merge station-internal sharing into `FileShareLink`. +Continue using: +- `FileShareLink` for public token shares +- `FilePermissionEntry` for logged-in user-to-user sharing + +- [ ] **Step 4: Re-run targeted integration tests** + +Run: `cd backend && mvn test -Dtest=FilePermissionsV2ControllerIntegrationTest,FileShareControllerIntegrationTest` +Expected: PASS for grant, revoke, and `shared-with-me` listing behavior. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/main/java/com/yoyuzh/api/v2/files backend/src/main/java/com/yoyuzh/files/permission/FilePermissionService.java backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java backend/src/test/java/com/yoyuzh/api/v2/files/FilePermissionsV2ControllerIntegrationTest.java backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java +git commit -m "feat(files): add in-app sharing and shared-with-me" +``` + +### Task 6: Add Cross-Cutting Audit Logs And Admin Audit Read API + +**Files:** +- Create: `backend/src/main/java/com/yoyuzh/common/audit/AuditLogEntry.java` +- Create: `backend/src/main/java/com/yoyuzh/common/audit/AuditAction.java` +- Create: `backend/src/main/java/com/yoyuzh/common/audit/AuditLogEntryRepository.java` +- Create: `backend/src/main/java/com/yoyuzh/common/audit/AuditLogService.java` +- Create: `backend/src/main/java/com/yoyuzh/admin/AdminAuditLogResponse.java` +- Modify: `backend/src/main/java/com/yoyuzh/admin/AdminController.java` +- Modify: `backend/src/main/java/com/yoyuzh/admin/AdminService.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/FileService.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/space/SpaceService.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/permission/FilePermissionService.java` +- Modify: `backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java` +- Create: `backend/src/test/java/com/yoyuzh/common/audit/AuditLogServiceTest.java` + +- [ ] **Step 1: Write failing audit tests** + +Run: `cd backend && mvn test -Dtest=AuditLogServiceTest,AdminControllerIntegrationTest` +Expected: FAIL because no audit entity/service exists and admin cannot query audit logs. + +- [ ] **Step 2: Implement the smallest useful audit model** + +Persist at least: +- actor user id +- action type +- target type +- target id +- summary text +- created at + +Log these events: +- space create/member change +- file permission grant/revoke +- public share create/delete +- file delete/restore + +- [ ] **Step 3: Add admin read API only** + +Implement a read-only admin endpoint: +- `GET /api/admin/audit-logs?page=0&size=20&query=...` + +Do not add end-user audit UI in this phase. + +- [ ] **Step 4: Re-run audit tests** + +Run: `cd backend && mvn test -Dtest=AuditLogServiceTest,AdminControllerIntegrationTest` +Expected: PASS for audit persistence and admin listing. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/main/java/com/yoyuzh/common/audit backend/src/main/java/com/yoyuzh/admin/AdminController.java backend/src/main/java/com/yoyuzh/admin/AdminService.java backend/src/main/java/com/yoyuzh/files/FileService.java backend/src/main/java/com/yoyuzh/files/space/SpaceService.java backend/src/main/java/com/yoyuzh/files/permission/FilePermissionService.java backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java backend/src/test/java/com/yoyuzh/common/audit/AuditLogServiceTest.java +git commit -m "feat(admin): add audit log backbone" +``` + +### Task 7: Add Desktop-Web Space, Permission, And Shared-With-Me UI + +**Files:** +- Modify: `front/src/lib/types.ts` +- Modify: `front/src/lib/api.ts` +- Create: `front/src/lib/spaces.ts` +- Create: `front/src/lib/spaces.test.ts` +- Create: `front/src/lib/file-permissions.ts` +- Create: `front/src/lib/file-permissions.test.ts` +- Modify: `front/src/App.tsx` +- Modify: `front/src/components/layout/Layout.tsx` +- Modify: `front/src/pages/files/FilesDirectoryRail.tsx` +- Modify: `front/src/pages/files/FilesToolbar.tsx` +- Modify: `front/src/pages/files/useFilesDirectoryState.ts` +- Create: `front/src/pages/Spaces.tsx` +- Create: `front/src/pages/SharedWithMe.tsx` +- Create: `front/src/pages/files/SpaceMembersDialog.tsx` +- Create: `front/src/pages/files/FilePermissionsDialog.tsx` +- Create: `front/src/pages/spaces-state.ts` +- Create: `front/src/pages/spaces-state.test.ts` +- Create: `front/src/pages/shared-with-me-state.ts` +- Create: `front/src/pages/shared-with-me-state.test.ts` + +- [ ] **Step 1: Write failing frontend helper tests** + +Run: `cd front && npm run test -- src/lib/spaces.test.ts src/lib/file-permissions.test.ts src/pages/spaces-state.test.ts src/pages/shared-with-me-state.test.ts` +Expected: FAIL because the new API helpers and page state modules do not exist. + +- [ ] **Step 2: Add typed API helpers first** + +Implement the smallest helpers for: +- list/create spaces +- list/update members +- get/update file permissions +- list shared-with-me files + +Do not start with JSX-heavy pages before helpers and tests exist. + +- [ ] **Step 3: Add desktop-only navigation and management UI** + +Implement: +- `Spaces` page for list/create/member management +- `SharedWithMe` page for station-internal shares +- file page dialogs for members and permissions +- a space switcher in the file rail or toolbar + +Rules: +- keep current private disk route working +- personal space remains default landing target +- mobile app can stay unchanged in this phase + +- [ ] **Step 4: Re-run targeted frontend tests** + +Run: `cd front && npm run test -- src/lib/spaces.test.ts src/lib/file-permissions.test.ts src/pages/spaces-state.test.ts src/pages/shared-with-me-state.test.ts` +Expected: PASS with helpers and page state stabilized before broad UI verification. + +- [ ] **Step 5: Commit** + +```bash +git add front/src/lib/types.ts front/src/lib/api.ts front/src/lib/spaces.ts front/src/lib/spaces.test.ts front/src/lib/file-permissions.ts front/src/lib/file-permissions.test.ts front/src/App.tsx front/src/components/layout/Layout.tsx front/src/pages/files/FilesDirectoryRail.tsx front/src/pages/files/FilesToolbar.tsx front/src/pages/files/useFilesDirectoryState.ts front/src/pages/Spaces.tsx front/src/pages/SharedWithMe.tsx front/src/pages/files/SpaceMembersDialog.tsx front/src/pages/files/FilePermissionsDialog.tsx front/src/pages/spaces-state.ts front/src/pages/spaces-state.test.ts front/src/pages/shared-with-me-state.ts front/src/pages/shared-with-me-state.test.ts +git commit -m "feat(front): add spaces and shared-with-me ui" +``` + +### Task 8: Full Verification And Documentation Handoff + +**Files:** +- Modify: `docs/architecture.md` +- Modify: `docs/api-reference.md` +- Modify: `memory.md` +- Modify only if verification reveals gaps: `backend/**`, `front/**` + +- [ ] **Step 1: Run full backend verification** + +Run: `cd backend && mvn test` +Expected: PASS with no regressions in auth, files, tasks, shares, admin, and API v2 tests. + +- [ ] **Step 2: Run full frontend verification** + +Run: `cd front && npm run test` +Expected: PASS with no regressions in files, transfer, admin, and new spaces/shared state tests. + +- [ ] **Step 3: Run frontend type/lint verification** + +Run: `cd front && npm run lint` +Expected: PASS with no TypeScript errors. + +- [ ] **Step 4: Update project memory and architecture docs** + +Document: +- space model +- member roles +- ACL rules +- in-app sharing vs public share split +- admin audit log availability +- mobile deferred scope + +- [ ] **Step 5: Final commit** + +```bash +git add docs/architecture.md docs/api-reference.md memory.md +git commit -m "docs: record multi-user platform phase 2 architecture" +``` + +## Deferred Explicitly + +- 移动端 `MobileFiles` / `MobileOverview` / `MobileApp` 的空间协作 UI +- 组织/部门/用户组层级,不在本计划混入 +- WebDAV、OAuth scope、桌面同步客户端权限联动 +- 团队回收站、空间级生命周期策略、空间级存储策略切换 +- 文件评论、审批流、在线协作文档 + +## Success Criteria + +- 现有个人网盘用户登录后仍能像今天一样使用自己的私有文件空间。 +- 每个用户可看到自己的 personal space,且可创建 collaborative space。 +- collaborative space 支持最小成员角色与目录/文件 ACL。 +- 站内用户可通过 ACL 被共享文件,并在 “Shared With Me” 中看到结果。 +- 审计日志能覆盖空间、权限、分享、删除/恢复等关键动作。 +- 旧公开分享、快传、上传会话、后台任务和管理台文件列表不被打断。 + diff --git a/front/src/MobileApp.tsx b/front/src/MobileApp.tsx index 69785fe..d1ab4bb 100644 --- a/front/src/MobileApp.tsx +++ b/front/src/MobileApp.tsx @@ -11,7 +11,8 @@ import MobileOverview from './mobile-pages/MobileOverview'; import MobileFiles from './mobile-pages/MobileFiles'; import MobileTransfer from './mobile-pages/MobileTransfer'; import MobileFileShare from './mobile-pages/MobileFileShare'; -import RecycleBin from './pages/RecycleBin'; +import MobileRecycleBin from './mobile-pages/MobileRecycleBin'; +import MobileAdminUnavailable from './mobile-pages/MobileAdminUnavailable'; function LegacyTransferRedirect() { const location = useLocation(); @@ -54,16 +55,16 @@ function MobileAppRoutes() { } /> } /> } /> - } /> + } /> } /> } /> - {/* Admin dashboard is not mobile-optimized in this phase yet, redirect to overview or login */} + {/* Admin dashboard is not mobile-optimized in this phase yet, show stub page */} : } + element={isAuthenticated ? : } /> (null); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [activeModal, setActiveModal] = useState(null); const [avatarPreviewUrl, setAvatarPreviewUrl] = useState(null); const [selectedAvatarFile, setSelectedAvatarFile] = useState(null); @@ -192,7 +191,6 @@ export function Layout({ children }: LayoutProps = {}) { if (!currentSession) { return; } - saveStoredSession({ ...currentSession, user: nextProfile, @@ -326,113 +324,69 @@ export function Layout({ children }: LayoutProps = {}) { }; return ( -
-
-
-
-
-
- -
-
-
-
- Y -
-
- YOYUZH.XYZ - Personal Portal -
+
+
+ -
+
{children ?? }
@@ -460,80 +414,80 @@ export function Layout({ children }: LayoutProps = {}) {
-
- -
-
-

登录密码

-

密码修改后会刷新当前登录凭据并使旧 refresh token 失效

-
+
+ +
+
+

登录密码

+

密码修改后会刷新当前登录凭据并使旧 refresh token 失效

+
- setCurrentPassword(event.target.value)} - className="bg-black/20 border-white/10" - /> - setNewPassword(event.target.value)} - className="bg-black/20 border-white/10" - /> - setConfirmPassword(event.target.value)} - className="bg-black/20 border-white/10" - /> -
- -
+ setCurrentPassword(event.target.value)} + className="bg-black/20 border-white/10" + /> + setNewPassword(event.target.value)} + className="bg-black/20 border-white/10" + /> + setConfirmPassword(event.target.value)} + className="bg-black/20 border-white/10" + /> +
+ +
-
- -
-
-

手机绑定

-

当前手机号:{phoneNumber}

-
+
+ +
+
+

手机绑定

+

当前手机号:{phoneNumber}

+
-
- -
-
-

邮箱绑定

-

当前邮箱:{email}

-
+
+ +
+
+

邮箱绑定

+

当前邮箱:{email}

+
@@ -547,103 +501,124 @@ export function Layout({ children }: LayoutProps = {}) { {activeModal === 'settings' && (
- -
-

- - 账户设置 -

- -
-
-
-
-
- {displayedAvatarUrl ? Avatar : avatarFallback} -
-
- {selectedAvatarFile ? '等待保存' : '更换头像'} -
- -
-
-

{displayName}

-

{roleLabel}

-
-
+ +
+

+ + 账户设置 +

+ +
+
+
+
+
+ {displayedAvatarUrl ? Avatar : avatarFallback} +
+
+ {selectedAvatarFile ? '等待保存' : '更换头像'} +
+ +
+
+

{displayName}

+

{roleLabel}

+
+
-
-
- - handleProfileDraftChange('displayName', event.target.value)} - className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]" - /> -
+
+
+ + handleProfileDraftChange('displayName', event.target.value)} + className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]" + /> +
-
- - handleProfileDraftChange('email', event.target.value)} - className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]" - /> -
+
+ + handleProfileDraftChange('email', event.target.value)} + className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]" + /> +
-
- - handleProfileDraftChange('phoneNumber', event.target.value)} - className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]" - /> -
+
+ + handleProfileDraftChange('phoneNumber', event.target.value)} + className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]" + /> +
-
- -