diff --git a/AGENTS.md b/AGENTS.md index e569f2d..9038288 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,13 @@ This repository is split across a Java backend, a Vite/React frontend, a small `docs/` area, and utility scripts. Use the project-level agents defined in `.codex/agents/` instead of improvising overlapping roles. +## Session startup + +- Every new window / new session that starts work in this repository must read `memory.md`, `docs/architecture.md`, and `docs/api-reference.md` first before planning, coding, reviewing, or deploying. +- Treat `memory.md` as the current project memory and continuity handoff unless the user explicitly overrides it. +- Treat `docs/architecture.md` as the system-level source of truth for module boundaries and runtime structure. +- Treat `docs/api-reference.md` as the quick reference for backend endpoints and auth/public access boundaries. + ## Real project structure - `backend/`: Spring Boot 3.3.8, Java 17, Maven, domain packages under `com.yoyuzh.{auth,cqu,files,config,common}`. diff --git a/backend/src/main/java/com/yoyuzh/config/ApiRootController.java b/backend/src/main/java/com/yoyuzh/config/ApiRootController.java new file mode 100644 index 0000000..3499219 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/config/ApiRootController.java @@ -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"; + } +} diff --git a/backend/src/main/java/com/yoyuzh/config/CorsProperties.java b/backend/src/main/java/com/yoyuzh/config/CorsProperties.java index 607c3db..e6eae38 100644 --- a/backend/src/main/java/com/yoyuzh/config/CorsProperties.java +++ b/backend/src/main/java/com/yoyuzh/config/CorsProperties.java @@ -10,7 +10,9 @@ public class CorsProperties { private List 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 getAllowedOrigins() { diff --git a/backend/src/main/java/com/yoyuzh/config/SchedulingConfiguration.java b/backend/src/main/java/com/yoyuzh/config/SchedulingConfiguration.java new file mode 100644 index 0000000..75a3904 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/config/SchedulingConfiguration.java @@ -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 { +} diff --git a/backend/src/main/java/com/yoyuzh/files/FileService.java b/backend/src/main/java/com/yoyuzh/files/FileService.java index 6e066ad..6a65936 100644 --- a/backend/src/main/java/com/yoyuzh/files/FileService.java +++ b/backend/src/main/java/com/yoyuzh/files/FileService.java @@ -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 ); } diff --git a/backend/src/main/java/com/yoyuzh/transfer/CreateTransferSessionRequest.java b/backend/src/main/java/com/yoyuzh/transfer/CreateTransferSessionRequest.java index 86a0b3d..8ea7571 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/CreateTransferSessionRequest.java +++ b/backend/src/main/java/com/yoyuzh/transfer/CreateTransferSessionRequest.java @@ -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 ) { diff --git a/backend/src/main/java/com/yoyuzh/transfer/LookupTransferSessionResponse.java b/backend/src/main/java/com/yoyuzh/transfer/LookupTransferSessionResponse.java index d8fa5c0..24d459f 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/LookupTransferSessionResponse.java +++ b/backend/src/main/java/com/yoyuzh/transfer/LookupTransferSessionResponse.java @@ -5,6 +5,7 @@ import java.time.Instant; public record LookupTransferSessionResponse( String sessionId, String pickupCode, + TransferMode mode, Instant expiresAt ) { } diff --git a/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferFile.java b/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferFile.java new file mode 100644 index 0000000..4dadb33 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferFile.java @@ -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; + } +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferSession.java b/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferSession.java new file mode 100644 index 0000000..eab8632 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferSession.java @@ -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 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 getFiles() { + return files; + } + + public void addFile(OfflineTransferFile file) { + files.add(file); + file.setSession(this); + } + + public boolean isExpired(Instant now) { + return expiresAt.isBefore(now); + } +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferSessionRepository.java b/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferSessionRepository.java new file mode 100644 index 0000000..e72850a --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferSessionRepository.java @@ -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 { + + boolean existsByPickupCode(String pickupCode); + + @Query(""" + select distinct session + from OfflineTransferSession session + left join fetch session.files + where session.sessionId = :sessionId + """) + Optional findWithFilesBySessionId(@Param("sessionId") String sessionId); + + @Query(""" + select distinct session + from OfflineTransferSession session + left join fetch session.files + where session.pickupCode = :pickupCode + """) + Optional findWithFilesByPickupCode(@Param("pickupCode") String pickupCode); + + @Query(""" + select distinct session + from OfflineTransferSession session + left join fetch session.files + where session.expiresAt < :now + """) + List findAllExpiredWithFiles(@Param("now") Instant now); +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferController.java b/backend/src/main/java/com/yoyuzh/transfer/TransferController.java index 51dd2e1..12a0f23 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/TransferController.java +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferController.java @@ -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 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 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 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 postSignal(@PathVariable String sessionId, diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferFileItem.java b/backend/src/main/java/com/yoyuzh/transfer/TransferFileItem.java index 71cb1c8..a660d4b 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/TransferFileItem.java +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferFileItem.java @@ -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); + } } diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferMode.java b/backend/src/main/java/com/yoyuzh/transfer/TransferMode.java new file mode 100644 index 0000000..0c69e8a --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferMode.java @@ -0,0 +1,6 @@ +package com.yoyuzh.transfer; + +public enum TransferMode { + ONLINE, + OFFLINE +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferService.java b/backend/src/main/java/com/yoyuzh/transfer/TransferService.java index 2145600..44e4dfa 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/TransferService.java +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferService.java @@ -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 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 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 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 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; + } } diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferSession.java b/backend/src/main/java/com/yoyuzh/transfer/TransferSession.java index 3f8ef88..bdc1a7f 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/TransferSession.java +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferSession.java @@ -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; diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferSessionResponse.java b/backend/src/main/java/com/yoyuzh/transfer/TransferSessionResponse.java index 8ea9def..2330ab9 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/TransferSessionResponse.java +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferSessionResponse.java @@ -6,6 +6,7 @@ import java.util.List; public record TransferSessionResponse( String sessionId, String pickupCode, + TransferMode mode, Instant expiresAt, List files ) { diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 57e43d1..2596609 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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: diff --git a/backend/src/test/java/com/yoyuzh/config/ApiRootControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/config/ApiRootControllerIntegrationTest.java new file mode 100644 index 0000000..ac87785 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/config/ApiRootControllerIntegrationTest.java @@ -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")); + } +} diff --git a/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java b/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java index 4b36bae..b6cbc92 100644 --- a/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java +++ b/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java @@ -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(); diff --git a/backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java index 264b706..babd4c8 100644 --- a/backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java +++ b/backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java @@ -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))); + } } diff --git a/backend/src/test/java/com/yoyuzh/transfer/TransferSessionTest.java b/backend/src/test/java/com/yoyuzh/transfer/TransferSessionTest.java index 5ed420d..3d4d681 100644 --- a/backend/src/test/java/com/yoyuzh/transfer/TransferSessionTest.java +++ b/backend/src/test/java/com/yoyuzh/transfer/TransferSessionTest.java @@ -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( diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..8850719 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,350 @@ +# API 接口文档 + +本文档用于快速了解 `yoyuzh.xyz` 当前后端 API 的职责、鉴权方式和主要接口分组。 + +## 1. 基本约定 + +### 基础路径 + +- 后端接口统一以 `/api` 开头 +- 本地开发默认地址:`http://localhost:8080` + +### 返回格式 + +大部分接口返回统一结构: + +```json +{ + "code": 0, + "msg": "success", + "data": {} +} +``` + +常见含义: + +- `code = 0`:成功 +- `code = 1000`:参数校验失败 +- `code = 1001`:未登录 +- `code = 1002`:权限不足 +- `code = 1003`:业务对象不存在、邀请码错误、取件码失效等业务失败 + +### 鉴权方式 + +- 采用 `Authorization: Bearer ` +- `refreshToken` 通过 `/api/auth/refresh` 换取新的登录态 +- 当前实现为“单账号单设备在线” + - 新设备登录后,旧设备的 access token 会失效 + +### 权限分层 + +- 公开接口: + - `/api/auth/**` + - `/api/transfer/**` + - `GET /api/files/share-links/{token}` +- 登录后接口: + - `/api/user/**` + - `/api/files/**` + - `/api/admin/**` + +## 2. 认证模块 + +控制器: + +- `backend/src/main/java/com/yoyuzh/auth/AuthController.java` +- `backend/src/main/java/com/yoyuzh/auth/DevAuthController.java` +- `backend/src/main/java/com/yoyuzh/auth/UserController.java` + +### 2.1 注册 + +`POST /api/auth/register` + +说明: + +- 使用邀请码注册 +- 注册成功后直接返回登录态 +- 邀请码成功使用后会自动刷新 + +请求重点字段: + +- `username` +- `email` +- `phoneNumber` +- `password` +- `confirmPassword` +- `inviteCode` + +### 2.2 登录 + +`POST /api/auth/login` + +请求字段: + +- `username` +- `password` + +返回字段: + +- `token` +- `accessToken` +- `refreshToken` +- `user` + +### 2.3 刷新登录态 + +`POST /api/auth/refresh` + +请求字段: + +- `refreshToken` + +说明: + +- 刷新后会返回新的 access token 与 refresh token +- 当前系统会让旧 refresh token 失效 + +### 2.4 开发环境登录 + +`POST /api/auth/dev-login` + +说明: + +- 仅用于开发联调 +- 是否可用取决于当前环境配置 + +### 2.5 获取用户资料 + +`GET /api/user/profile` + +### 2.6 更新用户资料 + +`PUT /api/user/profile` + +### 2.7 修改密码 + +`POST /api/user/password` + +说明: + +- 成功后会重新签发新的登录态 +- 同时会顶掉旧设备会话 + +### 2.8 头像相关 + +- `POST /api/user/avatar/upload/initiate` +- `POST /api/user/avatar/upload` +- `POST /api/user/avatar/upload/complete` +- `GET /api/user/avatar/content` + +说明: + +- 支持初始化直传 +- 支持代理上传 +- 最终通过完成接口落库 + +## 3. 网盘模块 + +控制器: + +- `backend/src/main/java/com/yoyuzh/files/FileController.java` + +### 3.1 上传相关 + +- `POST /api/files/upload` +- `POST /api/files/upload/initiate` +- `POST /api/files/upload/complete` + +说明: + +- 兼容普通上传和 OSS 直传 +- 前端会优先尝试“初始化上传 -> 直传/代理 -> 完成上传” + +### 3.2 目录与列表 + +- `POST /api/files/mkdir` +- `GET /api/files/list` +- `GET /api/files/recent` + +说明: + +- `list` 支持 `path`、`page`、`size` +- 当前前端会在网盘页缓存目录内容和最后访问路径 + +### 3.3 下载 + +- `GET /api/files/download/{fileId}` +- `GET /api/files/download/{fileId}/url` + +说明: + +- 普通文件优先获取下载 URL +- 文件夹可走 ZIP 下载 + +### 3.4 文件操作 + +- `PATCH /api/files/{fileId}/rename` +- `PATCH /api/files/{fileId}/move` +- `POST /api/files/{fileId}/copy` +- `DELETE /api/files/{fileId}` + +说明: + +- `move` 用于移动到目标路径 +- `copy` 用于复制到目标路径 +- 文件和文件夹都支持移动 / 复制 + +### 3.5 分享链接 + +- `POST /api/files/{fileId}/share-links` +- `GET /api/files/share-links/{token}` +- `POST /api/files/share-links/{token}/import` + +说明: + +- 已登录用户可为自己的文件或文件夹创建分享链接 +- 公开访客可查看分享详情 +- 登录用户可将分享内容导入自己的网盘 + +## 4. 快传模块 + +控制器: + +- `backend/src/main/java/com/yoyuzh/transfer/TransferController.java` + +### 4.1 创建会话 + +`POST /api/transfer/sessions` + +说明: + +- 创建快传会话需要发送端登录 +- 请求体必须区分 `mode` + - `ONLINE`: 在线快传,15 分钟有效,只能被接收一次 + - `OFFLINE`: 离线快传,7 天有效,文件会落到站点存储并可被重复接收 +- 返回会话 ID、取件码、模式、过期时间和文件清单 + +### 4.2 通过取件码查找会话 + +`GET /api/transfer/sessions/lookup?pickupCode=xxxxxx` + +说明: + +- 接收端通过 6 位取件码查找会话 + +### 4.3 加入会话 + +`POST /api/transfer/sessions/{sessionId}/join` + +说明: + +- 在线快传会占用一次性会话 +- 离线快传返回可下载文件清单,不需要建立 P2P 通道 + +### 4.4 信令交换 + +- `POST /api/transfer/sessions/{sessionId}/signals` +- `GET /api/transfer/sessions/{sessionId}/signals` + +说明: + +- 后端负责 WebRTC 信令交换 +- 文件内容本身不经过后端 +- 实际文件通过浏览器 DataChannel 进行 P2P 传输 +- 该组接口仅用于 `ONLINE` 模式 + +### 4.5 上传离线快传文件 + +`POST /api/transfer/sessions/{sessionId}/files/{fileId}/content` + +说明: + +- 需要发送端登录 +- 发送端把离线文件内容上传到站点存储 +- 线上环境会把离线文件落到 OSS + +### 4.6 下载离线快传文件 + +`GET /api/transfer/sessions/{sessionId}/files/{fileId}/download` + +说明: + +- 公开接口 +- 离线文件在有效期内可以被重复下载 + +### 4.7 存入网盘 + +`POST /api/transfer/sessions/{sessionId}/files/{fileId}/import` + +说明: + +- 需要登录 +- 把离线快传文件导入到当前用户网盘 + +## 5. 管理台模块 + +控制器: + +- `backend/src/main/java/com/yoyuzh/admin/AdminController.java` + +### 5.1 总览 + +`GET /api/admin/summary` + +返回内容包括: + +- 用户总数 +- 文件总数 +- 当前邀请码 + +### 5.2 用户管理 + +- `GET /api/admin/users` +- `PATCH /api/admin/users/{userId}/role` +- `PATCH /api/admin/users/{userId}/status` +- `PUT /api/admin/users/{userId}/password` +- `POST /api/admin/users/{userId}/password/reset` + +说明: + +- 可调整用户角色 +- 可封禁用户 +- 可重置或直接设置密码 +- 封禁/改密会使原登录态失效 + +### 5.3 文件管理 + +- `GET /api/admin/files` +- `DELETE /api/admin/files/{fileId}` + +## 6. 前端公开路由与接口关系 + +前端入口在: + +- `front/src/App.tsx` + +主要页面: + +- `/login` +- `/overview` +- `/files` +- `/transfer` +- `/share/:token` +- `/admin/*` + +接口关系: + +- 登录页:调用 `/api/auth/login`、`/api/auth/register` +- 网盘页:调用 `/api/files/**` +- 快传页:调用 `/api/transfer/**` +- 分享页:调用 `/api/files/share-links/{token}` 和导入接口 +- 管理台:调用 `/api/admin/**` + +## 7. 建议阅读顺序 + +后续新窗口如果要接手后端功能,建议按这个顺序看: + +1. `memory.md` +2. `docs/architecture.md` +3. `docs/api-reference.md` +4. `backend/src/main/java/com/yoyuzh/config/SecurityConfig.java` +5. 对应业务模块的 `Controller + Service` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..6f0012e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,340 @@ +# 架构文档 + +本文档用于描述 `yoyuzh.xyz` 当前的系统结构、模块边界、关键流程和部署方式,便于后续窗口快速建立整体上下文。 + +## 1. 系统概览 + +项目是一个前后端分离的全栈站点,核心由三部分组成: + +1. React 前端站点 +2. Spring Boot 后端 API +3. 文件存储层(本地文件系统或 OSS) + +业务主线已经从旧教务方向切换为: + +- 账号系统 +- 个人网盘 +- 快传 +- 管理台 + +## 2. 仓库结构与职责 + +### 2.1 前端 + +路径: + +- `front/` + +核心职责: + +- 页面路由与交互 +- 登录态管理 +- 网盘 UI 与缓存 +- 快传发/收流程 +- 管理台前端 +- 生产环境 API 基址拼装与调用 + +关键入口: + +- `front/src/App.tsx` +- `front/src/lib/api.ts` +- `front/src/components/layout/Layout.tsx` + +主要页面: + +- `front/src/pages/Login.tsx` +- `front/src/pages/Overview.tsx` +- `front/src/pages/Files.tsx` +- `front/src/pages/Transfer.tsx` +- `front/src/pages/TransferReceive.tsx` +- `front/src/pages/FileShare.tsx` + +### 2.2 后端 + +路径: + +- `backend/` + +核心职责: + +- 认证与 JWT 鉴权 +- 网盘元数据与文件流转 +- 快传信令与会话状态 +- 管理台 API +- OSS / 本地存储抽象 + +后端包结构: + +- `com.yoyuzh.auth` +- `com.yoyuzh.files` +- `com.yoyuzh.transfer` +- `com.yoyuzh.admin` +- `com.yoyuzh.config` +- `com.yoyuzh.common` + +启动类: + +- `backend/src/main/java/com/yoyuzh/PortalBackendApplication.java` + +### 2.3 文档与脚本 + +- `docs/`: 实现计划与补充文档 +- `scripts/`: 前端 OSS 发布、存储迁移和本地辅助脚本 + +## 3. 模块划分 + +### 3.1 认证模块 + +核心文件: + +- `backend/src/main/java/com/yoyuzh/auth/AuthController.java` +- `backend/src/main/java/com/yoyuzh/auth/AuthService.java` +- `backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java` +- `backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java` +- `backend/src/main/java/com/yoyuzh/auth/RefreshTokenService.java` + +职责: + +- 注册、登录、刷新登录态 +- 用户资料查询和修改 +- 头像上传 +- 单设备登录控制 +- 邀请码消费与轮换 + +关键实现说明: + +- access token 使用 JWT +- refresh token 持久化到数据库 +- 当前会话通过 `activeSessionId + JWT sid claim` 绑定 +- 新登录会挤掉旧设备 + +### 3.2 网盘模块 + +核心文件: + +- `backend/src/main/java/com/yoyuzh/files/FileController.java` +- `backend/src/main/java/com/yoyuzh/files/FileService.java` +- `backend/src/main/java/com/yoyuzh/files/storage/*` +- `front/src/pages/Files.tsx` + +职责: + +- 文件/文件夹上传、下载、删除、重命名 +- 目录创建与分页列表 +- 移动、复制 +- 分享链接与导入 +- 前端树状目录导航 + +关键实现说明: + +- 文件元数据在数据库 +- 文件内容走存储层抽象 +- 支持本地磁盘和 OSS +- 前端会缓存目录列表和最后访问路径 + +### 3.3 快传模块 + +核心文件: + +- `backend/src/main/java/com/yoyuzh/transfer/TransferController.java` +- `backend/src/main/java/com/yoyuzh/transfer/TransferService.java` +- `backend/src/main/java/com/yoyuzh/transfer/TransferSession.java` +- `front/src/pages/Transfer.tsx` +- `front/src/pages/TransferReceive.tsx` +- `front/src/lib/transfer-runtime.ts` +- `front/src/lib/transfer-protocol.ts` + +职责: + +- 创建快传会话 +- 生成取件码与分享链接 +- WebRTC 信令交换 +- 浏览器端文件发送与接收 +- 接收后下载或存入网盘 + +关键实现说明: + +- 后端只做信令和会话状态,不中转文件内容 +- 文件内容走浏览器 DataChannel +- 接收端支持部分文件选择 +- 多文件或文件夹可走 ZIP 下载 +- 在线快传是一次性浏览器 P2P 传输,首个接收者进入后即占用该会话 +- 离线快传会把文件内容落到站点存储,线上环境使用 OSS,默认保留 7 天并支持重复接收 + +### 3.4 管理台模块 + +核心文件: + +- `backend/src/main/java/com/yoyuzh/admin/AdminController.java` +- `backend/src/main/java/com/yoyuzh/admin/AdminService.java` +- `front/src/admin/*` + +职责: + +- 管理用户 +- 管理文件 +- 查看邀请码 + +关键实现说明: + +- 管理台依赖后端 summary/users/files 接口 +- 当前邀请码由后端返回给管理台展示 + +## 4. 关键业务流程 + +### 4.1 登录流程 + +1. 前端登录页调用 `/api/auth/login` +2. 后端鉴权成功后签发 access token + refresh token +3. 后端刷新 `activeSessionId` +4. 前端本地存储 `portal-session` +5. 后续请求通过 `Authorization: Bearer ` 访问 +6. JWT 过滤器校验 token、用户状态和会话 ID 是否仍匹配 + +补充说明: + +- 前端生产构建当前仍会把 API 基址固化为 `https://api.yoyuzh.xyz/api` +- 因此前端登录、刷新、受保护接口访问都依赖 `api.yoyuzh.xyz` 这条独立 API 子域名链路 +- 若该子域名在某些网络环境下 TLS/SNI 不稳定,前端会直接表现为“网络异常”或“登录失败” + +### 4.2 邀请码注册流程 + +1. 用户提交注册信息与邀请码 +2. 后端验证用户名、邮箱、手机号唯一性 +3. 邀请码服务校验当前邀请码 +4. 注册成功后自动轮换邀请码 +5. 返回登录态 + +### 4.3 网盘上传流程 + +1. 前端在 `Files` 页面选择文件或文件夹 +2. 前端优先调用 `/api/files/upload/initiate` +3. 如果存储支持直传,则浏览器直接上传到 OSS +4. 前端再调用 `/api/files/upload/complete` +5. 如果直传失败,会回退到代理上传接口 `/api/files/upload` + +### 4.4 文件分享流程 + +1. 登录用户创建分享链接 +2. 后端生成 token +3. 公开用户通过 `/share/:token` 查看详情 +4. 登录用户可以导入到自己的网盘 + +### 4.5 快传流程 + +1. 发送端登录后创建快传会话 +2. 若是在线模式,后端返回 `sessionId + pickupCode` 并保留 15 分钟的一次性会话 +3. 接收端通过取件码或分享链接加入在线会话 +4. 双方通过 `/api/transfer/.../signals` 交换 offer / answer / ice +5. DataChannel 建立后传输文件内容 +6. 接收端可直接下载或存入网盘 + +### 4.6 离线快传流程 + +1. 发送端登录后创建离线快传会话 +2. 后端生成 `sessionId + pickupCode`,并为每个文件创建离线存储槽位 +3. 发送端把文件上传到站点存储 +4. 上传完成后,会话变为可接收状态并保留 7 天 +5. 接收端通过取件码或分享链接打开会话 +6. 接收端可直接下载离线文件,也可登录后存入网盘 +7. 文件在有效期内不会因一次接收而被删除,过期后由后端清理任务自动销毁 + +## 5. 前端路由架构 + +路由入口: + +- `front/src/App.tsx` + +主要路由: + +- `/login` +- `/overview` +- `/files` +- `/transfer` +- `/share/:token` +- `/admin/*` + +说明: + +- `/transfer` 同时承担发送端和接收端入口 +- `/share/:token` 是公开文件分享页 +- `/admin/*` 为懒加载管理台 + +## 6. 安全模型 + +### 6.1 访问控制 + +由 `SecurityConfig` 控制: + +- `/api/auth/**` 公开 +- `/api/transfer/**` 公开 +- `GET /api/files/share-links/{token}` 公开 +- `/api/files/**`、`/api/user/**`、`/api/admin/**` 需登录 + +### 6.2 单设备登录 + +当前实现不是只撤销 refresh token,而是同时控制 access token: + +- 用户表记录 `activeSessionId` +- JWT 里包含 `sid` +- 过滤器每次请求都会比对当前用户的 `activeSessionId` +- 新登录成功后,旧设备 token 会失效 + +## 7. 存储架构 + +抽象层: + +- `backend/src/main/java/com/yoyuzh/files/storage/FileContentStorage.java` + +实现方向: + +- 本地文件系统 +- OSS + +设计目的: + +- 让文件元数据逻辑与底层存储解耦 +- 上传、下载、复制、移动都通过统一抽象收口 + +## 8. 部署架构 + +### 8.1 前端 + +- 构建工具:Vite +- 发布方式:OSS 静态资源发布 +- 发布脚本:`node scripts/deploy-front-oss.mjs` + +### 8.2 后端 + +- 打包方式:`mvn package` +- 产物:`backend/target/yoyuzh-portal-backend-0.0.1-SNAPSHOT.jar` +- 线上通常采用 jar + systemd 方式运行 + +当前已知线上信息: + +- 服务名:`my-site-api.service` +- 运行包路径:`/opt/yoyuzh/yoyuzh-portal-backend.jar` + +## 9. 开发注意事项 + +- 仓库根目录没有 `package.json`,不要在根目录执行 `npm` +- 前端命令只从 `front/package.json` 读取 +- 后端命令只从 `backend/pom.xml` 读取 +- 前端 `npm run lint` 实际是 `tsc --noEmit` +- 后端没有单独 lint 命令 +- 本仓库大量使用 Lombok,VS Code 若出现“final 字段未初始化”之类误报,优先检查 Lombok 扩展、Java Language Server 和 annotation processor + +## 10. 新窗口建议阅读顺序 + +后续新窗口进入仓库时,建议顺序: + +1. `memory.md` +2. `docs/architecture.md` +3. `docs/api-reference.md` +4. `AGENTS.md` + +如果要继续某个具体功能,再进入对应模块的: + +- 前端页面文件 +- 后端 Controller / Service +- 紧邻测试文件 diff --git a/docs/superpowers/plans/2026-03-23-transfer-online-offline-mode.md b/docs/superpowers/plans/2026-03-23-transfer-online-offline-mode.md new file mode 100644 index 0000000..da96996 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-transfer-online-offline-mode.md @@ -0,0 +1,94 @@ +# Transfer Online Offline Mode Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Distinguish quick transfer online and offline sending so online stays one-time P2P, while offline persists files for 7 days in storage and can be received repeatedly. + +**Architecture:** Keep the current in-memory WebRTC signaling flow for online transfers and add a persistent offline transfer path in the backend. Offline transfers store file metadata plus storage references, expose repeatable public download/import behavior, and let the frontend branch between P2P receive and offline download based on the transfer mode returned by the API. + +**Tech Stack:** Spring Boot 3.3.8, Java 17, JPA/H2 tests, Vite 6, React 19, TypeScript, existing OSS/local storage abstraction + +--- + +### Task 1: Plan the transfer domain split + +**Files:** +- Modify: `backend/src/main/java/com/yoyuzh/transfer/CreateTransferSessionRequest.java` +- Modify: `backend/src/main/java/com/yoyuzh/transfer/TransferSessionResponse.java` +- Modify: `backend/src/main/java/com/yoyuzh/transfer/LookupTransferSessionResponse.java` +- Create: `backend/src/main/java/com/yoyuzh/transfer/TransferMode.java` +- Create: `backend/src/main/java/com/yoyuzh/transfer/OfflineTransfer*` + +- [ ] **Step 1: Write the failing backend tests for mode-aware session responses** +- [ ] **Step 2: Run `cd backend && mvn test -Dtest=TransferSessionTest,TransferControllerIntegrationTest` and verify the new assertions fail for missing mode-aware behavior** +- [ ] **Step 3: Add the minimal transfer mode types, request fields, and response fields** +- [ ] **Step 4: Run the same Maven test command and verify the new mode assertions pass** + +### Task 2: Add offline transfer storage and repeatable receive flow + +**Files:** +- Modify: `backend/src/main/java/com/yoyuzh/files/storage/FileContentStorage.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/storage/LocalFileContentStorage.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/storage/OssFileContentStorage.java` +- Modify: `backend/src/main/java/com/yoyuzh/transfer/TransferController.java` +- Modify: `backend/src/main/java/com/yoyuzh/transfer/TransferService.java` +- Create: `backend/src/main/java/com/yoyuzh/transfer/OfflineTransfer*.java` +- Test: `backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java` + +- [ ] **Step 1: Write failing integration tests for offline create, upload, lookup, repeatable receive, and 7-day expiry metadata** +- [ ] **Step 2: Run `cd backend && mvn test -Dtest=TransferControllerIntegrationTest` and verify the offline scenarios fail for the expected missing endpoints/fields** +- [ ] **Step 3: Implement the minimal persistent offline transfer entities, repositories, service methods, and public download/import endpoints** +- [ ] **Step 4: Run `cd backend && mvn test -Dtest=TransferControllerIntegrationTest` and verify the offline scenarios pass** + +### Task 3: Keep online transfers one-time + +**Files:** +- Modify: `backend/src/main/java/com/yoyuzh/transfer/TransferSession.java` +- Modify: `backend/src/main/java/com/yoyuzh/transfer/TransferService.java` +- Test: `backend/src/test/java/com/yoyuzh/transfer/TransferSessionTest.java` + +- [ ] **Step 1: Write the failing test that a second online receiver cannot join/re-receive** +- [ ] **Step 2: Run `cd backend && mvn test -Dtest=TransferSessionTest` and verify it fails for the current reusable online session behavior** +- [ ] **Step 3: Implement the minimal online single-receive guard** +- [ ] **Step 4: Run `cd backend && mvn test -Dtest=TransferSessionTest` and verify it passes** + +### Task 4: Add frontend mode-aware API helpers and state + +**Files:** +- Modify: `front/src/lib/types.ts` +- Modify: `front/src/lib/transfer.ts` +- Modify: `front/src/pages/transfer-state.ts` +- Test: `front/src/pages/transfer-state.test.ts` + +- [ ] **Step 1: Write failing frontend tests for transfer mode options, request payloads, and helper text/state** +- [ ] **Step 2: Run `cd front && npm run test` and verify the new mode-aware tests fail** +- [ ] **Step 3: Implement the minimal frontend types and helpers for online/offline mode branching** +- [ ] **Step 4: Run `cd front && npm run test` and verify the helper tests pass** + +### Task 5: Update the transfer send and receive pages + +**Files:** +- Modify: `front/src/pages/Transfer.tsx` +- Modify: `front/src/pages/TransferReceive.tsx` +- Modify: `front/src/lib/transfer.ts` +- Test: `front/src/pages/transfer-state.test.ts` + +- [ ] **Step 1: Add failing tests for send-mode selection and receive-mode branching where possible in existing frontend test files** +- [ ] **Step 2: Run `cd front && npm run test` and verify the new assertions fail** +- [ ] **Step 3: Implement the minimal UI and flow split so online stays P2P and offline uses backend-backed receive/download behavior** +- [ ] **Step 4: Run `cd front && npm run test` and verify the mode flow tests pass** + +### Task 6: Verify and release + +**Files:** +- Modify: `docs/architecture.md` +- Modify: `docs/api-reference.md` + +- [ ] **Step 1: Run `cd backend && mvn test`** +- [ ] **Step 2: Run `cd front && npm run test`** +- [ ] **Step 3: Run `cd front && npm run lint`** +- [ ] **Step 4: Run `cd front && npm run build`** +- [ ] **Step 5: Run `cd backend && mvn package`** +- [ ] **Step 6: Update the docs to describe online/offline transfer behavior and offline endpoints** +- [ ] **Step 7: Publish frontend with `node scripts/deploy-front-oss.mjs` from repo root** +- [ ] **Step 8: Upload `backend/target/yoyuzh-portal-backend-0.0.1-SNAPSHOT.jar` to the real server and restart the existing backend service** diff --git a/front/src/lib/transfer.test.ts b/front/src/lib/transfer.test.ts new file mode 100644 index 0000000..c54f301 --- /dev/null +++ b/front/src/lib/transfer.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildOfflineTransferDownloadUrl, toTransferFilePayload } from './transfer'; + +test('toTransferFilePayload keeps relative folder paths for transfer files', () => { + const report = new File(['hello'], 'report.pdf', { + type: 'application/pdf', + }); + Object.defineProperty(report, 'webkitRelativePath', { + configurable: true, + value: '课程资料/第一周/report.pdf', + }); + + assert.deepEqual(toTransferFilePayload([report]), [ + { + name: 'report.pdf', + relativePath: '课程资料/第一周/report.pdf', + size: 5, + contentType: 'application/pdf', + }, + ]); +}); + +test('buildOfflineTransferDownloadUrl points to the public offline download endpoint', () => { + assert.equal( + buildOfflineTransferDownloadUrl('session-1', 'file-1'), + '/api/transfer/sessions/session-1/files/file-1/download', + ); +}); diff --git a/front/src/lib/transfer.ts b/front/src/lib/transfer.ts index f0a77be..1665e14 100644 --- a/front/src/lib/transfer.ts +++ b/front/src/lib/transfer.ts @@ -1,4 +1,7 @@ +import type { FileMetadata, TransferMode } from './types'; import { apiRequest } from './api'; +import { apiUploadRequest } from './api'; +import { getTransferFileRelativePath } from './transfer-protocol'; import type { LookupTransferSessionResponse, PollTransferSignalsResponse, @@ -13,15 +16,17 @@ export const DEFAULT_TRANSFER_ICE_SERVERS: RTCIceServer[] = [ export function toTransferFilePayload(files: File[]) { return files.map((file) => ({ name: file.name, + relativePath: getTransferFileRelativePath(file), size: file.size, contentType: file.type || 'application/octet-stream', })); } -export function createTransferSession(files: File[]) { +export function createTransferSession(files: File[], mode: TransferMode) { return apiRequest('/transfer/sessions', { method: 'POST', body: { + mode, files: toTransferFilePayload(files), }, }); @@ -39,6 +44,38 @@ export function joinTransferSession(sessionId: string) { }); } +export function uploadOfflineTransferFile( + sessionId: string, + fileId: string, + file: File, + onProgress?: (progress: {loaded: number; total: number}) => void, +) { + const body = new FormData(); + body.append('file', file); + + return apiUploadRequest(`/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/content`, { + body, + onProgress, + }); +} + +export function buildOfflineTransferDownloadUrl(sessionId: string, fileId: string) { + const apiBaseUrl = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, ''); + return `${apiBaseUrl}/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/download`; +} + +export function importOfflineTransferFile(sessionId: string, fileId: string, path: string) { + return apiRequest( + `/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/import`, + { + method: 'POST', + body: { + path, + }, + }, + ); +} + export function postTransferSignal(sessionId: string, role: 'sender' | 'receiver', type: string, payload: string) { return apiRequest(`/transfer/sessions/${encodeURIComponent(sessionId)}/signals?role=${role}`, { method: 'POST', diff --git a/front/src/lib/types.ts b/front/src/lib/types.ts index 5da3367..bca94fe 100644 --- a/front/src/lib/types.ts +++ b/front/src/lib/types.ts @@ -106,15 +106,21 @@ export interface FileShareDetailsResponse { createdAt: string; } +export type TransferMode = 'ONLINE' | 'OFFLINE'; + export interface TransferFileItem { + id?: string | null; name: string; + relativePath: string; size: number; contentType: string; + uploaded?: boolean | null; } export interface TransferSessionResponse { sessionId: string; pickupCode: string; + mode: TransferMode; expiresAt: string; files: TransferFileItem[]; } @@ -122,6 +128,7 @@ export interface TransferSessionResponse { export interface LookupTransferSessionResponse { sessionId: string; pickupCode: string; + mode: TransferMode; expiresAt: string; } diff --git a/front/src/pages/Transfer.tsx b/front/src/pages/Transfer.tsx index b7f4df6..a085005 100644 --- a/front/src/pages/Transfer.tsx +++ b/front/src/pages/Transfer.tsx @@ -36,19 +36,26 @@ import { } from '@/src/lib/transfer-protocol'; import { waitForTransferChannelDrain } from '@/src/lib/transfer-runtime'; import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling'; -import { DEFAULT_TRANSFER_ICE_SERVERS, createTransferSession, pollTransferSignals, postTransferSignal } from '@/src/lib/transfer'; -import type { TransferSessionResponse } from '@/src/lib/types'; +import { + DEFAULT_TRANSFER_ICE_SERVERS, + createTransferSession, + pollTransferSignals, + postTransferSignal, + uploadOfflineTransferFile, +} from '@/src/lib/transfer'; +import type { TransferMode, TransferSessionResponse } from '@/src/lib/types'; import { cn } from '@/src/lib/utils'; import { buildQrImageUrl, canSendTransferFiles, formatTransferSize, + getTransferModeSummary, resolveInitialTransferTab, } from './transfer-state'; import TransferReceive from './TransferReceive'; -type SendPhase = 'idle' | 'creating' | 'waiting' | 'connecting' | 'transferring' | 'completed' | 'error'; +type SendPhase = 'idle' | 'creating' | 'waiting' | 'connecting' | 'uploading' | 'transferring' | 'completed' | 'error'; function parseJsonPayload(payload: string): T | null { try { @@ -58,7 +65,22 @@ function parseJsonPayload(payload: string): T | null { } } -function getPhaseMessage(phase: SendPhase, errorMessage: string) { +function getPhaseMessage(mode: TransferMode, phase: SendPhase, errorMessage: string) { + if (mode === 'OFFLINE') { + switch (phase) { + case 'creating': + return '正在创建离线快传会话并生成取件链接...'; + case 'uploading': + return '文件正在上传到站点存储,上传完成后 7 天内都可以反复接收。'; + case 'completed': + return '离线文件已上传完成,接收方现在可以多次下载或存入网盘。'; + case 'error': + return errorMessage || '离线快传初始化失败,请重试。'; + default: + return '拖拽文件后会生成离线取件码,并把文件上传到站点存储保留 7 天。'; + } + } + switch (phase) { case 'creating': return '正在创建快传会话并准备 P2P 连接...'; @@ -85,6 +107,7 @@ export default function Transfer() { const [activeTab, setActiveTab] = useState(() => resolveInitialTransferTab(allowSend, sessionId)); const [selectedFiles, setSelectedFiles] = useState([]); + const [transferMode, setTransferMode] = useState('ONLINE'); const [session, setSession] = useState(null); const [sendPhase, setSendPhase] = useState('idle'); const [sendProgress, setSendProgress] = useState(0); @@ -129,11 +152,20 @@ export default function Transfer() { } }, [allowSend, sessionId]); + useEffect(() => { + if (selectedFiles.length === 0) { + return; + } + + void bootstrapTransfer(selectedFiles); + }, [transferMode]); + const totalSize = selectedFiles.reduce((sum, file) => sum + file.size, 0); const shareLink = session ? buildTransferShareUrl(window.location.origin, session.sessionId, getTransferRouterMode()) : ''; const qrImageUrl = shareLink ? buildQrImageUrl(shareLink) : ''; + const transferModeSummary = getTransferModeSummary(transferMode); function cleanupCurrentTransfer() { if (pollTimerRef.current) { @@ -229,12 +261,17 @@ export default function Transfer() { sentBytesRef.current = 0; try { - const createdSession = await createTransferSession(files); + const createdSession = await createTransferSession(files, transferMode); if (bootstrapIdRef.current !== bootstrapId) { return; } setSession(createdSession); + if (createdSession.mode === 'OFFLINE') { + await uploadOfflineFiles(createdSession, files, bootstrapId); + return; + } + setSendPhase('waiting'); await setupSenderPeer(createdSession, files, bootstrapId); } catch (error) { @@ -246,6 +283,42 @@ export default function Transfer() { } } + async function uploadOfflineFiles(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) { + setSendPhase('uploading'); + totalBytesRef.current = files.reduce((sum, file) => sum + file.size, 0); + sentBytesRef.current = 0; + setSendProgress(0); + + for (const [index, file] of files.entries()) { + if (bootstrapIdRef.current !== bootstrapId) { + return; + } + + const sessionFile = createdSession.files[index]; + if (!sessionFile?.id) { + throw new Error('离线快传文件清单不完整,请重新开始本次发送。'); + } + + let lastLoaded = 0; + await uploadOfflineTransferFile(createdSession.sessionId, sessionFile.id, file, ({ loaded, total }) => { + const delta = loaded - lastLoaded; + lastLoaded = loaded; + sentBytesRef.current += delta; + + if (loaded >= total) { + sentBytesRef.current = Math.min(totalBytesRef.current, sentBytesRef.current); + } + + if (totalBytesRef.current > 0) { + setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100))); + } + }); + } + + setSendProgress(100); + setSendPhase('completed'); + } + async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) { const connection = new RTCPeerConnection({ iceServers: DEFAULT_TRANSFER_ICE_SERVERS, @@ -439,8 +512,8 @@ export default function Transfer() {
-

