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 d71b810..5e019be 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 @@ -53,6 +53,13 @@ public class UploadSessionV2Controller { return ApiV2Response.success(toResponse(uploadSessionService.cancelOwnedSession(user, sessionId))); } + @PostMapping("/{sessionId}/complete") + public ApiV2Response completeSession(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable String sessionId) { + User user = userDetailsService.loadDomainUser(userDetails.getUsername()); + return ApiV2Response.success(toResponse(uploadSessionService.completeOwnedSession(user, sessionId))); + } + private UploadSessionV2Response toResponse(UploadSession session) { return new UploadSessionV2Response( session.getSessionId(), diff --git a/backend/src/main/java/com/yoyuzh/files/UploadSessionService.java b/backend/src/main/java/com/yoyuzh/files/UploadSessionService.java index 8c8f9e7..2e22aa8 100644 --- a/backend/src/main/java/com/yoyuzh/files/UploadSessionService.java +++ b/backend/src/main/java/com/yoyuzh/files/UploadSessionService.java @@ -21,22 +21,26 @@ public class UploadSessionService { private final UploadSessionRepository uploadSessionRepository; private final StoredFileRepository storedFileRepository; + private final FileService fileService; private final long maxFileSize; private final Clock clock; @Autowired public UploadSessionService(UploadSessionRepository uploadSessionRepository, StoredFileRepository storedFileRepository, + FileService fileService, FileStorageProperties properties) { - this(uploadSessionRepository, storedFileRepository, properties, Clock.systemUTC()); + this(uploadSessionRepository, storedFileRepository, fileService, properties, Clock.systemUTC()); } UploadSessionService(UploadSessionRepository uploadSessionRepository, StoredFileRepository storedFileRepository, + FileService fileService, FileStorageProperties properties, Clock clock) { this.uploadSessionRepository = uploadSessionRepository; this.storedFileRepository = storedFileRepository; + this.fileService = fileService; this.maxFileSize = properties.getMaxFileSize(); this.clock = clock; } @@ -83,6 +87,46 @@ public class UploadSessionService { return uploadSessionRepository.save(session); } + @Transactional + public UploadSession completeOwnedSession(User user, String sessionId) { + UploadSession session = getOwnedSession(user, sessionId); + if (session.getStatus() == UploadSessionStatus.COMPLETED) { + return session; + } + if (session.getStatus() == UploadSessionStatus.CANCELLED || session.getStatus() == UploadSessionStatus.FAILED) { + throw new BusinessException(ErrorCode.UNKNOWN, "上传会话不能完成"); + } + LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone()); + if (session.getExpiresAt().isBefore(now)) { + session.setStatus(UploadSessionStatus.EXPIRED); + session.setUpdatedAt(now); + uploadSessionRepository.save(session); + throw new BusinessException(ErrorCode.UNKNOWN, "上传会话已过期"); + } + + session.setStatus(UploadSessionStatus.COMPLETING); + session.setUpdatedAt(now); + uploadSessionRepository.save(session); + + try { + fileService.completeUpload(user, new CompleteUploadRequest( + session.getTargetPath(), + session.getFilename(), + session.getObjectKey(), + session.getContentType(), + session.getSize() + )); + session.setStatus(UploadSessionStatus.COMPLETED); + session.setUpdatedAt(now); + return uploadSessionRepository.save(session); + } catch (RuntimeException ex) { + session.setStatus(UploadSessionStatus.FAILED); + session.setUpdatedAt(now); + uploadSessionRepository.save(session); + throw ex; + } + } + private void validateTarget(User user, String normalizedPath, String filename, long size) { long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes()); if (size > effectiveMaxUploadSize) { 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 5666f41..aebc94f 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 @@ -87,6 +87,22 @@ class UploadSessionV2ControllerTest { .andExpect(jsonPath("$.data.status").value("CREATED")); } + @Test + void shouldCompleteUploadSessionWithV2Envelope() throws Exception { + User user = createUser(7L); + UploadSession session = createSession(user); + session.setStatus(UploadSessionStatus.COMPLETED); + when(userDetailsService.loadDomainUser("alice")).thenReturn(user); + when(uploadSessionService.completeOwnedSession(user, "session-1")).thenReturn(session); + + mockMvc.perform(post("/api/v2/files/upload-sessions/session-1/complete") + .with(user(userDetails()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.sessionId").value("session-1")) + .andExpect(jsonPath("$.data.status").value("COMPLETED")); + } + private UserDetails userDetails() { return org.springframework.security.core.userdetails.User .withUsername("alice") diff --git a/backend/src/test/java/com/yoyuzh/files/UploadSessionServiceTest.java b/backend/src/test/java/com/yoyuzh/files/UploadSessionServiceTest.java index 49575b9..f67f75d 100644 --- a/backend/src/test/java/com/yoyuzh/files/UploadSessionServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/files/UploadSessionServiceTest.java @@ -6,6 +6,7 @@ import com.yoyuzh.config.FileStorageProperties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -18,7 +19,9 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class UploadSessionServiceTest { @@ -27,6 +30,8 @@ class UploadSessionServiceTest { private UploadSessionRepository uploadSessionRepository; @Mock private StoredFileRepository storedFileRepository; + @Mock + private FileService fileService; private UploadSessionService uploadSessionService; @@ -37,6 +42,7 @@ class UploadSessionServiceTest { uploadSessionService = new UploadSessionService( uploadSessionRepository, storedFileRepository, + fileService, properties, Clock.fixed(Instant.parse("2026-04-08T06:00:00Z"), ZoneOffset.UTC) ); @@ -91,6 +97,39 @@ class UploadSessionServiceTest { )).isInstanceOf(BusinessException.class); } + @Test + void shouldCompleteOwnedSessionThroughLegacyFileCommitPath() { + User user = createUser(7L); + UploadSession session = createSession(user); + when(uploadSessionRepository.findBySessionIdAndUserId("session-1", 7L)) + .thenReturn(Optional.of(session)); + when(uploadSessionRepository.save(any(UploadSession.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + UploadSession result = uploadSessionService.completeOwnedSession(user, "session-1"); + + assertThat(result.getStatus()).isEqualTo(UploadSessionStatus.COMPLETED); + assertThat(result.getUpdatedAt()).isEqualTo(LocalDateTime.of(2026, 4, 8, 6, 0)); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CompleteUploadRequest.class); + verify(fileService).completeUpload(eq(user), requestCaptor.capture()); + assertThat(requestCaptor.getValue().path()).isEqualTo("/docs"); + assertThat(requestCaptor.getValue().filename()).isEqualTo("movie.mp4"); + assertThat(requestCaptor.getValue().storageName()).isEqualTo("blobs/session-1"); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("video/mp4"); + assertThat(requestCaptor.getValue().size()).isEqualTo(20L); + } + + @Test + void shouldRejectCompletingCancelledSession() { + User user = createUser(7L); + UploadSession session = createSession(user); + session.setStatus(UploadSessionStatus.CANCELLED); + when(uploadSessionRepository.findBySessionIdAndUserId("session-1", 7L)) + .thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> uploadSessionService.completeOwnedSession(user, "session-1")) + .isInstanceOf(BusinessException.class); + } + private User createUser(Long id) { User user = new User(); user.setId(id); @@ -100,4 +139,23 @@ class UploadSessionServiceTest { user.setCreatedAt(LocalDateTime.now()); return user; } + + private UploadSession createSession(User user) { + UploadSession session = new UploadSession(); + session.setSessionId("session-1"); + session.setUser(user); + session.setTargetPath("/docs"); + session.setFilename("movie.mp4"); + session.setContentType("video/mp4"); + session.setSize(20L); + session.setObjectKey("blobs/session-1"); + session.setChunkSize(8L * 1024 * 1024); + session.setChunkCount(1); + session.setUploadedPartsJson("[]"); + session.setStatus(UploadSessionStatus.CREATED); + session.setCreatedAt(LocalDateTime.of(2026, 4, 8, 6, 0)); + session.setUpdatedAt(LocalDateTime.of(2026, 4, 8, 6, 0)); + session.setExpiresAt(LocalDateTime.of(2026, 4, 9, 6, 0)); + return session; + } } diff --git a/docs/api-reference.md b/docs/api-reference.md index 023d772..3a3410d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -442,3 +442,4 @@ - 后端在旧接口内部开始双写实体模型:上传完成、外部导入、分享导入和网盘复制会继续写 `FileBlob`,同时创建或复用 `FileEntity.VERSION`,并写入 `StoredFile.primaryEntity` 与 `StoredFileEntity(PRIMARY)`。 - 下载、分享详情、回收站、ZIP 下载仍读取 `StoredFile.blob`;后续阶段稳定后再切换到 `primaryEntity` 读取。 - 2026-04-08 阶段 3 第一小步 API 补充:新增受保护的 v2 上传会话骨架接口,`POST /api/v2/files/upload-sessions` 创建会话,`GET /api/v2/files/upload-sessions/{sessionId}` 查询当前用户自己的会话,`DELETE /api/v2/files/upload-sessions/{sessionId}` 取消会话。当前响应只返回 `sessionId`、`objectKey`、路径、文件名、状态、分片大小、分片数量和时间字段;实际文件内容仍走旧上传链路,尚未开放 v2 分片上传/完成接口。 +- 2026-04-08 阶段 3 第二小步 API 补充:新增 `POST /api/v2/files/upload-sessions/{sessionId}/complete`,用于把当前用户自己的上传会话提交完成。该接口当前不接收请求体,会复用会话里的 `objectKey/path/filename/contentType/size` 调用旧上传完成落库链路,成功后返回 `COMPLETED` 状态的 v2 会话响应;分片内容上传端点仍未开放。 diff --git a/docs/architecture.md b/docs/architecture.md index e3a3591..34add8d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -446,3 +446,4 @@ Android 壳补充说明: - `StoredFile.blob` 仍是当前生产读取路径;`StoredFile.primaryEntity` 与关系表暂时只作为兼容迁移数据,不影响旧 `/api/files/**` DTO 和前端调用。 - `portal_stored_file_entity.stored_file_id` 随 `portal_file` 删除级联清理;`portal_file_entity.created_by` 在用户删除时置空,避免实体审计关系阻塞用户清理。 - 2026-04-08 阶段 3 第一小步补充:后端新增上传会话二期最小骨架。`UploadSession` 记录用户、目标路径、文件名、对象键、分片大小、分片数量、状态、过期时间和已上传分片占位 JSON;`/api/v2/files/upload-sessions` 目前只提供创建、查询、取消会话,不承接实际分片内容上传,也不替换旧 `/api/files/upload/**` 生产链路。 +- 2026-04-08 阶段 3 第二小步补充:上传会话新增完成状态机。`UploadSessionService.completeOwnedSession()` 会复用旧 `FileService.completeUpload()` 完成对象确认、目录补齐、配额/冲突校验和 `FileBlob + StoredFile + FileEntity.VERSION` 双写落库,然后把会话标记为 `COMPLETED`;失败时标记 `FAILED`,过期时标记 `EXPIRED`。当前仍没有独立 v2 分片内容写入端点。 diff --git a/memory.md b/memory.md index 2595028..943adfc 100644 --- a/memory.md +++ b/memory.md @@ -160,3 +160,4 @@ - 当前仍不切换读取路径:下载、ZIP、分享详情、回收站等旧业务继续依赖 `StoredFile.blob`,`primaryEntity` 只作为后续版本、缩略图、转码、存储策略迁移的兼容数据。 - 为避免新关系表阻塞现有删除和测试清理,`StoredFileEntity -> StoredFile` 使用数据库级删除级联;`FileEntity.createdBy` 删除用户时置空,保留物理实体审计数据但不阻塞用户清理。 - 2026-04-08 阶段 3 第一小步:新增后端上传会话二期最小骨架,包含 `UploadSession`、`UploadSessionStatus`、`UploadSessionRepository`、`UploadSessionService`,以及受保护的 `/api/v2/files/upload-sessions` 创建、查询、取消接口;旧 `/api/files/upload/**` 上传链路暂不切换,前端上传队列暂不改动。 +- 2026-04-08 阶段 3 第二小步:新增 `POST /api/v2/files/upload-sessions/{sessionId}/complete`,v2 上传会话可从 `CREATED` 进入 `COMPLETING` 并复用旧 `FileService.completeUpload()` 完成 `FileBlob + StoredFile + FileEntity.VERSION` 落库,成功后标记 `COMPLETED`;取消、失败、过期会话不能完成。实际分片内容上传和前端上传队列仍未切换。