添加快传7天离线传

This commit is contained in:
yoyuzh
2026-03-24 09:12:10 +08:00
parent e004e64009
commit b9ab1a7640
32 changed files with 1927 additions and 81 deletions

View File

@@ -0,0 +1,13 @@
package com.yoyuzh.config;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ApiRootController {
@GetMapping("/")
public String redirectToSwaggerUi() {
return "redirect:/swagger-ui.html";
}
}

View File

@@ -10,7 +10,9 @@ public class CorsProperties {
private List<String> allowedOrigins = new ArrayList<>(List.of(
"http://localhost:3000",
"http://127.0.0.1:3000"
"http://127.0.0.1:3000",
"https://yoyuzh.xyz",
"https://www.yoyuzh.xyz"
));
public List<String> getAllowedOrigins() {

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
@Configuration
@EnableScheduling
public class SchedulingConfiguration {
}

View File

@@ -397,31 +397,47 @@ public class FileService {
throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持导入");
}
String normalizedPath = normalizeDirectoryPath(path);
String filename = normalizeLeafName(sourceFile.getFilename());
validateUpload(recipient.getId(), normalizedPath, filename, sourceFile.getSize());
ensureDirectoryHierarchy(recipient, normalizedPath);
byte[] content = fileContentStorage.readFile(
sourceFile.getUser().getId(),
sourceFile.getPath(),
sourceFile.getStorageName()
);
return importExternalFile(
recipient,
path,
sourceFile.getFilename(),
sourceFile.getContentType(),
sourceFile.getSize(),
content
);
}
@Transactional
public FileMetadataResponse importExternalFile(User recipient,
String path,
String filename,
String contentType,
long size,
byte[] content) {
String normalizedPath = normalizeDirectoryPath(path);
String normalizedFilename = normalizeLeafName(filename);
validateUpload(recipient.getId(), normalizedPath, normalizedFilename, size);
ensureDirectoryHierarchy(recipient, normalizedPath);
fileContentStorage.storeImportedFile(
recipient.getId(),
normalizedPath,
filename,
sourceFile.getContentType(),
normalizedFilename,
contentType,
content
);
return saveFileMetadata(
recipient,
normalizedPath,
filename,
filename,
sourceFile.getContentType(),
sourceFile.getSize()
normalizedFilename,
normalizedFilename,
contentType,
size
);
}

View File

@@ -2,10 +2,13 @@ package com.yoyuzh.transfer;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
public record CreateTransferSessionRequest(
@NotNull(message = "传输模式不能为空")
TransferMode mode,
@NotEmpty(message = "至少选择一个文件")
List<@Valid TransferFileItem> files
) {

View File

@@ -5,6 +5,7 @@ import java.time.Instant;
public record LookupTransferSessionResponse(
String sessionId,
String pickupCode,
TransferMode mode,
Instant expiresAt
) {
}

View File

@@ -0,0 +1,110 @@
package com.yoyuzh.transfer;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(
name = "portal_offline_transfer_file",
indexes = {
@Index(name = "idx_offline_transfer_file_session", columnList = "session_id")
}
)
public class OfflineTransferFile {
@Id
@Column(name = "id", nullable = false, length = 64)
private String id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "session_id", nullable = false)
private OfflineTransferSession session;
@Column(name = "filename", nullable = false, length = 255)
private String filename;
@Column(name = "relative_path", nullable = false, length = 512)
private String relativePath;
@Column(name = "size", nullable = false)
private long size;
@Column(name = "content_type", length = 255)
private String contentType;
@Column(name = "storage_name", nullable = false, length = 255)
private String storageName;
@Column(name = "uploaded", nullable = false)
private boolean uploaded;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public OfflineTransferSession getSession() {
return session;
}
public void setSession(OfflineTransferSession session) {
this.session = session;
}
public String getFilename() {
return filename;
}
public void setFilename(String filename) {
this.filename = filename;
}
public String getRelativePath() {
return relativePath;
}
public void setRelativePath(String relativePath) {
this.relativePath = relativePath;
}
public long getSize() {
return size;
}
public void setSize(long size) {
this.size = size;
}
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
public String getStorageName() {
return storageName;
}
public void setStorageName(String storageName) {
this.storageName = storageName;
}
public boolean isUploaded() {
return uploaded;
}
public void setUploaded(boolean uploaded) {
this.uploaded = uploaded;
}
}

View File

@@ -0,0 +1,96 @@
package com.yoyuzh.transfer;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(
name = "portal_offline_transfer_session",
indexes = {
@Index(name = "idx_offline_transfer_expires_at", columnList = "expires_at")
}
)
public class OfflineTransferSession {
@Id
@Column(name = "session_id", nullable = false, length = 64)
private String sessionId;
@Column(name = "pickup_code", nullable = false, unique = true, length = 6)
private String pickupCode;
@Column(name = "sender_user_id", nullable = false)
private Long senderUserId;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "ready", nullable = false)
private boolean ready;
@OneToMany(mappedBy = "session", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private final List<OfflineTransferFile> files = new ArrayList<>();
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public String getPickupCode() {
return pickupCode;
}
public void setPickupCode(String pickupCode) {
this.pickupCode = pickupCode;
}
public Long getSenderUserId() {
return senderUserId;
}
public void setSenderUserId(Long senderUserId) {
this.senderUserId = senderUserId;
}
public Instant getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Instant expiresAt) {
this.expiresAt = expiresAt;
}
public boolean isReady() {
return ready;
}
public void setReady(boolean ready) {
this.ready = ready;
}
public List<OfflineTransferFile> getFiles() {
return files;
}
public void addFile(OfflineTransferFile file) {
files.add(file);
file.setSession(this);
}
public boolean isExpired(Instant now) {
return expiresAt.isBefore(now);
}
}

