feat(files): track v2 upload session parts
This commit is contained in:
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.yoyuzh.auth.CustomUserDetailsService;
|
|||||||
import com.yoyuzh.auth.User;
|
import com.yoyuzh.auth.User;
|
||||||
import com.yoyuzh.files.UploadSession;
|
import com.yoyuzh.files.UploadSession;
|
||||||
import com.yoyuzh.files.UploadSessionCreateCommand;
|
import com.yoyuzh.files.UploadSessionCreateCommand;
|
||||||
|
import com.yoyuzh.files.UploadSessionPartCommand;
|
||||||
import com.yoyuzh.files.UploadSessionService;
|
import com.yoyuzh.files.UploadSessionService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
@@ -60,6 +62,21 @@ public class UploadSessionV2Controller {
|
|||||||
return ApiV2Response.success(toResponse(uploadSessionService.completeOwnedSession(user, sessionId)));
|
return ApiV2Response.success(toResponse(uploadSessionService.completeOwnedSession(user, sessionId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{sessionId}/parts/{partIndex}")
|
||||||
|
public ApiV2Response<UploadSessionV2Response> 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) {
|
private UploadSessionV2Response toResponse(UploadSession session) {
|
||||||
return new UploadSessionV2Response(
|
return new UploadSessionV2Response(
|
||||||
session.getSessionId(),
|
session.getSessionId(),
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.yoyuzh.files;
|
||||||
|
|
||||||
|
public record UploadSessionPartCommand(
|
||||||
|
String etag,
|
||||||
|
long size
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.yoyuzh.files;
|
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.auth.User;
|
||||||
import com.yoyuzh.common.BusinessException;
|
import com.yoyuzh.common.BusinessException;
|
||||||
import com.yoyuzh.common.ErrorCode;
|
import com.yoyuzh.common.ErrorCode;
|
||||||
@@ -11,6 +13,9 @@ import org.springframework.util.StringUtils;
|
|||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -18,10 +23,13 @@ public class UploadSessionService {
|
|||||||
|
|
||||||
private static final long DEFAULT_CHUNK_SIZE = 8L * 1024 * 1024;
|
private static final long DEFAULT_CHUNK_SIZE = 8L * 1024 * 1024;
|
||||||
private static final long SESSION_TTL_HOURS = 24;
|
private static final long SESSION_TTL_HOURS = 24;
|
||||||
|
private static final TypeReference<List<UploadedPart>> UPLOADED_PARTS_TYPE = new TypeReference<>() {
|
||||||
|
};
|
||||||
|
|
||||||
private final UploadSessionRepository uploadSessionRepository;
|
private final UploadSessionRepository uploadSessionRepository;
|
||||||
private final StoredFileRepository storedFileRepository;
|
private final StoredFileRepository storedFileRepository;
|
||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
private final long maxFileSize;
|
private final long maxFileSize;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
@@ -87,6 +95,37 @@ public class UploadSessionService {
|
|||||||
return uploadSessionRepository.save(session);
|
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<UploadedPart> 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
|
@Transactional
|
||||||
public UploadSession completeOwnedSession(User user, String sessionId) {
|
public UploadSession completeOwnedSession(User user, String sessionId) {
|
||||||
UploadSession session = getOwnedSession(user, 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<UploadedPart> 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<UploadedPart> 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) {
|
private int calculateChunkCount(long size, long chunkSize) {
|
||||||
if (size <= 0) {
|
if (size <= 0) {
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
@@ -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.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.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
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.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -103,6 +104,29 @@ class UploadSessionV2ControllerTest {
|
|||||||
.andExpect(jsonPath("$.data.status").value("COMPLETED"));
|
.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() {
|
private UserDetails userDetails() {
|
||||||
return org.springframework.security.core.userdetails.User
|
return org.springframework.security.core.userdetails.User
|
||||||
.withUsername("alice")
|
.withUsername("alice")
|
||||||
|
|||||||
@@ -130,6 +130,55 @@ class UploadSessionServiceTest {
|
|||||||
.isInstanceOf(BusinessException.class);
|
.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) {
|
private User createUser(Long id) {
|
||||||
User user = new User();
|
User user = new User();
|
||||||
user.setId(id);
|
user.setId(id);
|
||||||
|
|||||||
@@ -443,3 +443,4 @@
|
|||||||
- 下载、分享详情、回收站、ZIP 下载仍读取 `StoredFile.blob`;后续阶段稳定后再切换到 `primaryEntity` 读取。
|
- 下载、分享详情、回收站、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 补充:新增受保护的 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 补充:新增 `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`,不接收或合并真实文件分片内容。
|
||||||
|
|||||||
@@ -447,3 +447,4 @@ Android 壳补充说明:
|
|||||||
- `portal_stored_file_entity.stored_file_id` 随 `portal_file` 删除级联清理;`portal_file_entity.created_by` 在用户删除时置空,避免实体审计关系阻塞用户清理。
|
- `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 第一小步补充:后端新增上传会话二期最小骨架。`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 第二小步补充:上传会话新增完成状态机。`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`。当前实现是会话状态跟踪,不是跨存储驱动的分片内容写入/合并实现。
|
||||||
|
|||||||
@@ -161,3 +161,4 @@
|
|||||||
- 为避免新关系表阻塞现有删除和测试清理,`StoredFileEntity -> StoredFile` 使用数据库级删除级联;`FileEntity.createdBy` 删除用户时置空,保留物理实体审计数据但不阻塞用户清理。
|
- 为避免新关系表阻塞现有删除和测试清理,`StoredFileEntity -> StoredFile` 使用数据库级删除级联;`FileEntity.createdBy` 删除用户时置空,保留物理实体审计数据但不阻塞用户清理。
|
||||||
- 2026-04-08 阶段 3 第一小步:新增后端上传会话二期最小骨架,包含 `UploadSession`、`UploadSessionStatus`、`UploadSessionRepository`、`UploadSessionService`,以及受保护的 `/api/v2/files/upload-sessions` 创建、查询、取消接口;旧 `/api/files/upload/**` 上传链路暂不切换,前端上传队列暂不改动。
|
- 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 第二小步:新增 `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` 等状态,不承担真正的对象存储分片内容写入或合并。
|
||||||
|
|||||||
Reference in New Issue
Block a user