feat(files): add v2 upload session skeleton

This commit is contained in:
yoyuzh
2026-04-08 15:12:36 +08:00
parent 5802f396c5
commit 7ddef9bddb
17 changed files with 778 additions and 0 deletions

View File

@@ -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);

View File

@@ -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<ApiV2Response<Void>> handleBusinessException(BusinessException ex) {
ApiV2ErrorCode errorCode = mapBusinessErrorCode(ex.getErrorCode());
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(ApiV2Response.error(errorCode, ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiV2Response<Void>> 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;
};
}
}

View File

@@ -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
) {
}

View File

@@ -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<UploadSessionV2Response> 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<UploadSessionV2Response> 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<UploadSessionV2Response> 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()
);
}
}

View File

@@ -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
) {
}

View File

@@ -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/*")

View File

@@ -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;
}
}

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.files;
public record UploadSessionCreateCommand(
String path,
String filename,
String contentType,
long size
) {
}

View File

@@ -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<UploadSession, Long> {
Optional<UploadSession> findBySessionIdAndUserId(String sessionId, Long userId);
List<UploadSession> findByStatusInAndExpiresAtBefore(List<UploadSessionStatus> statuses, LocalDateTime expiresAt);
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.files;
public enum UploadSessionStatus {
CREATED,
UPLOADING,
COMPLETING,
COMPLETED,
CANCELLED,
EXPIRED,
FAILED
}