View File

@@ -0,0 +1,38 @@
package com.yoyuzh.transfer;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
public interface OfflineTransferSessionRepository extends JpaRepository<OfflineTransferSession, String> {
boolean existsByPickupCode(String pickupCode);
@Query("""
select distinct session
from OfflineTransferSession session
left join fetch session.files
where session.sessionId = :sessionId
""")
Optional<OfflineTransferSession> findWithFilesBySessionId(@Param("sessionId") String sessionId);
@Query("""
select distinct session
from OfflineTransferSession session
left join fetch session.files
where session.pickupCode = :pickupCode
""")
Optional<OfflineTransferSession> findWithFilesByPickupCode(@Param("pickupCode") String pickupCode);
@Query("""
select distinct session
from OfflineTransferSession session
left join fetch session.files
where session.expiresAt < :now
""")
List<OfflineTransferSession> findAllExpiredWithFiles(@Param("now") Instant now);
}

View File

@@ -1,21 +1,27 @@
package com.yoyuzh.transfer;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.ApiResponse;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.files.FileMetadataResponse;
import com.yoyuzh.files.ImportSharedFileRequest;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
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.RequestPart;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/transfer")
@@ -30,8 +36,8 @@ public class TransferController {
public ApiResponse<TransferSessionResponse> createSession(@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody CreateTransferSessionRequest request) {
requireAuthenticatedUser(userDetails);
userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiResponse.success(transferService.createSession(request));
User sender = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiResponse.success(transferService.createSession(sender, request));
}
@Operation(summary = "通过取件码查找快传会话")
@@ -46,6 +52,44 @@ public class TransferController {
return ApiResponse.success(transferService.joinSession(sessionId));
}
@Operation(summary = "上传离线快传文件")
@PostMapping("/sessions/{sessionId}/files/{fileId}/content")
public ApiResponse<Void> uploadOfflineFile(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId,
@PathVariable String fileId,
@RequestPart("file") MultipartFile file) {
requireAuthenticatedUser(userDetails);
transferService.uploadOfflineFile(
userDetailsService.loadDomainUser(userDetails.getUsername()),
sessionId,
fileId,
file
);
return ApiResponse.success();
}
@Operation(summary = "下载离线快传文件")
@GetMapping("/sessions/{sessionId}/files/{fileId}/download")
public ResponseEntity<?> downloadOfflineFile(@PathVariable String sessionId,
@PathVariable String fileId) {
return transferService.downloadOfflineFile(sessionId, fileId);
}
@Operation(summary = "把离线快传文件存入网盘")
@PostMapping("/sessions/{sessionId}/files/{fileId}/import")
public ApiResponse<FileMetadataResponse> importOfflineFile(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId,
@PathVariable String fileId,
@Valid @RequestBody ImportSharedFileRequest request) {
requireAuthenticatedUser(userDetails);
return ApiResponse.success(transferService.importOfflineFile(
userDetailsService.loadDomainUser(userDetails.getUsername()),
sessionId,
fileId,
request.path()
));
}
@Operation(summary = "提交快传信令")
@PostMapping("/sessions/{sessionId}/signals")
public ApiResponse<Void> postSignal(@PathVariable String sessionId,

View File

@@ -4,10 +4,16 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public record TransferFileItem(
String id,
@NotBlank(message = "文件名不能为空")
String name,
String relativePath,
@Min(value = 0, message = "文件大小不能为负数")
long size,
String contentType
String contentType,
Boolean uploaded
) {
public TransferFileItem(String name, long size, String contentType) {
this(null, name, name, size, contentType, null);
}
}

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.transfer;
public enum TransferMode {
ONLINE,
OFFLINE
}

View File

@@ -1,34 +1,198 @@
package com.yoyuzh.transfer;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.config.FileStorageProperties;
import com.yoyuzh.files.FileMetadataResponse;
import com.yoyuzh.files.FileService;
import com.yoyuzh.files.storage.FileContentStorage;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
@Service
public class TransferService {
private static final Duration SESSION_TTL = Duration.ofMinutes(15);
private static final Duration ONLINE_SESSION_TTL = Duration.ofMinutes(15);
private static final Duration OFFLINE_SESSION_TTL = Duration.ofDays(7);
private final TransferSessionStore sessionStore;
private final OfflineTransferSessionRepository offlineTransferSessionRepository;
private final FileContentStorage fileContentStorage;
private final FileService fileService;
private final long maxFileSize;
public TransferService(TransferSessionStore sessionStore) {
public TransferService(TransferSessionStore sessionStore,
OfflineTransferSessionRepository offlineTransferSessionRepository,
FileContentStorage fileContentStorage,
FileService fileService,
FileStorageProperties properties) {
this.sessionStore = sessionStore;
this.offlineTransferSessionRepository = offlineTransferSessionRepository;
this.fileContentStorage = fileContentStorage;
this.fileService = fileService;
this.maxFileSize = properties.getMaxFileSize();
}
public TransferSessionResponse createSession(CreateTransferSessionRequest request) {
@Transactional
public TransferSessionResponse createSession(User sender, CreateTransferSessionRequest request) {
pruneExpiredSessions();
if (request.mode() == TransferMode.OFFLINE) {
return createOfflineSession(sender, request);
}
return createOnlineSession(request);
}
public LookupTransferSessionResponse lookupSession(String pickupCode) {
pruneExpiredSessions();
String normalizedPickupCode = normalizePickupCode(pickupCode);
TransferSession onlineSession = sessionStore.findByPickupCode(normalizedPickupCode).orElse(null);
if (onlineSession != null) {
return onlineSession.toLookupResponse();
}
OfflineTransferSession offlineSession = getRequiredOfflineReadySessionByPickupCode(normalizedPickupCode);
return toLookupResponse(offlineSession);
}
public TransferSessionResponse joinSession(String sessionId) {
pruneExpiredSessions();
TransferSession onlineSession = sessionStore.findById(sessionId).orElse(null);
if (onlineSession != null) {
try {
onlineSession.markReceiverJoined();
} catch (IllegalStateException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "在线快传不能被多次接收,请让发送方重新发起");
}
return onlineSession.toSessionResponse();
}
OfflineTransferSession offlineSession = getRequiredOfflineReadySession(sessionId);
return toSessionResponse(offlineSession);
}
@Transactional
public void uploadOfflineFile(User sender, String sessionId, String fileId, MultipartFile multipartFile) {
pruneExpiredSessions();
OfflineTransferSession session = getRequiredOfflineEditableSession(sender, sessionId);
OfflineTransferFile targetFile = getRequiredOfflineFile(session, fileId);
if (multipartFile.getSize() <= 0) {
throw new BusinessException(ErrorCode.UNKNOWN, "离线文件不能为空");
}
if (multipartFile.getSize() > maxFileSize) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制");
}
if (multipartFile.getSize() != targetFile.getSize()) {
throw new BusinessException(ErrorCode.UNKNOWN, "离线文件大小与会话清单不一致");
}
try {
fileContentStorage.storeTransferFile(
session.getSessionId(),
targetFile.getStorageName(),
normalizeContentType(targetFile.getContentType()),
multipartFile.getBytes()
);
} catch (java.io.IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "离线文件上传失败");
}
targetFile.setUploaded(true);
session.setReady(session.getFiles().stream().allMatch(OfflineTransferFile::isUploaded));
offlineTransferSessionRepository.save(session);
}
public void postSignal(String sessionId, String role, TransferSignalRequest request) {
pruneExpiredSessions();
TransferSession session = sessionStore.findById(sessionId).orElse(null);
if (session == null) {
if (offlineTransferSessionRepository.findWithFilesBySessionId(sessionId).isPresent()) {
throw new BusinessException(ErrorCode.UNKNOWN, "离线快传无需建立在线连接");
}
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "快传会话不存在或已失效");
}
session.enqueue(TransferRole.from(role), request.type().trim(), request.payload().trim());
}
public PollTransferSignalsResponse pollSignals(String sessionId, String role, long after) {
pruneExpiredSessions();
TransferSession session = sessionStore.findById(sessionId).orElse(null);
if (session == null) {
if (offlineTransferSessionRepository.findWithFilesBySessionId(sessionId).isPresent()) {
throw new BusinessException(ErrorCode.UNKNOWN, "离线快传无需轮询信令");
}
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "快传会话不存在或已失效");
}
return session.poll(TransferRole.from(role), Math.max(0, after));
}
public ResponseEntity<?> downloadOfflineFile(String sessionId, String fileId) {
pruneExpiredSessions();
OfflineTransferSession session = getRequiredOfflineReadySession(sessionId);
OfflineTransferFile file = getRequiredOfflineFile(session, fileId);
ensureOfflineFileUploaded(file);
if (fileContentStorage.supportsDirectDownload()) {
String downloadUrl = fileContentStorage.createTransferDownloadUrl(sessionId, file.getStorageName(), file.getFilename());
return ResponseEntity.status(302).location(URI.create(downloadUrl)).build();
}
byte[] content = fileContentStorage.readTransferFile(sessionId, file.getStorageName());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + URLEncoder.encode(file.getFilename(), StandardCharsets.UTF_8))
.contentType(MediaType.parseMediaType(normalizeContentType(file.getContentType())))
.body(content);
}
@Transactional
public FileMetadataResponse importOfflineFile(User recipient, String sessionId, String fileId, String path) {
pruneExpiredSessions();
OfflineTransferSession session = getRequiredOfflineReadySession(sessionId);
OfflineTransferFile file = getRequiredOfflineFile(session, fileId);
ensureOfflineFileUploaded(file);
byte[] content = fileContentStorage.readTransferFile(sessionId, file.getStorageName());
return fileService.importExternalFile(
recipient,
path,
file.getFilename(),
normalizeContentType(file.getContentType()),
file.getSize(),
content
);
}
@Scheduled(fixedDelay = 60 * 60 * 1000L)
@Transactional
public void pruneExpiredTransfers() {
pruneExpiredSessions();
}
private TransferSessionResponse createOnlineSession(CreateTransferSessionRequest request) {
String sessionId = UUID.randomUUID().toString();
String pickupCode = sessionStore.nextPickupCode();
Instant expiresAt = Instant.now().plus(SESSION_TTL);
String pickupCode = nextPickupCode();
Instant expiresAt = Instant.now().plus(ONLINE_SESSION_TTL);
List<TransferFileItem> files = request.files().stream()
.map(file -> new TransferFileItem(file.name(), file.size(), normalizeContentType(file.contentType())))
.map(this::normalizeOnlineFileItem)
.toList();
TransferSession session = new TransferSession(sessionId, pickupCode, expiresAt, files);
@@ -36,46 +200,150 @@ public class TransferService {
return session.toSessionResponse();
}
public LookupTransferSessionResponse lookupSession(String pickupCode) {
pruneExpiredSessions();
String normalizedPickupCode = normalizePickupCode(pickupCode);
TransferSession session = sessionStore.findByPickupCode(normalizedPickupCode)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效"));
return session.toLookupResponse();
private TransferSessionResponse createOfflineSession(User sender, CreateTransferSessionRequest request) {
OfflineTransferSession session = new OfflineTransferSession();
session.setSessionId(UUID.randomUUID().toString());
session.setPickupCode(nextPickupCode());
session.setSenderUserId(sender.getId());
session.setExpiresAt(Instant.now().plus(OFFLINE_SESSION_TTL));
session.setReady(false);
for (TransferFileItem requestFile : request.files()) {
OfflineTransferFile file = new OfflineTransferFile();
String normalizedFilename = normalizeLeafName(requestFile.name());
String normalizedRelativePath = normalizeRelativePath(requestFile.relativePath(), normalizedFilename);
String fileId = UUID.randomUUID().toString();
file.setId(fileId);
file.setFilename(normalizedFilename);
file.setRelativePath(normalizedRelativePath);
file.setSize(requestFile.size());
file.setContentType(normalizeContentType(requestFile.contentType()));
file.setStorageName(buildTransferStorageName(fileId, normalizedFilename));
file.setUploaded(false);
session.addFile(file);
}
return toSessionResponse(offlineTransferSessionRepository.save(session));
}
public TransferSessionResponse joinSession(String sessionId) {
pruneExpiredSessions();
TransferSession session = getRequiredSession(sessionId);
session.markReceiverJoined();
return session.toSessionResponse();
private TransferFileItem normalizeOnlineFileItem(TransferFileItem file) {
String normalizedFilename = normalizeLeafName(file.name());
String normalizedRelativePath = normalizeRelativePath(file.relativePath(), normalizedFilename);
return new TransferFileItem(
null,
normalizedFilename,
normalizedRelativePath,
file.size(),
normalizeContentType(file.contentType()),
null
);
}
public void postSignal(String sessionId, String role, TransferSignalRequest request) {
pruneExpiredSessions();
TransferSession session = getRequiredSession(sessionId);
session.enqueue(TransferRole.from(role), request.type().trim(), request.payload().trim());
private TransferSessionResponse toSessionResponse(OfflineTransferSession session) {
return new TransferSessionResponse(
session.getSessionId(),
session.getPickupCode(),
TransferMode.OFFLINE,
session.getExpiresAt(),
session.getFiles().stream().map(this::toFileItem).toList()
);
}
public PollTransferSignalsResponse pollSignals(String sessionId, String role, long after) {
pruneExpiredSessions();
TransferSession session = getRequiredSession(sessionId);
return session.poll(TransferRole.from(role), Math.max(0, after));
private LookupTransferSessionResponse toLookupResponse(OfflineTransferSession session) {
return new LookupTransferSessionResponse(
session.getSessionId(),
session.getPickupCode(),
TransferMode.OFFLINE,
session.getExpiresAt()
);
}
private TransferSession getRequiredSession(String sessionId) {
TransferSession session = sessionStore.findById(sessionId).orElse(null);
if (session == null || session.isExpired(Instant.now())) {
if (session != null) {
sessionStore.remove(session);
private TransferFileItem toFileItem(OfflineTransferFile file) {
return new TransferFileItem(
file.getId(),
file.getFilename(),
file.getRelativePath(),
file.getSize(),
normalizeContentType(file.getContentType()),
file.isUploaded()
);
}
private void pruneExpiredSessions() {
Instant now = Instant.now();
sessionStore.pruneExpired(now);
List<OfflineTransferSession> expiredSessions = offlineTransferSessionRepository.findAllExpiredWithFiles(now);
if (expiredSessions.isEmpty()) {
return;
}
for (OfflineTransferSession session : expiredSessions) {
for (OfflineTransferFile file : session.getFiles()) {
if (file.isUploaded()) {
fileContentStorage.deleteTransferFile(session.getSessionId(), file.getStorageName());
}
}
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "快传会话不存在或已失效");
}
offlineTransferSessionRepository.deleteAll(expiredSessions);
}
private OfflineTransferSession getRequiredOfflineEditableSession(User sender, String sessionId) {
OfflineTransferSession session = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线快传会话不存在或已失效"));
if (!Objects.equals(session.getSenderUserId(), sender.getId())) {
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "没有权限上传该离线快传文件");
}
if (session.isExpired(Instant.now())) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线快传会话不存在或已失效");
}
return session;
}
private void pruneExpiredSessions() {
sessionStore.pruneExpired(Instant.now());
private OfflineTransferSession getRequiredOfflineReadySession(String sessionId) {
OfflineTransferSession session = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线快传会话不存在或已失效"));
if (session.isExpired(Instant.now())) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线快传会话不存在或已失效");
}
if (!session.isReady()) {
throw new BusinessException(ErrorCode.UNKNOWN, "离线快传仍在上传中,请稍后再试");
}
return session;
}
private OfflineTransferSession getRequiredOfflineReadySessionByPickupCode(String pickupCode) {
OfflineTransferSession session = offlineTransferSessionRepository.findWithFilesByPickupCode(pickupCode)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效"));
if (session.isExpired(Instant.now())) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效");
}
if (!session.isReady()) {
throw new BusinessException(ErrorCode.UNKNOWN, "离线快传仍在上传中,请稍后再试");
}
return session;
}
private OfflineTransferFile getRequiredOfflineFile(OfflineTransferSession session, String fileId) {
return session.getFiles().stream()
.filter(file -> file.getId().equals(fileId))
.findFirst()
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线文件不存在"));
}
private void ensureOfflineFileUploaded(OfflineTransferFile file) {
if (!file.isUploaded()) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线文件不存在");
}
}
private String nextPickupCode() {
String pickupCode;
do {
pickupCode = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000));
} while (sessionStore.findByPickupCode(pickupCode).isPresent()
|| offlineTransferSessionRepository.existsByPickupCode(pickupCode));
return pickupCode;
}
private String normalizePickupCode(String pickupCode) {
@@ -90,4 +358,45 @@ public class TransferService {
String normalized = Objects.requireNonNullElse(contentType, "").trim();
return normalized.isEmpty() ? "application/octet-stream" : normalized;
}
private String normalizeLeafName(String value) {
String normalized = Objects.requireNonNullElse(value, "").trim();
if (normalized.isEmpty()) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空");
}
if (normalized.contains("/") || normalized.contains("\\") || ".".equals(normalized) || "..".equals(normalized)) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件名不合法");
}
return normalized;
}
private String normalizeRelativePath(String relativePath, String fallbackFilename) {
String rawPath = Objects.requireNonNullElse(relativePath, fallbackFilename).replace('\\', '/');
List<String> segments = new ArrayList<>();
for (String segment : rawPath.split("/")) {
String trimmed = segment.trim();
if (trimmed.isEmpty()) {
continue;
}
if (".".equals(trimmed) || "..".equals(trimmed)) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件路径不合法");
}
segments.add(trimmed);
}
String normalizedFilename = normalizeLeafName(fallbackFilename);
if (segments.isEmpty()) {
return normalizedFilename;
}
List<String> normalizedSegments = new ArrayList<>(segments.subList(0, Math.max(0, segments.size() - 1)));
normalizedSegments.add(normalizedFilename);
return String.join("/", normalizedSegments);
}
private String buildTransferStorageName(String fileId, String filename) {
int extensionIndex = filename.lastIndexOf('.');
String extension = extensionIndex > 0 ? filename.substring(extensionIndex) : "";
return fileId + extension;
}
}

