feat(files): complete v2 upload sessions
This commit is contained in:
@@ -53,6 +53,13 @@ public class UploadSessionV2Controller {
|
|||||||
return ApiV2Response.success(toResponse(uploadSessionService.cancelOwnedSession(user, sessionId)));
|
return ApiV2Response.success(toResponse(uploadSessionService.cancelOwnedSession(user, sessionId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{sessionId}/complete")
|
||||||
|
public ApiV2Response<UploadSessionV2Response> 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) {
|
private UploadSessionV2Response toResponse(UploadSession session) {
|
||||||
return new UploadSessionV2Response(
|
return new UploadSessionV2Response(
|
||||||
session.getSessionId(),
|
session.getSessionId(),
|
||||||
|
|||||||
@@ -21,22 +21,26 @@ public class UploadSessionService {
|
|||||||
|
|
||||||
private final UploadSessionRepository uploadSessionRepository;
|
private final UploadSessionRepository uploadSessionRepository;
|
||||||
private final StoredFileRepository storedFileRepository;
|
private final StoredFileRepository storedFileRepository;
|
||||||
|
private final FileService fileService;
|
||||||
private final long maxFileSize;
|
private final long maxFileSize;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public UploadSessionService(UploadSessionRepository uploadSessionRepository,
|
public UploadSessionService(UploadSessionRepository uploadSessionRepository,
|
||||||
StoredFileRepository storedFileRepository,
|
StoredFileRepository storedFileRepository,
|
||||||
|
FileService fileService,
|
||||||
FileStorageProperties properties) {
|
FileStorageProperties properties) {
|
||||||
this(uploadSessionRepository, storedFileRepository, properties, Clock.systemUTC());
|
this(uploadSessionRepository, storedFileRepository, fileService, properties, Clock.systemUTC());
|
||||||
}
|
}
|
||||||
|
|
||||||
UploadSessionService(UploadSessionRepository uploadSessionRepository,
|
UploadSessionService(UploadSessionRepository uploadSessionRepository,
|
||||||
StoredFileRepository storedFileRepository,
|
StoredFileRepository storedFileRepository,
|
||||||
|
FileService fileService,
|
||||||
FileStorageProperties properties,
|
FileStorageProperties properties,
|
||||||
Clock clock) {
|
Clock clock) {
|
||||||
this.uploadSessionRepository = uploadSessionRepository;
|
this.uploadSessionRepository = uploadSessionRepository;
|
||||||
this.storedFileRepository = storedFileRepository;
|
this.storedFileRepository = storedFileRepository;
|
||||||
|
this.fileService = fileService;
|
||||||
this.maxFileSize = properties.getMaxFileSize();
|
this.maxFileSize = properties.getMaxFileSize();
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
@@ -83,6 +87,46 @@ public class UploadSessionService {
|
|||||||
return uploadSessionRepository.save(session);
|
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) {
|
private void validateTarget(User user, String normalizedPath, String filename, long size) {
|
||||||
long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes());
|
long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes());
|
||||||
if (size > effectiveMaxUploadSize) {
|
if (size > effectiveMaxUploadSize) {
|
||||||
|
|||||||
@@ -87,6 +87,22 @@ class UploadSessionV2ControllerTest {
|
|||||||
.andExpect(jsonPath("$.data.status").value("CREATED"));
|
.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() {
|
private UserDetails userDetails() {
|
||||||
return org.springframework.security.core.userdetails.User
|
return org.springframework.security.core.userdetails.User
|
||||||
.withUsername("alice")
|
.withUsername("alice")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.yoyuzh.config.FileStorageProperties;
|
|||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
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.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class UploadSessionServiceTest {
|
class UploadSessionServiceTest {
|
||||||
@@ -27,6 +30,8 @@ class UploadSessionServiceTest {
|
|||||||
private UploadSessionRepository uploadSessionRepository;
|
private UploadSessionRepository uploadSessionRepository;
|
||||||
@Mock
|
@Mock
|
||||||
private StoredFileRepository storedFileRepository;
|
private StoredFileRepository storedFileRepository;
|
||||||
|
@Mock
|
||||||
|
private FileService fileService;
|
||||||
|
|
||||||
private UploadSessionService uploadSessionService;
|
private UploadSessionService uploadSessionService;
|
||||||
|
|
||||||
@@ -37,6 +42,7 @@ class UploadSessionServiceTest {
|
|||||||
uploadSessionService = new UploadSessionService(
|
uploadSessionService = new UploadSessionService(
|
||||||
uploadSessionRepository,
|
uploadSessionRepository,
|
||||||
storedFileRepository,
|
storedFileRepository,
|
||||||
|
fileService,
|
||||||
properties,
|
properties,
|
||||||
Clock.fixed(Instant.parse("2026-04-08T06:00:00Z"), ZoneOffset.UTC)
|
Clock.fixed(Instant.parse("2026-04-08T06:00:00Z"), ZoneOffset.UTC)
|
||||||
);
|
);
|
||||||
@@ -91,6 +97,39 @@ class UploadSessionServiceTest {
|
|||||||
)).isInstanceOf(BusinessException.class);
|
)).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<CompleteUploadRequest> 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) {
|
private User createUser(Long id) {
|
||||||
User user = new User();
|
User user = new User();
|
||||||
user.setId(id);
|
user.setId(id);
|
||||||
@@ -100,4 +139,23 @@ class UploadSessionServiceTest {
|
|||||||
user.setCreatedAt(LocalDateTime.now());
|
user.setCreatedAt(LocalDateTime.now());
|
||||||
return user;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -442,3 +442,4 @@
|
|||||||
- 后端在旧接口内部开始双写实体模型:上传完成、外部导入、分享导入和网盘复制会继续写 `FileBlob`,同时创建或复用 `FileEntity.VERSION`,并写入 `StoredFile.primaryEntity` 与 `StoredFileEntity(PRIMARY)`。
|
- 后端在旧接口内部开始双写实体模型:上传完成、外部导入、分享导入和网盘复制会继续写 `FileBlob`,同时创建或复用 `FileEntity.VERSION`,并写入 `StoredFile.primaryEntity` 与 `StoredFileEntity(PRIMARY)`。
|
||||||
- 下载、分享详情、回收站、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 会话响应;分片内容上传端点仍未开放。
|
||||||
|
|||||||
@@ -446,3 +446,4 @@ Android 壳补充说明:
|
|||||||
- `StoredFile.blob` 仍是当前生产读取路径;`StoredFile.primaryEntity` 与关系表暂时只作为兼容迁移数据,不影响旧 `/api/files/**` DTO 和前端调用。
|
- `StoredFile.blob` 仍是当前生产读取路径;`StoredFile.primaryEntity` 与关系表暂时只作为兼容迁移数据,不影响旧 `/api/files/**` DTO 和前端调用。
|
||||||
- `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 分片内容写入端点。
|
||||||
|
|||||||
@@ -160,3 +160,4 @@
|
|||||||
- 当前仍不切换读取路径:下载、ZIP、分享详情、回收站等旧业务继续依赖 `StoredFile.blob`,`primaryEntity` 只作为后续版本、缩略图、转码、存储策略迁移的兼容数据。
|
- 当前仍不切换读取路径:下载、ZIP、分享详情、回收站等旧业务继续依赖 `StoredFile.blob`,`primaryEntity` 只作为后续版本、缩略图、转码、存储策略迁移的兼容数据。
|
||||||
- 为避免新关系表阻塞现有删除和测试清理,`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`;取消、失败、过期会话不能完成。实际分片内容上传和前端上传队列仍未切换。
|
||||||
|
|||||||
Reference in New Issue
Block a user