P2P 快传

-

二维码负责把对方带到网页,真正的文件内容在两个浏览器之间通过 P2P 直连传输。

+

快传

+

在线快传走浏览器 P2P 一次性传输,离线快传会把文件存到站点存储里保留 7 天,可被反复接收。

@@ -490,6 +563,38 @@ export default function Transfer() { transition={{ duration: 0.2 }} className="flex-1 flex flex-col h-full min-w-0" > +
+ {(['ONLINE', 'OFFLINE'] as TransferMode[]).map((mode) => { + const summary = getTransferModeSummary(mode); + const active = transferMode === mode; + + return ( + + ); + })} +
+ {selectedFiles.length === 0 ? (

拖拽文件或文件夹到此处

- 选中文件后会自动创建一条公开接收链接,扫码打开网页就能在浏览器之间发起 P2P 下载。 + {transferModeSummary.description}

@@ -680,8 +785,8 @@ export default function Transfer() {
-

面向一次性分享

-

更适合把压缩包、截图和临时资料从当前浏览器快速交给另一台设备。

+

在线一次性,离线可重复

+

在线模式适合临时快传,离线模式会保留 7 天,接收后文件也不会立刻消失。

diff --git a/front/src/pages/TransferReceive.tsx b/front/src/pages/TransferReceive.tsx index 21c7589..54b6f1a 100644 --- a/front/src/pages/TransferReceive.tsx +++ b/front/src/pages/TransferReceive.tsx @@ -26,7 +26,15 @@ import { type TransferFileDescriptor, } from '@/src/lib/transfer-protocol'; import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling'; -import { DEFAULT_TRANSFER_ICE_SERVERS, joinTransferSession, lookupTransferSession, pollTransferSignals, postTransferSignal } from '@/src/lib/transfer'; +import { + buildOfflineTransferDownloadUrl, + DEFAULT_TRANSFER_ICE_SERVERS, + importOfflineTransferFile, + joinTransferSession, + lookupTransferSession, + pollTransferSignals, + postTransferSignal, +} from '@/src/lib/transfer'; import type { TransferSessionResponse } from '@/src/lib/types'; import { canArchiveTransferSelection, formatTransferSize, sanitizeReceiveCode } from './transfer-state'; @@ -164,7 +172,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro setArchiveName(buildTransferArchiveFileName('快传文件')); setArchiveUrl(null); setSavingFileId(null); - setSaveMessage(''); + setSaveMessage(''); try { const joinedSession = await joinTransferSession(sessionId); @@ -175,6 +183,27 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro setTransferSession(joinedSession); setArchiveName(buildTransferArchiveFileName(`快传-${joinedSession.pickupCode}`)); + if (joinedSession.mode === 'OFFLINE') { + const offlineFiles = joinedSession.files.map((file) => ({ + id: file.id ?? file.relativePath, + name: file.name, + size: file.size, + contentType: file.contentType, + relativePath: file.relativePath, + progress: file.uploaded ? 100 : 0, + selected: true, + requested: true, + downloadUrl: file.id ? buildOfflineTransferDownloadUrl(joinedSession.sessionId, file.id) : undefined, + savedToNetdisk: false, + })); + + setFiles(offlineFiles); + setRequestSubmitted(true); + setOverallProgress(offlineFiles.length > 0 ? 100 : 0); + setPhase('completed'); + return; + } + const connection = new RTCPeerConnection({ iceServers: DEFAULT_TRANSFER_ICE_SERVERS, }); @@ -567,6 +596,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro const canZipAllFiles = canArchiveTransferSelection(files); const hasSelectableFiles = selectedFiles.length > 0; const canSubmitSelection = Boolean(dataChannelRef.current && dataChannelRef.current.readyState === 'open' && hasSelectableFiles); + const isOfflineSession = transferSession?.mode === 'OFFLINE'; const panelContent = ( <> @@ -576,7 +606,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro

网页接收页

-

你现在打开的是公开接收链接,先选文件,再通过浏览器 P2P 通道接收并下载。

+

你现在打开的是公开接收链接。在线快传会走浏览器 P2P,离线快传会直接显示 7 天内可重复接收的文件。

) : null} @@ -650,11 +680,17 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro : '文件清单已同步,请勾选要接收的文件。')} {phase === 'connecting' && 'P2P 通道协商中...'} {phase === 'receiving' && '文件正在接收...'} - {phase === 'completed' && (archiveUrl ? '接收完成,ZIP 已准备好下载' : '接收完成,下面可以下载文件')} + {phase === 'completed' && (isOfflineSession + ? '离线文件已就绪,7 天内可以重复下载或存入网盘' + : archiveUrl + ? '接收完成,ZIP 已准备好下载' + : '接收完成,下面可以下载文件')} {phase === 'error' && '接收失败'}

