feat(files): add v2 upload session skeleton
This commit is contained in:
@@ -3,6 +3,9 @@ package com.yoyuzh.api.v2;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
public enum ApiV2ErrorCode {
|
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),
|
FILE_NOT_FOUND(2404, HttpStatus.NOT_FOUND),
|
||||||
INTERNAL_ERROR(2500, HttpStatus.INTERNAL_SERVER_ERROR);
|
INTERNAL_ERROR(2500, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.yoyuzh.api.v2;
|
package com.yoyuzh.api.v2;
|
||||||
|
|
||||||
|
import com.yoyuzh.common.BusinessException;
|
||||||
|
import com.yoyuzh.common.ErrorCode;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
@@ -15,10 +17,27 @@ public class ApiV2ExceptionHandler {
|
|||||||
.body(ApiV2Response.error(errorCode, ex.getMessage()));
|
.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)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ApiV2Response<Void>> handleUnknownException(Exception ex) {
|
public ResponseEntity<ApiV2Response<Void>> handleUnknownException(Exception ex) {
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(ApiV2ErrorCode.INTERNAL_ERROR.getHttpStatus())
|
.status(ApiV2ErrorCode.INTERNAL_ERROR.getHttpStatus())
|
||||||
.body(ApiV2Response.error(ApiV2ErrorCode.INTERNAL_ERROR, "服务器内部错误"));
|
.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;
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -54,6 +54,8 @@ public class SecurityConfig {
|
|||||||
.permitAll()
|
.permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v2/site/ping")
|
.requestMatchers(HttpMethod.GET, "/api/v2/site/ping")
|
||||||
.permitAll()
|
.permitAll()
|
||||||
|
.requestMatchers("/api/v2/files/**")
|
||||||
|
.authenticated()
|
||||||
.requestMatchers("/api/transfer/**")
|
.requestMatchers("/api/transfer/**")
|
||||||
.permitAll()
|
.permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/files/share-links/*")
|
.requestMatchers(HttpMethod.GET, "/api/files/share-links/*")
|
||||||
|
|||||||
218
backend/src/main/java/com/yoyuzh/files/UploadSession.java
Normal file
218
backend/src/main/java/com/yoyuzh/files/UploadSession.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.yoyuzh.files;
|
||||||
|
|
||||||
|
public record UploadSessionCreateCommand(
|
||||||
|
String path,
|
||||||
|
String filename,
|
||||||
|
String contentType,
|
||||||
|
long size
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
135
backend/src/main/java/com/yoyuzh/files/UploadSessionService.java
Normal file
135
backend/src/main/java/com/yoyuzh/files/UploadSessionService.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.yoyuzh.files;
|
||||||
|
|
||||||
|
public enum UploadSessionStatus {
|
||||||
|
CREATED,
|
||||||
|
UPLOADING,
|
||||||
|
COMPLETING,
|
||||||
|
COMPLETED,
|
||||||
|
CANCELLED,
|
||||||
|
EXPIRED,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.yoyuzh.api.v2;
|
package com.yoyuzh.api.v2;
|
||||||
|
|
||||||
|
import com.yoyuzh.common.BusinessException;
|
||||||
|
import com.yoyuzh.common.ErrorCode;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -33,4 +35,17 @@ class ApiV2ExceptionHandlerTest {
|
|||||||
assertThat(response.getBody().msg()).isEqualTo("服务器内部错误");
|
assertThat(response.getBody().msg()).isEqualTo("服务器内部错误");
|
||||||
assertThat(response.getBody().data()).isNull();
|
assertThat(response.getBody().data()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldMapLegacyBusinessExceptionToV2Envelope() {
|
||||||
|
ResponseEntity<ApiV2Response<Void>> 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -441,3 +441,4 @@
|
|||||||
- 本阶段不新增对外 API,`/api/files/**`、分享、回收站、快传导入等响应结构保持不变。
|
- 本阶段不新增对外 API,`/api/files/**`、分享、回收站、快传导入等响应结构保持不变。
|
||||||
- 后端在旧接口内部开始双写实体模型:上传完成、外部导入、分享导入和网盘复制会继续写 `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 分片上传/完成接口。
|
||||||
|
|||||||
@@ -445,3 +445,4 @@ Android 壳补充说明:
|
|||||||
- 文件写入路径已经从“只写 `FileBlob`”扩展为“继续写 `FileBlob`,同时写 `FileEntity.VERSION` 和 `StoredFileEntity(PRIMARY)`”。覆盖普通上传、直传完成、外部导入、分享导入和网盘复制。
|
- 文件写入路径已经从“只写 `FileBlob`”扩展为“继续写 `FileBlob`,同时写 `FileEntity.VERSION` 和 `StoredFileEntity(PRIMARY)`”。覆盖普通上传、直传完成、外部导入、分享导入和网盘复制。
|
||||||
- `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/**` 生产链路。
|
||||||
|
|||||||
@@ -159,3 +159,4 @@
|
|||||||
- 文件写入路径开始双写 `FileBlob + FileEntity.VERSION`:普通代理上传、直传完成、外部文件导入、分享导入,以及网盘复制复用 blob 时,都会给新 `StoredFile` 写入 `primaryEntity` 并创建 `StoredFileEntity(PRIMARY)` 关系。
|
- 文件写入路径开始双写 `FileBlob + FileEntity.VERSION`:普通代理上传、直传完成、外部文件导入、分享导入,以及网盘复制复用 blob 时,都会给新 `StoredFile` 写入 `primaryEntity` 并创建 `StoredFileEntity(PRIMARY)` 关系。
|
||||||
- 当前仍不切换读取路径:下载、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/**` 上传链路暂不切换,前端上传队列暂不改动。
|
||||||
|
|||||||
Reference in New Issue
Block a user