View File

@@ -24,16 +24,16 @@ final class TransferSession {
}
synchronized TransferSessionResponse toSessionResponse() {
return new TransferSessionResponse(sessionId, pickupCode, expiresAt, files);
return new TransferSessionResponse(sessionId, pickupCode, TransferMode.ONLINE, expiresAt, files);
}
synchronized LookupTransferSessionResponse toLookupResponse() {
return new LookupTransferSessionResponse(sessionId, pickupCode, expiresAt);
return new LookupTransferSessionResponse(sessionId, pickupCode, TransferMode.ONLINE, expiresAt);
}
synchronized void markReceiverJoined() {
if (receiverJoined) {
return;
throw new IllegalStateException("在线快传仅支持一次接收");
}
receiverJoined = true;

View File

@@ -6,6 +6,7 @@ import java.util.List;
public record TransferSessionResponse(
String sessionId,
String pickupCode,
TransferMode mode,
Instant expiresAt,
List<TransferFileItem> files
) {

View File

@@ -38,6 +38,8 @@ app:
allowed-origins:
- http://localhost:3000
- http://127.0.0.1:3000
- https://yoyuzh.xyz
- https://www.yoyuzh.xyz
springdoc:
swagger-ui:

View File

@@ -0,0 +1,37 @@
package com.yoyuzh.config;
import com.yoyuzh.PortalBackendApplication;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(
classes = PortalBackendApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:api_root_test;MODE=MySQL;DB_CLOSE_DELAY=-1",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.jpa.hibernate.ddl-auto=create-drop",
"app.jwt.secret=0123456789abcdef0123456789abcdef"
}
)
@AutoConfigureMockMvc
class ApiRootControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldRedirectRootPathToSwaggerUi() throws Exception {
mockMvc.perform(get("/"))
.andExpect(status().isFound())
.andExpect(redirectedUrl("/swagger-ui.html"));
}
}

