From 7ddef9bddb6b2ea22c3c950a9bcd4c193a2ba912 Mon Sep 17 00:00:00 2001 From: yoyuzh Date: Wed, 8 Apr 2026 15:12:36 +0800 Subject: [PATCH] feat(files): add v2 upload session skeleton --- .../com/yoyuzh/api/v2/ApiV2ErrorCode.java | 3 + .../yoyuzh/api/v2/ApiV2ExceptionHandler.java | 19 ++ .../files/CreateUploadSessionV2Request.java | 12 + .../v2/files/UploadSessionV2Controller.java | 72 ++++++ .../api/v2/files/UploadSessionV2Response.java | 19 ++ .../com/yoyuzh/config/SecurityConfig.java | 2 + .../java/com/yoyuzh/files/UploadSession.java | 218 ++++++++++++++++++ .../files/UploadSessionCreateCommand.java | 9 + .../yoyuzh/files/UploadSessionRepository.java | 14 ++ .../yoyuzh/files/UploadSessionService.java | 135 +++++++++++ .../com/yoyuzh/files/UploadSessionStatus.java | 11 + .../api/v2/ApiV2ExceptionHandlerTest.java | 15 ++ .../files/UploadSessionV2ControllerTest.java | 143 ++++++++++++ .../files/UploadSessionServiceTest.java | 103 +++++++++ docs/api-reference.md | 1 + docs/architecture.md | 1 + memory.md | 1 + 17 files changed, 778 insertions(+) create mode 100644 backend/src/main/java/com/yoyuzh/api/v2/files/CreateUploadSessionV2Request.java create mode 100644 backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Controller.java create mode 100644 backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Response.java create mode 100644 backend/src/main/java/com/yoyuzh/files/UploadSession.java create mode 100644 backend/src/main/java/com/yoyuzh/files/UploadSessionCreateCommand.java create mode 100644 backend/src/main/java/com/yoyuzh/files/UploadSessionRepository.java create mode 100644 backend/src/main/java/com/yoyuzh/files/UploadSessionService.java create mode 100644 backend/src/main/java/com/yoyuzh/files/UploadSessionStatus.java create mode 100644 backend/src/test/java/com/yoyuzh/api/v2/files/UploadSessionV2ControllerTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/UploadSessionServiceTest.java diff --git a/backend/src/main/java/com/yoyuzh/api/v2/ApiV2ErrorCode.java b/backend/src/main/java/com/yoyuzh/api/v2/ApiV2ErrorCode.java index 657290e..2dc2410 100644 --- a/backend/src/main/java/com/yoyuzh/api/v2/ApiV2ErrorCode.java +++ b/backend/src/main/java/com/yoyuzh/api/v2/ApiV2ErrorCode.java @@ -3,6 +3,9 @@ package com.yoyuzh.api.v2; import org.springframework.http.HttpStatus; public enum ApiV2ErrorCode { + BAD_REQUEST(2400, HttpStatus.BAD_REQUEST), + NOT_LOGGED_IN(2401, HttpStatus.UNAUTHORIZED), + PERMISSION_DENIED(2403, HttpStatus.FORBIDDEN), FILE_NOT_FOUND(2404, HttpStatus.NOT_FOUND), INTERNAL_ERROR(2500, HttpStatus.INTERNAL_SERVER_ERROR); diff --git a/backend/src/main/java/com/yoyuzh/api/v2/ApiV2ExceptionHandler.java b/backend/src/main/java/com/yoyuzh/api/v2/ApiV2ExceptionHandler.java index 64f02b1..936c446 100644 --- a/backend/src/main/java/com/yoyuzh/api/v2/ApiV2ExceptionHandler.java +++ b/backend/src/main/java/com/yoyuzh/api/v2/ApiV2ExceptionHandler.java @@ -1,5 +1,7 @@ package com.yoyuzh.api.v2; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -15,10 +17,27 @@ public class ApiV2ExceptionHandler { .body(ApiV2Response.error(errorCode, ex.getMessage())); } + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusinessException(BusinessException ex) { + ApiV2ErrorCode errorCode = mapBusinessErrorCode(ex.getErrorCode()); + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(ApiV2Response.error(errorCode, ex.getMessage())); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleUnknownException(Exception ex) { return ResponseEntity .status(ApiV2ErrorCode.INTERNAL_ERROR.getHttpStatus()) .body(ApiV2Response.error(ApiV2ErrorCode.INTERNAL_ERROR, "服务器内部错误")); } + + private ApiV2ErrorCode mapBusinessErrorCode(ErrorCode errorCode) { + return switch (errorCode) { + case NOT_LOGGED_IN -> ApiV2ErrorCode.NOT_LOGGED_IN; + case PERMISSION_DENIED -> ApiV2ErrorCode.PERMISSION_DENIED; + case FILE_NOT_FOUND -> ApiV2ErrorCode.FILE_NOT_FOUND; + case UNKNOWN -> ApiV2ErrorCode.BAD_REQUEST; + }; + } } diff --git a/backend/src/main/java/com/yoyuzh/api/v2/files/CreateUploadSessionV2Request.java b/backend/src/main/java/com/yoyuzh/api/v2/files/CreateUploadSessionV2Request.java new file mode 100644 index 0000000..6b638d4 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/api/v2/files/CreateUploadSessionV2Request.java @@ -0,0 +1,12 @@ +package com.yoyuzh.api.v2.files; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +public record CreateUploadSessionV2Request( + @NotBlank String path, + @NotBlank String filename, + String contentType, + @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 new file mode 100644 index 0000000..d71b810 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Controller.java @@ -0,0 +1,72 @@ +package com.yoyuzh.api.v2.files; + +import com.yoyuzh.api.v2.ApiV2Response; +import com.yoyuzh.auth.CustomUserDetailsService; +import com.yoyuzh.auth.User; +import com.yoyuzh.files.UploadSession; +import com.yoyuzh.files.UploadSessionCreateCommand; +import com.yoyuzh.files.UploadSessionService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v2/files/upload-sessions") +@RequiredArgsConstructor +public class UploadSessionV2Controller { + + private final UploadSessionService uploadSessionService; + private final CustomUserDetailsService userDetailsService; + + @PostMapping + public ApiV2Response createSession(@AuthenticationPrincipal UserDetails userDetails, + @Valid @RequestBody CreateUploadSessionV2Request request) { + User user = userDetailsService.loadDomainUser(userDetails.getUsername()); + UploadSession session = uploadSessionService.createSession(user, new UploadSessionCreateCommand( + request.path(), + request.filename(), + request.contentType(), + request.size() + )); + return ApiV2Response.success(toResponse(session)); + } + + @GetMapping("/{sessionId}") + public ApiV2Response getSession(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable String sessionId) { + User user = userDetailsService.loadDomainUser(userDetails.getUsername()); + return ApiV2Response.success(toResponse(uploadSessionService.getOwnedSession(user, sessionId))); + } + + @DeleteMapping("/{sessionId}") + public ApiV2Response cancelSession(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable String sessionId) { + User user = userDetailsService.loadDomainUser(userDetails.getUsername()); + return ApiV2Response.success(toResponse(uploadSessionService.cancelOwnedSession(user, sessionId))); + } + + private UploadSessionV2Response toResponse(UploadSession session) { + return new UploadSessionV2Response( + session.getSessionId(), + session.getObjectKey(), + session.getTargetPath(), + session.getFilename(), + session.getContentType(), + session.getSize(), + session.getStatus().name(), + session.getChunkSize(), + session.getChunkCount(), + session.getExpiresAt(), + session.getCreatedAt(), + session.getUpdatedAt() + ); + } +} 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 new file mode 100644 index 0000000..da79b6b --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Response.java @@ -0,0 +1,19 @@ +package com.yoyuzh.api.v2.files; + +import java.time.LocalDateTime; + +public record UploadSessionV2Response( + String sessionId, + String objectKey, + String path, + String filename, + String contentType, + long size, + String status, + long chunkSize, + int chunkCount, + LocalDateTime expiresAt, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java b/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java index 5d36497..db7f96f 100644 --- a/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java +++ b/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java @@ -54,6 +54,8 @@ public class SecurityConfig { .permitAll() .requestMatchers(HttpMethod.GET, "/api/v2/site/ping") .permitAll() + .requestMatchers("/api/v2/files/**") + .authenticated() .requestMatchers("/api/transfer/**") .permitAll() .requestMatchers(HttpMethod.GET, "/api/files/share-links/*") diff --git a/backend/src/main/java/com/yoyuzh/files/UploadSession.java b/backend/src/main/java/com/yoyuzh/files/UploadSession.java new file mode 100644 index 0000000..55c0f40 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/UploadSession.java @@ -0,0 +1,218 @@ +package com.yoyuzh.files; + +import com.yoyuzh.auth.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "portal_upload_session", indexes = { + @Index(name = "uk_upload_session_session_id", columnList = "session_id", unique = true), + @Index(name = "idx_upload_session_user_status", columnList = "user_id,status"), + @Index(name = "idx_upload_session_expires_at", columnList = "expires_at") +}) +public class UploadSession { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "session_id", nullable = false, length = 64) + private String sessionId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User user; + + @Column(name = "target_path", nullable = false, length = 512) + private String targetPath; + + @Column(nullable = false, length = 255) + private String filename; + + @Column(name = "content_type", length = 255) + private String contentType; + + @Column(nullable = false) + private Long size; + + @Column(name = "object_key", nullable = false, length = 512) + private String objectKey; + + @Column(name = "chunk_size", nullable = false) + private Long chunkSize; + + @Column(name = "chunk_count", nullable = false) + private Integer chunkCount; + + @Column(name = "uploaded_parts_json", columnDefinition = "TEXT") + private String uploadedPartsJson; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private UploadSessionStatus status; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + public void prePersist() { + LocalDateTime now = LocalDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + if (status == null) { + status = UploadSessionStatus.CREATED; + } + } + + @PreUpdate + public void preUpdate() { + updatedAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getTargetPath() { + return targetPath; + } + + public void setTargetPath(String targetPath) { + this.targetPath = targetPath; + } + + public String getFilename() { + return filename; + } + + public void setFilename(String filename) { + this.filename = filename; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public Long getSize() { + return size; + } + + public void setSize(Long size) { + this.size = size; + } + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public Long getChunkSize() { + return chunkSize; + } + + public void setChunkSize(Long chunkSize) { + this.chunkSize = chunkSize; + } + + public Integer getChunkCount() { + return chunkCount; + } + + public void setChunkCount(Integer chunkCount) { + this.chunkCount = chunkCount; + } + + public String getUploadedPartsJson() { + return uploadedPartsJson; + } + + public void setUploadedPartsJson(String uploadedPartsJson) { + this.uploadedPartsJson = uploadedPartsJson; + } + + public UploadSessionStatus getStatus() { + return status; + } + + public void setStatus(UploadSessionStatus status) { + this.status = status; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/UploadSessionCreateCommand.java b/backend/src/main/java/com/yoyuzh/files/UploadSessionCreateCommand.java new file mode 100644 index 0000000..952d9fc --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/UploadSessionCreateCommand.java @@ -0,0 +1,9 @@ +package com.yoyuzh.files; + +public record UploadSessionCreateCommand( + String path, + String filename, + String contentType, + long size +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/UploadSessionRepository.java b/backend/src/main/java/com/yoyuzh/files/UploadSessionRepository.java new file mode 100644 index 0000000..2f29740 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/UploadSessionRepository.java @@ -0,0 +1,14 @@ +package com.yoyuzh.files; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface UploadSessionRepository extends JpaRepository { + + Optional findBySessionIdAndUserId(String sessionId, Long userId); + + List findByStatusInAndExpiresAtBefore(List statuses, LocalDateTime expiresAt); +} diff --git a/backend/src/main/java/com/yoyuzh/files/UploadSessionService.java b/backend/src/main/java/com/yoyuzh/files/UploadSessionService.java new file mode 100644 index 0000000..8c8f9e7 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/UploadSessionService.java @@ -0,0 +1,135 @@ +package com.yoyuzh.files; + +import com.yoyuzh.auth.User; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import com.yoyuzh.config.FileStorageProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +public class UploadSessionService { + + private static final long DEFAULT_CHUNK_SIZE = 8L * 1024 * 1024; + private static final long SESSION_TTL_HOURS = 24; + + private final UploadSessionRepository uploadSessionRepository; + private final StoredFileRepository storedFileRepository; + private final long maxFileSize; + private final Clock clock; + + @Autowired + public UploadSessionService(UploadSessionRepository uploadSessionRepository, + StoredFileRepository storedFileRepository, + FileStorageProperties properties) { + this(uploadSessionRepository, storedFileRepository, properties, Clock.systemUTC()); + } + + UploadSessionService(UploadSessionRepository uploadSessionRepository, + StoredFileRepository storedFileRepository, + FileStorageProperties properties, + Clock clock) { + this.uploadSessionRepository = uploadSessionRepository; + this.storedFileRepository = storedFileRepository; + this.maxFileSize = properties.getMaxFileSize(); + this.clock = clock; + } + + @Transactional + public UploadSession createSession(User user, UploadSessionCreateCommand command) { + String normalizedPath = normalizeDirectoryPath(command.path()); + String filename = normalizeLeafName(command.filename()); + validateTarget(user, normalizedPath, filename, command.size()); + + UploadSession session = new UploadSession(); + session.setSessionId(UUID.randomUUID().toString()); + session.setUser(user); + session.setTargetPath(normalizedPath); + session.setFilename(filename); + session.setContentType(command.contentType()); + session.setSize(command.size()); + session.setObjectKey(createBlobObjectKey()); + session.setChunkSize(DEFAULT_CHUNK_SIZE); + session.setChunkCount(calculateChunkCount(command.size(), DEFAULT_CHUNK_SIZE)); + 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)); + return uploadSessionRepository.save(session); + } + + @Transactional(readOnly = true) + public UploadSession getOwnedSession(User user, String sessionId) { + return uploadSessionRepository.findBySessionIdAndUserId(sessionId, user.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "上传会话不存在")); + } + + @Transactional + public UploadSession cancelOwnedSession(User user, String sessionId) { + UploadSession session = getOwnedSession(user, sessionId); + if (session.getStatus() == UploadSessionStatus.COMPLETED) { + throw new BusinessException(ErrorCode.UNKNOWN, "已完成的上传会话不能取消"); + } + session.setStatus(UploadSessionStatus.CANCELLED); + session.setUpdatedAt(LocalDateTime.ofInstant(clock.instant(), clock.getZone())); + return uploadSessionRepository.save(session); + } + + private void validateTarget(User user, String normalizedPath, String filename, long size) { + long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes()); + if (size > effectiveMaxUploadSize) { + throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制"); + } + if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedPath, filename)) { + throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在"); + } + long usedBytes = storedFileRepository.sumFileSizeByUserId(user.getId()); + if (user.getStorageQuotaBytes() >= 0 && usedBytes + size > user.getStorageQuotaBytes()) { + throw new BusinessException(ErrorCode.UNKNOWN, "存储空间不足"); + } + } + + private int calculateChunkCount(long size, long chunkSize) { + if (size <= 0) { + return 1; + } + return (int) Math.ceil((double) size / chunkSize); + } + + private String createBlobObjectKey() { + return "blobs/" + UUID.randomUUID(); + } + + private String normalizeDirectoryPath(String path) { + String cleaned = StringUtils.cleanPath(path == null ? "/" : path.trim().replace("\\", "/")); + if (!cleaned.startsWith("/")) { + cleaned = "/" + cleaned; + } + while (cleaned.length() > 1 && cleaned.endsWith("/")) { + cleaned = cleaned.substring(0, cleaned.length() - 1); + } + if (!StringUtils.hasText(cleaned) || cleaned.contains("..")) { + throw new BusinessException(ErrorCode.UNKNOWN, "路径不合法"); + } + return cleaned; + } + + private String normalizeLeafName(String filename) { + String cleaned = StringUtils.cleanPath(filename == null ? "" : filename).trim(); + if (!StringUtils.hasText(cleaned)) { + throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空"); + } + if (cleaned.contains("/") || cleaned.contains("\\") || cleaned.contains("..")) { + throw new BusinessException(ErrorCode.UNKNOWN, "文件名不合法"); + } + return cleaned; + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/UploadSessionStatus.java b/backend/src/main/java/com/yoyuzh/files/UploadSessionStatus.java new file mode 100644 index 0000000..98692f5 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/UploadSessionStatus.java @@ -0,0 +1,11 @@ +package com.yoyuzh.files; + +public enum UploadSessionStatus { + CREATED, + UPLOADING, + COMPLETING, + COMPLETED, + CANCELLED, + EXPIRED, + FAILED +} diff --git a/backend/src/test/java/com/yoyuzh/api/v2/ApiV2ExceptionHandlerTest.java b/backend/src/test/java/com/yoyuzh/api/v2/ApiV2ExceptionHandlerTest.java index 317a472..22dc6e1 100644 --- a/backend/src/test/java/com/yoyuzh/api/v2/ApiV2ExceptionHandlerTest.java +++ b/backend/src/test/java/com/yoyuzh/api/v2/ApiV2ExceptionHandlerTest.java @@ -1,5 +1,7 @@ package com.yoyuzh.api.v2; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -33,4 +35,17 @@ class ApiV2ExceptionHandlerTest { assertThat(response.getBody().msg()).isEqualTo("服务器内部错误"); assertThat(response.getBody().data()).isNull(); } + + @Test + void shouldMapLegacyBusinessExceptionToV2Envelope() { + ResponseEntity> response = handler.handleBusinessException( + new BusinessException(ErrorCode.UNKNOWN, "duplicate target") + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo(ApiV2ErrorCode.BAD_REQUEST.getCode()); + assertThat(response.getBody().msg()).isEqualTo("duplicate target"); + assertThat(response.getBody().data()).isNull(); + } } 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 new file mode 100644 index 0000000..5666f41 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/api/v2/files/UploadSessionV2ControllerTest.java @@ -0,0 +1,143 @@ +package com.yoyuzh.api.v2.files; + +import com.yoyuzh.auth.CustomUserDetailsService; +import com.yoyuzh.auth.User; +import com.yoyuzh.files.UploadSession; +import com.yoyuzh.files.UploadSessionService; +import com.yoyuzh.files.UploadSessionStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.time.LocalDateTime; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +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.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class UploadSessionV2ControllerTest { + + private UploadSessionService uploadSessionService; + private CustomUserDetailsService userDetailsService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + uploadSessionService = mock(UploadSessionService.class); + userDetailsService = mock(CustomUserDetailsService.class); + mockMvc = MockMvcBuilders.standaloneSetup( + new UploadSessionV2Controller(uploadSessionService, userDetailsService) + ).setCustomArgumentResolvers(authenticationPrincipalResolver()).build(); + } + + @Test + void shouldCreateUploadSessionWithV2Envelope() throws Exception { + User user = createUser(7L); + UploadSession session = createSession(user); + when(userDetailsService.loadDomainUser("alice")).thenReturn(user); + when(uploadSessionService.createSession(eq(user), any())).thenReturn(session); + + mockMvc.perform(post("/api/v2/files/upload-sessions") + .with(user(userDetails())) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "path": "/docs", + "filename": "movie.mp4", + "contentType": "video/mp4", + "size": 20971520 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.sessionId").value("session-1")) + .andExpect(jsonPath("$.data.objectKey").value("blobs/session-1")) + .andExpect(jsonPath("$.data.status").value("CREATED")) + .andExpect(jsonPath("$.data.chunkSize").value(8388608)) + .andExpect(jsonPath("$.data.chunkCount").value(3)); + } + + @Test + void shouldReturnOwnedUploadSessionWithV2Envelope() throws Exception { + User user = createUser(7L); + UploadSession session = createSession(user); + when(userDetailsService.loadDomainUser("alice")).thenReturn(user); + when(uploadSessionService.getOwnedSession(user, "session-1")).thenReturn(session); + + mockMvc.perform(get("/api/v2/files/upload-sessions/session-1") + .with(user(userDetails()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.sessionId").value("session-1")) + .andExpect(jsonPath("$.data.status").value("CREATED")); + } + + private UserDetails userDetails() { + return org.springframework.security.core.userdetails.User + .withUsername("alice") + .password("encoded") + .authorities("ROLE_USER") + .build(); + } + + private HandlerMethodArgumentResolver authenticationPrincipalResolver() { + UserDetails userDetails = userDetails(); + return new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthenticationPrincipal.class) + && UserDetails.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + return userDetails; + } + }; + } + + private User createUser(Long id) { + User user = new User(); + user.setId(id); + user.setUsername("alice"); + user.setEmail("alice@example.com"); + return user; + } + + private UploadSession createSession(User user) { + UploadSession session = new UploadSession(); + session.setId(100L); + session.setSessionId("session-1"); + session.setUser(user); + session.setTargetPath("/docs"); + session.setFilename("movie.mp4"); + session.setContentType("video/mp4"); + session.setSize(20L * 1024 * 1024); + session.setObjectKey("blobs/session-1"); + session.setChunkSize(8L * 1024 * 1024); + session.setChunkCount(3); + session.setStatus(UploadSessionStatus.CREATED); + session.setExpiresAt(LocalDateTime.of(2026, 4, 9, 6, 0)); + session.setCreatedAt(LocalDateTime.of(2026, 4, 8, 6, 0)); + session.setUpdatedAt(LocalDateTime.of(2026, 4, 8, 6, 0)); + return session; + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/UploadSessionServiceTest.java b/backend/src/test/java/com/yoyuzh/files/UploadSessionServiceTest.java new file mode 100644 index 0000000..49575b9 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/UploadSessionServiceTest.java @@ -0,0 +1,103 @@ +package com.yoyuzh.files; + +import com.yoyuzh.auth.User; +import com.yoyuzh.common.BusinessException; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +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.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UploadSessionServiceTest { + + @Mock + private UploadSessionRepository uploadSessionRepository; + @Mock + private StoredFileRepository storedFileRepository; + + private UploadSessionService uploadSessionService; + + @BeforeEach + void setUp() { + FileStorageProperties properties = new FileStorageProperties(); + properties.setMaxFileSize(500L * 1024 * 1024); + uploadSessionService = new UploadSessionService( + uploadSessionRepository, + storedFileRepository, + properties, + Clock.fixed(Instant.parse("2026-04-08T06:00:00Z"), ZoneOffset.UTC) + ); + } + + @Test + void shouldCreateUploadSessionWithoutChangingLegacyUploadPath() { + User user = createUser(7L); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.mp4")).thenReturn(false); + when(uploadSessionRepository.save(any(UploadSession.class))).thenAnswer(invocation -> { + UploadSession session = invocation.getArgument(0); + session.setId(100L); + return session; + }); + + UploadSession session = uploadSessionService.createSession( + user, + new UploadSessionCreateCommand("/docs", "movie.mp4", "video/mp4", 20L * 1024 * 1024) + ); + + assertThat(session.getSessionId()).isNotBlank(); + assertThat(session.getObjectKey()).startsWith("blobs/"); + assertThat(session.getStatus()).isEqualTo(UploadSessionStatus.CREATED); + assertThat(session.getChunkSize()).isEqualTo(8L * 1024 * 1024); + assertThat(session.getChunkCount()).isEqualTo(3); + assertThat(session.getExpiresAt()).isEqualTo(LocalDateTime.of(2026, 4, 9, 6, 0)); + } + + @Test + void shouldOnlyReturnSessionOwnedByCurrentUser() { + User user = createUser(7L); + UploadSession session = new UploadSession(); + session.setSessionId("session-1"); + session.setUser(user); + session.setStatus(UploadSessionStatus.CREATED); + when(uploadSessionRepository.findBySessionIdAndUserId("session-1", 7L)) + .thenReturn(Optional.of(session)); + + UploadSession result = uploadSessionService.getOwnedSession(user, "session-1"); + + assertThat(result).isSameAs(session); + } + + @Test + void shouldRejectDuplicateTargetWhenCreatingSession() { + User user = createUser(7L); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.mp4")).thenReturn(true); + + assertThatThrownBy(() -> uploadSessionService.createSession( + user, + new UploadSessionCreateCommand("/docs", "movie.mp4", "video/mp4", 20L) + )).isInstanceOf(BusinessException.class); + } + + private User createUser(Long id) { + User user = new User(); + user.setId(id); + user.setUsername("user-" + id); + user.setEmail("user-" + id + "@example.com"); + user.setPasswordHash("encoded"); + user.setCreatedAt(LocalDateTime.now()); + return user; + } +} diff --git a/docs/api-reference.md b/docs/api-reference.md index badeec9..023d772 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -441,3 +441,4 @@ - 本阶段不新增对外 API,`/api/files/**`、分享、回收站、快传导入等响应结构保持不变。 - 后端在旧接口内部开始双写实体模型:上传完成、外部导入、分享导入和网盘复制会继续写 `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 分片上传/完成接口。 diff --git a/docs/architecture.md b/docs/architecture.md index c3a5722..e3a3591 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -445,3 +445,4 @@ Android 壳补充说明: - 文件写入路径已经从“只写 `FileBlob`”扩展为“继续写 `FileBlob`,同时写 `FileEntity.VERSION` 和 `StoredFileEntity(PRIMARY)`”。覆盖普通上传、直传完成、外部导入、分享导入和网盘复制。 - `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/**` 生产链路。 diff --git a/memory.md b/memory.md index bb40c7d..2595028 100644 --- a/memory.md +++ b/memory.md @@ -159,3 +159,4 @@ - 文件写入路径开始双写 `FileBlob + FileEntity.VERSION`:普通代理上传、直传完成、外部文件导入、分享导入,以及网盘复制复用 blob 时,都会给新 `StoredFile` 写入 `primaryEntity` 并创建 `StoredFileEntity(PRIMARY)` 关系。 - 当前仍不切换读取路径:下载、ZIP、分享详情、回收站等旧业务继续依赖 `StoredFile.blob`,`primaryEntity` 只作为后续版本、缩略图、转码、存储策略迁移的兼容数据。 - 为避免新关系表阻塞现有删除和测试清理,`StoredFileEntity -> StoredFile` 使用数据库级删除级联;`FileEntity.createdBy` 删除用户时置空,保留物理实体审计数据但不阻塞用户清理。 +- 2026-04-08 阶段 3 第一小步:新增后端上传会话二期最小骨架,包含 `UploadSession`、`UploadSessionStatus`、`UploadSessionRepository`、`UploadSessionService`,以及受保护的 `/api/v2/files/upload-sessions` 创建、查询、取消接口;旧 `/api/files/upload/**` 上传链路暂不切换,前端上传队列暂不改动。