添加快传7天离线传
This commit is contained in:
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.time.Instant;
|
||||
public record LookupTransferSessionResponse(
|
||||
String sessionId,
|
||||
String pickupCode,
|
||||
TransferMode mode,
|
||||
Instant expiresAt
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
public enum TransferMode {
|
||||
ONLINE,
|
||||
OFFLINE
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.util.List;
|
||||
public record TransferSessionResponse(
|
||||
String sessionId,
|
||||
String pickupCode,
|
||||
TransferMode mode,
|
||||
Instant expiresAt,
|
||||
List<TransferFileItem> files
|
||||
) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user