From 06a95bc489a2f34ca152c6373805038dc8c9e5ac Mon Sep 17 00:00:00 2001 From: yoyuzh Date: Wed, 8 Apr 2026 15:22:52 +0800 Subject: [PATCH] feat(files): track v2 upload session parts --- .../files/MarkUploadSessionPartV2Request.java | 10 +++ .../v2/files/UploadSessionV2Controller.java | 17 +++++ .../files/UploadSessionPartCommand.java | 7 ++ .../yoyuzh/files/UploadSessionService.java | 76 +++++++++++++++++++ .../files/UploadSessionV2ControllerTest.java | 24 ++++++ .../files/UploadSessionServiceTest.java | 49 ++++++++++++ docs/api-reference.md | 1 + docs/architecture.md | 1 + memory.md | 1 + 9 files changed, 186 insertions(+) create mode 100644 backend/src/main/java/com/yoyuzh/api/v2/files/MarkUploadSessionPartV2Request.java create mode 100644 backend/src/main/java/com/yoyuzh/files/UploadSessionPartCommand.java diff --git a/backend/src/main/java/com/yoyuzh/api/v2/files/MarkUploadSessionPartV2Request.java b/backend/src/main/java/com/yoyuzh/api/v2/files/MarkUploadSessionPartV2Request.java new file mode 100644 index 0000000..8db37bb --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/api/v2/files/MarkUploadSessionPartV2Request.java @@ -0,0 +1,10 @@ +package com.yoyuzh.api.v2.files; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +public record MarkUploadSessionPartV2Request( + @NotBlank String etag, + @Min(0) long size +) { +} 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 5e019be..a2a9696 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.UploadSession; import com.yoyuzh.files.UploadSessionCreateCommand; +import com.yoyuzh.files.UploadSessionPartCommand; import com.yoyuzh.files.UploadSessionService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; 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.RestController; @@ -60,6 +62,21 @@ public class UploadSessionV2Controller { return ApiV2Response.success(toResponse(uploadSessionService.completeOwnedSession(user, sessionId))); } + @PutMapping("/{sessionId}/parts/{partIndex}") + public ApiV2Response recordPart(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable String sessionId, + @PathVariable int partIndex, + @Valid @RequestBody MarkUploadSessionPartV2Request request) { + User user = userDetailsService.loadDomainUser(userDetails.getUsername()); + UploadSession session = uploadSessionService.recordUploadedPart( + user, + sessionId, + partIndex, + new UploadSessionPartCommand(request.etag(), request.size()) + ); + return ApiV2Response.success(toResponse(session)); + } + private UploadSessionV2Response toResponse(UploadSession session) { return new UploadSessionV2Response( session.getSessionId(), diff --git a/backend/src/main/java/com/yoyuzh/files/UploadSessionPartCommand.java b/backend/src/main/java/com/yoyuzh/files/UploadSessionPartCommand.java new file mode 100644 index 0000000..51c4b73 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/UploadSessionPartCommand.java @@ -0,0 +1,7 @@ +package com.yoyuzh.files; + +public record UploadSessionPartCommand( + String etag, + long size +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/UploadSessionService.java b/backend/src/main/java/com/yoyuzh/files/UploadSessionService.java index 2e22aa8..9d93fce 100644 --- a/backend/src/main/java/com/yoyuzh/files/UploadSessionService.java +++ b/backend/src/main/java/com/yoyuzh/files/UploadSessionService.java @@ -1,5 +1,7 @@ package com.yoyuzh.files; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.yoyuzh.auth.User; import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.ErrorCode; @@ -11,6 +13,9 @@ import org.springframework.util.StringUtils; import java.time.Clock; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; import java.util.UUID; @Service @@ -18,10 +23,13 @@ public class UploadSessionService { private static final long DEFAULT_CHUNK_SIZE = 8L * 1024 * 1024; private static final long SESSION_TTL_HOURS = 24; + private static final TypeReference> UPLOADED_PARTS_TYPE = new TypeReference<>() { + }; private final UploadSessionRepository uploadSessionRepository; private final StoredFileRepository storedFileRepository; private final FileService fileService; + private final ObjectMapper objectMapper = new ObjectMapper(); private final long maxFileSize; private final Clock clock; @@ -87,6 +95,37 @@ public class UploadSessionService { return uploadSessionRepository.save(session); } + @Transactional + public UploadSession recordUploadedPart(User user, + String sessionId, + int partIndex, + UploadSessionPartCommand command) { + UploadSession session = getOwnedSession(user, sessionId); + LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone()); + ensureSessionCanReceivePart(session, now); + if (partIndex < 0 || partIndex >= session.getChunkCount()) { + throw new BusinessException(ErrorCode.UNKNOWN, "分片序号不合法"); + } + if (!StringUtils.hasText(command.etag())) { + throw new BusinessException(ErrorCode.UNKNOWN, "分片标识不能为空"); + } + if (command.size() < 0) { + throw new BusinessException(ErrorCode.UNKNOWN, "分片大小不合法"); + } + + List uploadedParts = new ArrayList<>(readUploadedParts(session)); + uploadedParts.removeIf(part -> part.partIndex() == partIndex); + uploadedParts.add(new UploadedPart(partIndex, command.etag(), command.size(), now.toString())); + uploadedParts.sort(Comparator.comparingInt(UploadedPart::partIndex)); + + session.setUploadedPartsJson(writeUploadedParts(uploadedParts)); + if (session.getStatus() == UploadSessionStatus.CREATED) { + session.setStatus(UploadSessionStatus.UPLOADING); + } + session.setUpdatedAt(now); + return uploadSessionRepository.save(session); + } + @Transactional public UploadSession completeOwnedSession(User user, String sessionId) { UploadSession session = getOwnedSession(user, sessionId); @@ -141,6 +180,43 @@ public class UploadSessionService { } } + private void ensureSessionCanReceivePart(UploadSession session, LocalDateTime now) { + if (session.getStatus() == UploadSessionStatus.CANCELLED + || session.getStatus() == UploadSessionStatus.FAILED + || session.getStatus() == UploadSessionStatus.COMPLETING + || session.getStatus() == UploadSessionStatus.COMPLETED) { + throw new BusinessException(ErrorCode.UNKNOWN, "上传会话不能继续上传分片"); + } + if (session.getExpiresAt().isBefore(now)) { + session.setStatus(UploadSessionStatus.EXPIRED); + session.setUpdatedAt(now); + uploadSessionRepository.save(session); + throw new BusinessException(ErrorCode.UNKNOWN, "上传会话已过期"); + } + } + + private List readUploadedParts(UploadSession session) { + if (!StringUtils.hasText(session.getUploadedPartsJson())) { + return List.of(); + } + try { + return objectMapper.readValue(session.getUploadedPartsJson(), UPLOADED_PARTS_TYPE); + } catch (Exception ex) { + throw new BusinessException(ErrorCode.UNKNOWN, "上传会话分片状态不合法"); + } + } + + private String writeUploadedParts(List uploadedParts) { + try { + return objectMapper.writeValueAsString(uploadedParts); + } catch (Exception ex) { + throw new BusinessException(ErrorCode.UNKNOWN, "上传会话分片状态写入失败"); + } + } + + private record UploadedPart(int partIndex, String etag, long size, String uploadedAt) { + } + private int calculateChunkCount(long size, long chunkSize) { if (size <= 0) { return 1; 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 aebc94f..59bf251 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 @@ -27,6 +27,7 @@ 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.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -103,6 +104,29 @@ class UploadSessionV2ControllerTest { .andExpect(jsonPath("$.data.status").value("COMPLETED")); } + @Test + void shouldRecordUploadSessionPartWithV2Envelope() throws Exception { + User user = createUser(7L); + UploadSession session = createSession(user); + session.setStatus(UploadSessionStatus.UPLOADING); + when(userDetailsService.loadDomainUser("alice")).thenReturn(user); + when(uploadSessionService.recordUploadedPart(eq(user), eq("session-1"), eq(1), any())).thenReturn(session); + + mockMvc.perform(put("/api/v2/files/upload-sessions/session-1/parts/1") + .with(user(userDetails())) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "etag": "etag-1", + "size": 8388608 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.sessionId").value("session-1")) + .andExpect(jsonPath("$.data.status").value("UPLOADING")); + } + 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 f67f75d..75b5bcf 100644 --- a/backend/src/test/java/com/yoyuzh/files/UploadSessionServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/files/UploadSessionServiceTest.java @@ -130,6 +130,55 @@ class UploadSessionServiceTest { .isInstanceOf(BusinessException.class); } + @Test + void shouldRecordUploadedPartAndMoveSessionToUploading() { + User user = createUser(7L); + UploadSession session = createSession(user); + session.setChunkCount(3); + when(uploadSessionRepository.findBySessionIdAndUserId("session-1", 7L)) + .thenReturn(Optional.of(session)); + when(uploadSessionRepository.save(any(UploadSession.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + UploadSession result = uploadSessionService.recordUploadedPart( + user, + "session-1", + 1, + new UploadSessionPartCommand("etag-1", 8L * 1024 * 1024) + ); + + assertThat(result.getStatus()).isEqualTo(UploadSessionStatus.UPLOADING); + assertThat(result.getUploadedPartsJson()).contains("\"partIndex\":1"); + assertThat(result.getUploadedPartsJson()).contains("\"etag\":\"etag-1\""); + assertThat(result.getUploadedPartsJson()).contains("\"size\":8388608"); + + UploadSession secondResult = uploadSessionService.recordUploadedPart( + user, + "session-1", + 2, + new UploadSessionPartCommand("etag-2", 4L) + ); + + assertThat(secondResult.getUploadedPartsJson()).contains("\"partIndex\":1"); + assertThat(secondResult.getUploadedPartsJson()).contains("\"partIndex\":2"); + assertThat(secondResult.getUploadedPartsJson()).contains("\"etag\":\"etag-2\""); + } + + @Test + void shouldRejectUploadedPartOutsideSessionRange() { + User user = createUser(7L); + UploadSession session = createSession(user); + session.setChunkCount(3); + when(uploadSessionRepository.findBySessionIdAndUserId("session-1", 7L)) + .thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> uploadSessionService.recordUploadedPart( + user, + "session-1", + 3, + new UploadSessionPartCommand("etag-3", 1L) + )).isInstanceOf(BusinessException.class); + } + private User createUser(Long id) { User user = new User(); user.setId(id); diff --git a/docs/api-reference.md b/docs/api-reference.md index 3a3410d..9ba390f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -443,3 +443,4 @@ - 下载、分享详情、回收站、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 会话响应;分片内容上传端点仍未开放。 +- 2026-04-08 阶段 3 第三小步 API 补充:新增 `PUT /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}`,请求体为 `{ "etag": "...", "size": 8388608 }`,用于记录当前用户上传会话的 part 元数据并返回 v2 会话响应。该接口会校验 part 范围和会话状态,当前只更新 `uploadedPartsJson`,不接收或合并真实文件分片内容。 diff --git a/docs/architecture.md b/docs/architecture.md index 34add8d..24846f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -447,3 +447,4 @@ Android 壳补充说明: - `portal_stored_file_entity.stored_file_id` 随 `portal_file` 删除级联清理;`portal_file_entity.created_by` 在用户删除时置空,避免实体审计关系阻塞用户清理。 - 2026-04-08 阶段 3 第一小步补充:后端新增上传会话二期最小骨架。`UploadSession` 记录用户、目标路径、文件名、对象键、分片大小、分片数量、状态、过期时间和已上传分片占位 JSON;`/api/v2/files/upload-sessions` 目前只提供创建、查询、取消会话,不承接实际分片内容上传,也不替换旧 `/api/files/upload/**` 生产链路。 - 2026-04-08 阶段 3 第二小步补充:上传会话新增完成状态机。`UploadSessionService.completeOwnedSession()` 会复用旧 `FileService.completeUpload()` 完成对象确认、目录补齐、配额/冲突校验和 `FileBlob + StoredFile + FileEntity.VERSION` 双写落库,然后把会话标记为 `COMPLETED`;失败时标记 `FAILED`,过期时标记 `EXPIRED`。当前仍没有独立 v2 分片内容写入端点。 +- 2026-04-08 阶段 3 第三小步补充:上传会话新增 part 状态记录。`UploadSessionService.recordUploadedPart()` 会校验会话归属、状态、过期时间和 part 范围,把 `etag/size/uploadedAt` 写入 `uploadedPartsJson`,并将新会话推进到 `UPLOADING`。当前实现是会话状态跟踪,不是跨存储驱动的分片内容写入/合并实现。 diff --git a/memory.md b/memory.md index 943adfc..4465294 100644 --- a/memory.md +++ b/memory.md @@ -161,3 +161,4 @@ - 为避免新关系表阻塞现有删除和测试清理,`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`;取消、失败、过期会话不能完成。实际分片内容上传和前端上传队列仍未切换。 +- 2026-04-08 阶段 3 第三小步:新增 `PUT /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}`,用于记录当前用户上传会话的 part 元数据到 `uploadedPartsJson`,并把会话状态从 `CREATED` 推进到 `UPLOADING`;该接口只记录 `etag/size` 等状态,不承担真正的对象存储分片内容写入或合并。