feat(files): track v2 upload session parts

This commit is contained in:
yoyuzh
2026-04-08 15:22:52 +08:00
parent 35b0691188
commit 06a95bc489
9 changed files with 186 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
package com.yoyuzh.api.v2.files;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public record MarkUploadSessionPartV2Request(
@NotBlank String etag,
@Min(0) long size
) {
}

View File

@@ -5,6 +5,7 @@ import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.files.UploadSession;
import com.yoyuzh.files.UploadSessionCreateCommand;
import com.yoyuzh.files.UploadSessionPartCommand;
import com.yoyuzh.files.UploadSessionService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -60,6 +62,21 @@ public class UploadSessionV2Controller {
return ApiV2Response.success(toResponse(uploadSessionService.completeOwnedSession(user, sessionId)));
}
@PutMapping("/{sessionId}/parts/{partIndex}")
public ApiV2Response<UploadSessionV2Response> recordPart(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId,
@PathVariable int partIndex,
@Valid @RequestBody MarkUploadSessionPartV2Request request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
UploadSession session = uploadSessionService.recordUploadedPart(
user,
sessionId,
partIndex,
new UploadSessionPartCommand(request.etag(), request.size())
);
return ApiV2Response.success(toResponse(session));
}
private UploadSessionV2Response toResponse(UploadSession session) {
return new UploadSessionV2Response(
session.getSessionId(),

View File

@@ -0,0 +1,7 @@
package com.yoyuzh.files;
public record UploadSessionPartCommand(
String etag,
long size
) {
}

View File

@@ -1,5 +1,7 @@
package com.yoyuzh.files;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
@@ -11,6 +13,9 @@ import org.springframework.util.StringUtils;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
@Service
@@ -18,10 +23,13 @@ public class UploadSessionService {
private static final long DEFAULT_CHUNK_SIZE = 8L * 1024 * 1024;
private static final long SESSION_TTL_HOURS = 24;
private static final TypeReference<List<UploadedPart>> UPLOADED_PARTS_TYPE = new TypeReference<>() {
};
private final UploadSessionRepository uploadSessionRepository;
private final StoredFileRepository storedFileRepository;
private final FileService fileService;
private final ObjectMapper objectMapper = new ObjectMapper();
private final long maxFileSize;
private final Clock clock;
@@ -87,6 +95,37 @@ public class UploadSessionService {
return uploadSessionRepository.save(session);
}
@Transactional
public UploadSession recordUploadedPart(User user,
String sessionId,
int partIndex,
UploadSessionPartCommand command) {
UploadSession session = getOwnedSession(user, sessionId);
LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone());
ensureSessionCanReceivePart(session, now);
if (partIndex < 0 || partIndex >= session.getChunkCount()) {
throw new BusinessException(ErrorCode.UNKNOWN, "分片序号不合法");
}
if (!StringUtils.hasText(command.etag())) {
throw new BusinessException(ErrorCode.UNKNOWN, "分片标识不能为空");
}
if (command.size() < 0) {
throw new BusinessException(ErrorCode.UNKNOWN, "分片大小不合法");
}
List<UploadedPart> uploadedParts = new ArrayList<>(readUploadedParts(session));
uploadedParts.removeIf(part -> part.partIndex() == partIndex);
uploadedParts.add(new UploadedPart(partIndex, command.etag(), command.size(), now.toString()));
uploadedParts.sort(Comparator.comparingInt(UploadedPart::partIndex));
session.setUploadedPartsJson(writeUploadedParts(uploadedParts));
if (session.getStatus() == UploadSessionStatus.CREATED) {
session.setStatus(UploadSessionStatus.UPLOADING);
}
session.setUpdatedAt(now);
return uploadSessionRepository.save(session);
}
@Transactional
public UploadSession completeOwnedSession(User user, String sessionId) {
UploadSession session = getOwnedSession(user, sessionId);
@@ -141,6 +180,43 @@ public class UploadSessionService {
}
}
private void ensureSessionCanReceivePart(UploadSession session, LocalDateTime now) {
if (session.getStatus() == UploadSessionStatus.CANCELLED
|| session.getStatus() == UploadSessionStatus.FAILED
|| session.getStatus() == UploadSessionStatus.COMPLETING
|| session.getStatus() == UploadSessionStatus.COMPLETED) {
throw new BusinessException(ErrorCode.UNKNOWN, "上传会话不能继续上传分片");
}
if (session.getExpiresAt().isBefore(now)) {
session.setStatus(UploadSessionStatus.EXPIRED);
session.setUpdatedAt(now);
uploadSessionRepository.save(session);
throw new BusinessException(ErrorCode.UNKNOWN, "上传会话已过期");
}
}
private List<UploadedPart> readUploadedParts(UploadSession session) {
if (!StringUtils.hasText(session.getUploadedPartsJson())) {
return List.of();
}
try {
return objectMapper.readValue(session.getUploadedPartsJson(), UPLOADED_PARTS_TYPE);
} catch (Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "上传会话分片状态不合法");
}
}
private String writeUploadedParts(List<UploadedPart> uploadedParts) {
try {
return objectMapper.writeValueAsString(uploadedParts);
} catch (Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "上传会话分片状态写入失败");
}
}
private record UploadedPart(int partIndex, String etag, long size, String uploadedAt) {
}
private int calculateChunkCount(long size, long chunkSize) {
if (size <= 0) {
return 1;