View File

@@ -9,6 +9,14 @@ import static org.assertj.core.api.Assertions.assertThat;
class SecurityConfigTest {
@Test
void corsPropertiesShouldAllowProductionSiteOriginsByDefault() {
CorsProperties corsProperties = new CorsProperties();
assertThat(corsProperties.getAllowedOrigins())
.contains("https://yoyuzh.xyz", "https://www.yoyuzh.xyz");
}
@Test
void corsConfigurationShouldAllowPatchRequests() {
CorsProperties corsProperties = new CorsProperties();

View File

@@ -9,14 +9,20 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
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.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(
@@ -60,8 +66,9 @@ class TransferControllerIntegrationTest {
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"mode": "ONLINE",
"files": [
{"name": "report.pdf", "size": 2048, "contentType": "application/pdf"}
{"name": "report.pdf", "relativePath": "课程资料/report.pdf", "size": 2048, "contentType": "application/pdf"}
]
}
"""))
@@ -69,7 +76,9 @@ class TransferControllerIntegrationTest {
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.sessionId").isNotEmpty())
.andExpect(jsonPath("$.data.pickupCode").isString())
.andExpect(jsonPath("$.data.mode").value("ONLINE"))
.andExpect(jsonPath("$.data.files[0].name").value("report.pdf"))
.andExpect(jsonPath("$.data.files[0].relativePath").value("课程资料/report.pdf"))
.andReturn()
.getResponse()
.getContentAsString();
@@ -80,11 +89,13 @@ class TransferControllerIntegrationTest {
mockMvc.perform(get("/api/transfer/sessions/lookup").param("pickupCode", pickupCode))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.sessionId").value(sessionId))
.andExpect(jsonPath("$.data.pickupCode").value(pickupCode));
.andExpect(jsonPath("$.data.pickupCode").value(pickupCode))
.andExpect(jsonPath("$.data.mode").value("ONLINE"));
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", sessionId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.sessionId").value(sessionId))
.andExpect(jsonPath("$.data.mode").value("ONLINE"))
.andExpect(jsonPath("$.data.files[0].name").value("report.pdf"));
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/signals", sessionId)
@@ -113,11 +124,71 @@ class TransferControllerIntegrationTest {
mockMvc.perform(post("/api/transfer/sessions")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"files":[{"name":"demo.txt","size":12,"contentType":"text/plain"}]}
{"mode":"ONLINE","files":[{"name":"demo.txt","relativePath":"demo.txt","size":12,"contentType":"text/plain"}]}
"""))
.andExpect(status().isUnauthorized());
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", "missing-session"))
.andExpect(status().isNotFound());
}
@Test
@WithMockUser(username = "alice")
void shouldPersistOfflineTransfersForSevenDaysAndAllowRepeatedDownloads() throws Exception {
String response = mockMvc.perform(post("/api/transfer/sessions")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"mode": "OFFLINE",
"files": [
{"name": "offline.txt", "relativePath": "资料/offline.txt", "size": 13, "contentType": "text/plain"}
]
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.mode").value("OFFLINE"))
.andExpect(jsonPath("$.data.files[0].id").isString())
.andExpect(jsonPath("$.data.files[0].relativePath").value("资料/offline.txt"))
.andReturn()
.getResponse()
.getContentAsString();
String sessionId = com.jayway.jsonpath.JsonPath.read(response, "$.data.sessionId");
String pickupCode = com.jayway.jsonpath.JsonPath.read(response, "$.data.pickupCode");
String fileId = com.jayway.jsonpath.JsonPath.read(response, "$.data.files[0].id");
String expiresAtRaw = com.jayway.jsonpath.JsonPath.read(response, "$.data.expiresAt");
Instant expiresAt = Instant.parse(expiresAtRaw);
assertThat(expiresAt).isAfter(Instant.now().plusSeconds(6 * 24 * 60 * 60L));
MockMultipartFile offlineFile = new MockMultipartFile(
"file",
"offline.txt",
MediaType.TEXT_PLAIN_VALUE,
"hello offline".getBytes(StandardCharsets.UTF_8)
);
mockMvc.perform(multipart("/api/transfer/sessions/{sessionId}/files/{fileId}/content", sessionId, fileId)
.file(offlineFile))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
mockMvc.perform(get("/api/transfer/sessions/lookup").param("pickupCode", pickupCode))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.sessionId").value(sessionId))
.andExpect(jsonPath("$.data.mode").value("OFFLINE"));
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", sessionId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.mode").value("OFFLINE"))
.andExpect(jsonPath("$.data.files[0].name").value("offline.txt"));
mockMvc.perform(get("/api/transfer/sessions/{sessionId}/files/{fileId}/download", sessionId, fileId))
.andExpect(status().isOk())
.andExpect(content().bytes("hello offline".getBytes(StandardCharsets.UTF_8)));
mockMvc.perform(get("/api/transfer/sessions/{sessionId}/files/{fileId}/download", sessionId, fileId))
.andExpect(status().isOk())
.andExpect(content().bytes("hello offline".getBytes(StandardCharsets.UTF_8)));
}
}