- {errorMessage || `总进度 ${overallProgress}%`} + {errorMessage || (isOfflineSession && transferSession + ? `离线有效期至 ${new Date(transferSession.expiresAt).toLocaleString('zh-CN', {month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'})}` + : `总进度 ${overallProgress}%`)}

@@ -696,7 +732,9 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro

可接收文件

- {requestSubmitted + {isOfflineSession + ? `离线模式 · ${files.length} 项` + : requestSubmitted ? `已请求 ${requestedFiles.length} 项` : `已选择 ${selectedFiles.length} 项 · ${formatTransferSize(selectedSize)}`}

@@ -749,7 +787,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
{files.length === 0 ? (
- 连接建立后会先同步文件清单,你可以在这里先勾选想接收的内容。 + {isOfflineSession ? '离线文件上传完成后,会直接在这里显示可下载清单。' : '连接建立后会先同步文件清单,你可以在这里先勾选想接收的内容。'}
) : ( files.map((file) => ( @@ -831,15 +869,15 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
-

后端只做信令

-

当前页面通过后端交换 offer、answer 和 ICE candidate,但文件字节不走服务器中转。

+

在线走 P2P,离线走存储

+

在线快传继续通过信令交换建立浏览器直连;离线快传会直接从站点存储里下载。

-

先选文件,再接收下载

-

文件清单会先同步到页面,多文件可以勾选接收,整包接收时会在浏览器内直接生成 ZIP。

+

离线文件保留 7 天

+

离线快传接收之后文件也不会立刻消失,在有效期内还能再次打开链接重复接收。

) : null} @@ -851,7 +889,11 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro initialPath={saveRootPath} confirmLabel="存入这里" confirmPathPreview={(path) => { + const offlineFile = savePathPickerFileId ? files.find((file) => file.id === savePathPickerFileId) : null; const completedFile = savePathPickerFileId ? completedFilesRef.current.get(savePathPickerFileId) : null; + if (offlineFile) { + return resolveNetdiskSaveDirectory(offlineFile.relativePath, path); + } return completedFile ? resolveNetdiskSaveDirectory(completedFile.relativePath, path) : path; }} onClose={() => setSavePathPickerFileId(null)} @@ -860,7 +902,22 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro return; } setSaveRootPath(path); - await saveCompletedFile(savePathPickerFileId, path); + if (isOfflineSession && transferSession) { + const savedFile = await importOfflineTransferFile(transferSession.sessionId, savePathPickerFileId, path); + setFiles((current) => + current.map((file) => + file.id === savePathPickerFileId + ? { + ...file, + savedToNetdisk: true, + } + : file, + ), + ); + setSaveMessage(`${savedFile.filename} 已存入网盘 ${savedFile.path}`); + } else { + await saveCompletedFile(savePathPickerFileId, path); + } setSavePathPickerFileId(null); }} /> diff --git a/front/src/pages/transfer-state.test.ts b/front/src/pages/transfer-state.test.ts index 664feec..ef5c57d 100644 --- a/front/src/pages/transfer-state.test.ts +++ b/front/src/pages/transfer-state.test.ts @@ -8,6 +8,7 @@ import { canSendTransferFiles, createMockTransferCode, formatTransferSize, + getTransferModeSummary, resolveInitialTransferTab, sanitizeReceiveCode, } from './transfer-state'; @@ -60,6 +61,13 @@ test('canSendTransferFiles requires an authenticated session', () => { assert.equal(canSendTransferFiles(false), false); }); +test('getTransferModeSummary describes the offline seven-day retention rule', () => { + assert.deepEqual(getTransferModeSummary('OFFLINE'), { + title: '发离线', + description: '文件先上传到站点存储,保留 7 天,到期自动销毁,可被多次接收。', + }); +}); + test('canArchiveTransferSelection is enabled for multi-file or folder downloads', () => { assert.equal(canArchiveTransferSelection([ { diff --git a/front/src/pages/transfer-state.ts b/front/src/pages/transfer-state.ts index a8b5ce8..35571ae 100644 --- a/front/src/pages/transfer-state.ts +++ b/front/src/pages/transfer-state.ts @@ -1,3 +1,4 @@ +import type { TransferMode } from '../lib/types'; import type { TransferFileDescriptor } from '../lib/transfer-protocol'; export type TransferTab = 'send' | 'receive'; @@ -36,6 +37,20 @@ export function canSendTransferFiles(isAuthenticated: boolean) { return isAuthenticated; } +export function getTransferModeSummary(mode: TransferMode) { + if (mode === 'OFFLINE') { + return { + title: '发离线', + description: '文件先上传到站点存储,保留 7 天,到期自动销毁,可被多次接收。', + }; + } + + return { + title: '发在线', + description: '文件通过浏览器 P2P 直连发送,只能被接收一次,适合双方都在线时快速传输。', + }; +} + export function resolveInitialTransferTab( isAuthenticated: boolean, sessionId: string | null, diff --git a/memory.md b/memory.md index 3a2a923..66875b0 100644 --- a/memory.md +++ b/memory.md @@ -8,10 +8,12 @@ - 网盘已支持上传、下载、重命名、删除、移动、复制、公开分享、接收快传后存入 - 注册改成邀请码机制,邀请码单次使用后自动刷新,并在管理台展示与复制 - 同账号仅允许一台设备同时登录,旧设备会在下一次访问受保护接口时失效 + - 后端已补生产 CORS,默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz`,并已重新发布 - 根目录 README 已重写为中文公开版 GitHub 风格 - VS Code 工作区已补 `.vscode/settings.json`、`.vscode/extensions.json`、`lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor - 进行中: - 继续观察 VS Code Java/Lombok 误报是否完全消失 + - 继续排查 `api.yoyuzh.xyz` 在不同网络/设备下的 TLS/SNI 链路稳定性 - 后续如果再做 README/开源化展示,可以继续补 banner、截图和架构图 - 待开始: - 如果用户继续提需求,优先沿当前网站主线迭代,不再回到旧教务方向 @@ -27,11 +29,14 @@ | 单设备登录通过“用户当前会话 ID + JWT sid claim”实现 | 新登录能立即顶掉旧 access token,而不仅仅是旧 refresh token | 只撤销 refresh token: 旧 access token 仍会继续有效一段时间 | | 前端发布继续使用 `node scripts/deploy-front-oss.mjs` | 仓库已有正式 OSS 发布脚本,流程稳定 | 手动上传 OSS: 容易出错,也不利于复用 | | 后端发布继续采用“本地打包 + SSH/ SCP 上传 jar + systemd 重启” | 当前线上就按这个方式运行 | 自创部署脚本: 仓库里没有现成正式脚本,容易和现网偏离 | +| 主站 CORS 默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz` | 前端生产环境托管在 OSS 域名下,必须允许主站跨域调用后端 API | 仅保留 localhost: 会导致生产站调用 API 时被浏览器拦截 | ## 待解决问题 - [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误 - [ ] `front/README.md` 仍是旧模板风格说明,当前真实入口说明以根目录 `README.md` 为准,后续可继续整理 - [ ] 前端构建仍有 chunk size warning,目前不阻塞发布,但后续可以考虑做更细的拆包 +- [ ] `api.yoyuzh.xyz` 仍存在“同机房 IP 直连可用,但带域名 TLS/SNI 有时失败”的链路问题;这不是后端业务代码错误 +- [ ] 线上前端 bundle 当前仍内嵌 `https://api.yoyuzh.xyz/api`,API 子域名异常时会直接表现为“网络异常/登录失败” ## 关键约束 (只写这个任务特有的限制,区别于项目通用规则) @@ -42,6 +47,8 @@ - 已知线上后端服务名是 `my-site-api.service` - 已知线上后端运行包路径是 `/opt/yoyuzh/yoyuzh-portal-backend.jar` - 已知新服务器公网 IP 是 `1.14.49.201` +- 2026-03-23 排障确认:`api.yoyuzh.xyz` 在部分网络下存在 TLS/SNI 握手异常,但后端服务与 nginx 正常,且 IP 直连加 `Host: api.yoyuzh.xyz` 时可正常返回 +- 2026-03-23 实时日志确认:Mac 端 `202.202.9.243` 登录链路 `OPTIONS /api/auth/login -> POST /api/auth/login -> 后续 /api/*` 全部返回 200;手机失败时并不总能在服务端日志中看到对应登录请求 - 服务器登录信息保存在本地 `账号密码.txt`,不要把内容写进文档或对外输出 ## 参考资料 @@ -55,5 +62,7 @@ - 单设备登录: `backend/src/main/java/com/yoyuzh/auth/AuthService.java` - JWT 会话校验: `backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java` - JWT 过滤器: `backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java` + - CORS 配置: `backend/src/main/java/com/yoyuzh/config/CorsProperties.java`、`backend/src/main/resources/application.yml` - 网盘树状目录: `front/src/pages/Files.tsx`、`front/src/pages/files-tree.ts` - 快传接收页: `front/src/pages/TransferReceive.tsx` + - 前端生产 API 基址: `front/.env.production`