View File

@@ -5,12 +5,13 @@ import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
class TransferSessionTest {
@Test
void shouldEmitPeerJoinedOnlyOnceWhenReceiverJoinsRepeatedly() {
void shouldEmitPeerJoinedOnFirstReceiverJoin() {
TransferSession session = new TransferSession(
"session-1",
"849201",
@@ -18,7 +19,6 @@ class TransferSessionTest {
List.of(new TransferFileItem("report.pdf", 2048, "application/pdf"))
);
session.markReceiverJoined();
session.markReceiverJoined();
PollTransferSignalsResponse senderSignals = session.poll(TransferRole.SENDER, 0);
@@ -29,6 +29,21 @@ class TransferSessionTest {
assertThat(senderSignals.nextCursor()).isEqualTo(1);
}
@Test
void shouldRejectRepeatedReceiverJoinForOnlineTransfer() {
TransferSession session = new TransferSession(
"session-1",
"849201",
Instant.parse("2026-03-20T12:00:00Z"),
List.of(new TransferFileItem("report.pdf", 2048, "application/pdf"))
);
session.markReceiverJoined();
assertThatThrownBy(session::markReceiverJoined)
.hasMessageContaining("在线快传");
}
@Test
void shouldRouteSignalsToTheOppositeRoleQueue() {
TransferSession session = new TransferSession(