添加快传7天离线传
This commit is contained in:
@@ -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.
|
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
|
## Real project structure
|
||||||
|
|
||||||
- `backend/`: Spring Boot 3.3.8, Java 17, Maven, domain packages under `com.yoyuzh.{auth,cqu,files,config,common}`.
|
- `backend/`: Spring Boot 3.3.8, Java 17, Maven, domain packages under `com.yoyuzh.{auth,cqu,files,config,common}`.
|
||||||
|
|||||||
@@ -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(
|
private List<String> allowedOrigins = new ArrayList<>(List.of(
|
||||||
"http://localhost:3000",
|
"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() {
|
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, "目录暂不支持导入");
|
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(
|
byte[] content = fileContentStorage.readFile(
|
||||||
sourceFile.getUser().getId(),
|
sourceFile.getUser().getId(),
|
||||||
sourceFile.getPath(),
|
sourceFile.getPath(),
|
||||||
sourceFile.getStorageName()
|
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(
|
fileContentStorage.storeImportedFile(
|
||||||
recipient.getId(),
|
recipient.getId(),
|
||||||
normalizedPath,
|
normalizedPath,
|
||||||
filename,
|
normalizedFilename,
|
||||||
sourceFile.getContentType(),
|
contentType,
|
||||||
content
|
content
|
||||||
);
|
);
|
||||||
|
|
||||||
return saveFileMetadata(
|
return saveFileMetadata(
|
||||||
recipient,
|
recipient,
|
||||||
normalizedPath,
|
normalizedPath,
|
||||||
filename,
|
normalizedFilename,
|
||||||
filename,
|
normalizedFilename,
|
||||||
sourceFile.getContentType(),
|
contentType,
|
||||||
sourceFile.getSize()
|
size
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package com.yoyuzh.transfer;
|
|||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public record CreateTransferSessionRequest(
|
public record CreateTransferSessionRequest(
|
||||||
|
@NotNull(message = "传输模式不能为空")
|
||||||
|
TransferMode mode,
|
||||||
@NotEmpty(message = "至少选择一个文件")
|
@NotEmpty(message = "至少选择一个文件")
|
||||||
List<@Valid TransferFileItem> files
|
List<@Valid TransferFileItem> files
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import java.time.Instant;
|
|||||||
public record LookupTransferSessionResponse(
|
public record LookupTransferSessionResponse(
|
||||||
String sessionId,
|
String sessionId,
|
||||||
String pickupCode,
|
String pickupCode,
|
||||||
|
TransferMode mode,
|
||||||
Instant expiresAt
|
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;
|
package com.yoyuzh.transfer;
|
||||||
|
|
||||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||||
|
import com.yoyuzh.auth.User;
|
||||||
import com.yoyuzh.common.ApiResponse;
|
import com.yoyuzh.common.ApiResponse;
|
||||||
import com.yoyuzh.common.BusinessException;
|
import com.yoyuzh.common.BusinessException;
|
||||||
import com.yoyuzh.common.ErrorCode;
|
import com.yoyuzh.common.ErrorCode;
|
||||||
|
import com.yoyuzh.files.FileMetadataResponse;
|
||||||
|
import com.yoyuzh.files.ImportSharedFileRequest;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/transfer")
|
@RequestMapping("/api/transfer")
|
||||||
@@ -30,8 +36,8 @@ public class TransferController {
|
|||||||
public ApiResponse<TransferSessionResponse> createSession(@AuthenticationPrincipal UserDetails userDetails,
|
public ApiResponse<TransferSessionResponse> createSession(@AuthenticationPrincipal UserDetails userDetails,
|
||||||
@Valid @RequestBody CreateTransferSessionRequest request) {
|
@Valid @RequestBody CreateTransferSessionRequest request) {
|
||||||
requireAuthenticatedUser(userDetails);
|
requireAuthenticatedUser(userDetails);
|
||||||
userDetailsService.loadDomainUser(userDetails.getUsername());
|
User sender = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||||
return ApiResponse.success(transferService.createSession(request));
|
return ApiResponse.success(transferService.createSession(sender, request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "通过取件码查找快传会话")
|
@Operation(summary = "通过取件码查找快传会话")
|
||||||
@@ -46,6 +52,44 @@ public class TransferController {
|
|||||||
return ApiResponse.success(transferService.joinSession(sessionId));
|
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 = "提交快传信令")
|
@Operation(summary = "提交快传信令")
|
||||||
@PostMapping("/sessions/{sessionId}/signals")
|
@PostMapping("/sessions/{sessionId}/signals")
|
||||||
public ApiResponse<Void> postSignal(@PathVariable String sessionId,
|
public ApiResponse<Void> postSignal(@PathVariable String sessionId,
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ import jakarta.validation.constraints.Min;
|
|||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
public record TransferFileItem(
|
public record TransferFileItem(
|
||||||
|
String id,
|
||||||
@NotBlank(message = "文件名不能为空")
|
@NotBlank(message = "文件名不能为空")
|
||||||
String name,
|
String name,
|
||||||
|
String relativePath,
|
||||||
@Min(value = 0, message = "文件大小不能为负数")
|
@Min(value = 0, message = "文件大小不能为负数")
|
||||||
long size,
|
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;
|
package com.yoyuzh.transfer;
|
||||||
|
|
||||||
|
import com.yoyuzh.auth.User;
|
||||||
import com.yoyuzh.common.BusinessException;
|
import com.yoyuzh.common.BusinessException;
|
||||||
import com.yoyuzh.common.ErrorCode;
|
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.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.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class TransferService {
|
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 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.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();
|
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 sessionId = UUID.randomUUID().toString();
|
||||||
String pickupCode = sessionStore.nextPickupCode();
|
String pickupCode = nextPickupCode();
|
||||||
Instant expiresAt = Instant.now().plus(SESSION_TTL);
|
Instant expiresAt = Instant.now().plus(ONLINE_SESSION_TTL);
|
||||||
List<TransferFileItem> files = request.files().stream()
|
List<TransferFileItem> files = request.files().stream()
|
||||||
.map(file -> new TransferFileItem(file.name(), file.size(), normalizeContentType(file.contentType())))
|
.map(this::normalizeOnlineFileItem)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
TransferSession session = new TransferSession(sessionId, pickupCode, expiresAt, files);
|
TransferSession session = new TransferSession(sessionId, pickupCode, expiresAt, files);
|
||||||
@@ -36,46 +200,150 @@ public class TransferService {
|
|||||||
return session.toSessionResponse();
|
return session.toSessionResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
public LookupTransferSessionResponse lookupSession(String pickupCode) {
|
private TransferSessionResponse createOfflineSession(User sender, CreateTransferSessionRequest request) {
|
||||||
pruneExpiredSessions();
|
OfflineTransferSession session = new OfflineTransferSession();
|
||||||
String normalizedPickupCode = normalizePickupCode(pickupCode);
|
session.setSessionId(UUID.randomUUID().toString());
|
||||||
TransferSession session = sessionStore.findByPickupCode(normalizedPickupCode)
|
session.setPickupCode(nextPickupCode());
|
||||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效"));
|
session.setSenderUserId(sender.getId());
|
||||||
return session.toLookupResponse();
|
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) {
|
private TransferFileItem normalizeOnlineFileItem(TransferFileItem file) {
|
||||||
pruneExpiredSessions();
|
String normalizedFilename = normalizeLeafName(file.name());
|
||||||
TransferSession session = getRequiredSession(sessionId);
|
String normalizedRelativePath = normalizeRelativePath(file.relativePath(), normalizedFilename);
|
||||||
session.markReceiverJoined();
|
return new TransferFileItem(
|
||||||
return session.toSessionResponse();
|
null,
|
||||||
|
normalizedFilename,
|
||||||
|
normalizedRelativePath,
|
||||||
|
file.size(),
|
||||||
|
normalizeContentType(file.contentType()),
|
||||||
|
null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void postSignal(String sessionId, String role, TransferSignalRequest request) {
|
private TransferSessionResponse toSessionResponse(OfflineTransferSession session) {
|
||||||
pruneExpiredSessions();
|
return new TransferSessionResponse(
|
||||||
TransferSession session = getRequiredSession(sessionId);
|
session.getSessionId(),
|
||||||
session.enqueue(TransferRole.from(role), request.type().trim(), request.payload().trim());
|
session.getPickupCode(),
|
||||||
|
TransferMode.OFFLINE,
|
||||||
|
session.getExpiresAt(),
|
||||||
|
session.getFiles().stream().map(this::toFileItem).toList()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public PollTransferSignalsResponse pollSignals(String sessionId, String role, long after) {
|
private LookupTransferSessionResponse toLookupResponse(OfflineTransferSession session) {
|
||||||
pruneExpiredSessions();
|
return new LookupTransferSessionResponse(
|
||||||
TransferSession session = getRequiredSession(sessionId);
|
session.getSessionId(),
|
||||||
return session.poll(TransferRole.from(role), Math.max(0, after));
|
session.getPickupCode(),
|
||||||
|
TransferMode.OFFLINE,
|
||||||
|
session.getExpiresAt()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private TransferSession getRequiredSession(String sessionId) {
|
private TransferFileItem toFileItem(OfflineTransferFile file) {
|
||||||
TransferSession session = sessionStore.findById(sessionId).orElse(null);
|
return new TransferFileItem(
|
||||||
if (session == null || session.isExpired(Instant.now())) {
|
file.getId(),
|
||||||
if (session != null) {
|
file.getFilename(),
|
||||||
sessionStore.remove(session);
|
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;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void pruneExpiredSessions() {
|
private OfflineTransferSession getRequiredOfflineReadySession(String sessionId) {
|
||||||
sessionStore.pruneExpired(Instant.now());
|
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) {
|
private String normalizePickupCode(String pickupCode) {
|
||||||
@@ -90,4 +358,45 @@ public class TransferService {
|
|||||||
String normalized = Objects.requireNonNullElse(contentType, "").trim();
|
String normalized = Objects.requireNonNullElse(contentType, "").trim();
|
||||||
return normalized.isEmpty() ? "application/octet-stream" : normalized;
|
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() {
|
synchronized TransferSessionResponse toSessionResponse() {
|
||||||
return new TransferSessionResponse(sessionId, pickupCode, expiresAt, files);
|
return new TransferSessionResponse(sessionId, pickupCode, TransferMode.ONLINE, expiresAt, files);
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized LookupTransferSessionResponse toLookupResponse() {
|
synchronized LookupTransferSessionResponse toLookupResponse() {
|
||||||
return new LookupTransferSessionResponse(sessionId, pickupCode, expiresAt);
|
return new LookupTransferSessionResponse(sessionId, pickupCode, TransferMode.ONLINE, expiresAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized void markReceiverJoined() {
|
synchronized void markReceiverJoined() {
|
||||||
if (receiverJoined) {
|
if (receiverJoined) {
|
||||||
return;
|
throw new IllegalStateException("在线快传仅支持一次接收");
|
||||||
}
|
}
|
||||||
|
|
||||||
receiverJoined = true;
|
receiverJoined = true;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import java.util.List;
|
|||||||
public record TransferSessionResponse(
|
public record TransferSessionResponse(
|
||||||
String sessionId,
|
String sessionId,
|
||||||
String pickupCode,
|
String pickupCode,
|
||||||
|
TransferMode mode,
|
||||||
Instant expiresAt,
|
Instant expiresAt,
|
||||||
List<TransferFileItem> files
|
List<TransferFileItem> files
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ app:
|
|||||||
allowed-origins:
|
allowed-origins:
|
||||||
- http://localhost:3000
|
- http://localhost:3000
|
||||||
- http://127.0.0.1:3000
|
- http://127.0.0.1:3000
|
||||||
|
- https://yoyuzh.xyz
|
||||||
|
- https://www.yoyuzh.xyz
|
||||||
|
|
||||||
springdoc:
|
springdoc:
|
||||||
swagger-ui:
|
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 {
|
class SecurityConfigTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void corsPropertiesShouldAllowProductionSiteOriginsByDefault() {
|
||||||
|
CorsProperties corsProperties = new CorsProperties();
|
||||||
|
|
||||||
|
assertThat(corsProperties.getAllowedOrigins())
|
||||||
|
.contains("https://yoyuzh.xyz", "https://www.yoyuzh.xyz");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void corsConfigurationShouldAllowPatchRequests() {
|
void corsConfigurationShouldAllowPatchRequests() {
|
||||||
CorsProperties corsProperties = new CorsProperties();
|
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.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
import org.springframework.security.test.context.support.WithMockUser;
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
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.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
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.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@SpringBootTest(
|
@SpringBootTest(
|
||||||
@@ -60,8 +66,9 @@ class TransferControllerIntegrationTest {
|
|||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("""
|
.content("""
|
||||||
{
|
{
|
||||||
|
"mode": "ONLINE",
|
||||||
"files": [
|
"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("$.code").value(0))
|
||||||
.andExpect(jsonPath("$.data.sessionId").isNotEmpty())
|
.andExpect(jsonPath("$.data.sessionId").isNotEmpty())
|
||||||
.andExpect(jsonPath("$.data.pickupCode").isString())
|
.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].name").value("report.pdf"))
|
||||||
|
.andExpect(jsonPath("$.data.files[0].relativePath").value("课程资料/report.pdf"))
|
||||||
.andReturn()
|
.andReturn()
|
||||||
.getResponse()
|
.getResponse()
|
||||||
.getContentAsString();
|
.getContentAsString();
|
||||||
@@ -80,11 +89,13 @@ class TransferControllerIntegrationTest {
|
|||||||
mockMvc.perform(get("/api/transfer/sessions/lookup").param("pickupCode", pickupCode))
|
mockMvc.perform(get("/api/transfer/sessions/lookup").param("pickupCode", pickupCode))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.data.sessionId").value(sessionId))
|
.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))
|
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", sessionId))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.data.sessionId").value(sessionId))
|
.andExpect(jsonPath("$.data.sessionId").value(sessionId))
|
||||||
|
.andExpect(jsonPath("$.data.mode").value("ONLINE"))
|
||||||
.andExpect(jsonPath("$.data.files[0].name").value("report.pdf"));
|
.andExpect(jsonPath("$.data.files[0].name").value("report.pdf"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/signals", sessionId)
|
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/signals", sessionId)
|
||||||
@@ -113,11 +124,71 @@ class TransferControllerIntegrationTest {
|
|||||||
mockMvc.perform(post("/api/transfer/sessions")
|
mockMvc.perform(post("/api/transfer/sessions")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("""
|
.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());
|
.andExpect(status().isUnauthorized());
|
||||||
|
|
||||||
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", "missing-session"))
|
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", "missing-session"))
|
||||||
.andExpect(status().isNotFound());
|
.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.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
class TransferSessionTest {
|
class TransferSessionTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldEmitPeerJoinedOnlyOnceWhenReceiverJoinsRepeatedly() {
|
void shouldEmitPeerJoinedOnFirstReceiverJoin() {
|
||||||
TransferSession session = new TransferSession(
|
TransferSession session = new TransferSession(
|
||||||
"session-1",
|
"session-1",
|
||||||
"849201",
|
"849201",
|
||||||
@@ -18,7 +19,6 @@ class TransferSessionTest {
|
|||||||
List.of(new TransferFileItem("report.pdf", 2048, "application/pdf"))
|
List.of(new TransferFileItem("report.pdf", 2048, "application/pdf"))
|
||||||
);
|
);
|
||||||
|
|
||||||
session.markReceiverJoined();
|
|
||||||
session.markReceiverJoined();
|
session.markReceiverJoined();
|
||||||
|
|
||||||
PollTransferSignalsResponse senderSignals = session.poll(TransferRole.SENDER, 0);
|
PollTransferSignalsResponse senderSignals = session.poll(TransferRole.SENDER, 0);
|
||||||
@@ -29,6 +29,21 @@ class TransferSessionTest {
|
|||||||
assertThat(senderSignals.nextCursor()).isEqualTo(1);
|
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
|
@Test
|
||||||
void shouldRouteSignalsToTheOppositeRoleQueue() {
|
void shouldRouteSignalsToTheOppositeRoleQueue() {
|
||||||
TransferSession session = new TransferSession(
|
TransferSession session = new TransferSession(
|
||||||
|
|||||||
350
docs/api-reference.md
Normal file
350
docs/api-reference.md
Normal file
@@ -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 <accessToken>`
|
||||||
|
- `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`
|
||||||
340
docs/architecture.md
Normal file
340
docs/architecture.md
Normal file
@@ -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 <token>` 访问
|
||||||
|
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
|
||||||
|
- 紧邻测试文件
|
||||||
@@ -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**
|
||||||
30
front/src/lib/transfer.test.ts
Normal file
30
front/src/lib/transfer.test.ts
Normal file
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import type { FileMetadata, TransferMode } from './types';
|
||||||
import { apiRequest } from './api';
|
import { apiRequest } from './api';
|
||||||
|
import { apiUploadRequest } from './api';
|
||||||
|
import { getTransferFileRelativePath } from './transfer-protocol';
|
||||||
import type {
|
import type {
|
||||||
LookupTransferSessionResponse,
|
LookupTransferSessionResponse,
|
||||||
PollTransferSignalsResponse,
|
PollTransferSignalsResponse,
|
||||||
@@ -13,15 +16,17 @@ export const DEFAULT_TRANSFER_ICE_SERVERS: RTCIceServer[] = [
|
|||||||
export function toTransferFilePayload(files: File[]) {
|
export function toTransferFilePayload(files: File[]) {
|
||||||
return files.map((file) => ({
|
return files.map((file) => ({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
|
relativePath: getTransferFileRelativePath(file),
|
||||||
size: file.size,
|
size: file.size,
|
||||||
contentType: file.type || 'application/octet-stream',
|
contentType: file.type || 'application/octet-stream',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTransferSession(files: File[]) {
|
export function createTransferSession(files: File[], mode: TransferMode) {
|
||||||
return apiRequest<TransferSessionResponse>('/transfer/sessions', {
|
return apiRequest<TransferSessionResponse>('/transfer/sessions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
|
mode,
|
||||||
files: toTransferFilePayload(files),
|
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<void>(`/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<FileMetadata>(
|
||||||
|
`/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/import`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
path,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function postTransferSignal(sessionId: string, role: 'sender' | 'receiver', type: string, payload: string) {
|
export function postTransferSignal(sessionId: string, role: 'sender' | 'receiver', type: string, payload: string) {
|
||||||
return apiRequest<void>(`/transfer/sessions/${encodeURIComponent(sessionId)}/signals?role=${role}`, {
|
return apiRequest<void>(`/transfer/sessions/${encodeURIComponent(sessionId)}/signals?role=${role}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -106,15 +106,21 @@ export interface FileShareDetailsResponse {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TransferMode = 'ONLINE' | 'OFFLINE';
|
||||||
|
|
||||||
export interface TransferFileItem {
|
export interface TransferFileItem {
|
||||||
|
id?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
relativePath: string;
|
||||||
size: number;
|
size: number;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
|
uploaded?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransferSessionResponse {
|
export interface TransferSessionResponse {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
pickupCode: string;
|
pickupCode: string;
|
||||||
|
mode: TransferMode;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
files: TransferFileItem[];
|
files: TransferFileItem[];
|
||||||
}
|
}
|
||||||
@@ -122,6 +128,7 @@ export interface TransferSessionResponse {
|
|||||||
export interface LookupTransferSessionResponse {
|
export interface LookupTransferSessionResponse {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
pickupCode: string;
|
pickupCode: string;
|
||||||
|
mode: TransferMode;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,19 +36,26 @@ import {
|
|||||||
} from '@/src/lib/transfer-protocol';
|
} from '@/src/lib/transfer-protocol';
|
||||||
import { waitForTransferChannelDrain } from '@/src/lib/transfer-runtime';
|
import { waitForTransferChannelDrain } from '@/src/lib/transfer-runtime';
|
||||||
import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling';
|
import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling';
|
||||||
import { DEFAULT_TRANSFER_ICE_SERVERS, createTransferSession, pollTransferSignals, postTransferSignal } from '@/src/lib/transfer';
|
import {
|
||||||
import type { TransferSessionResponse } from '@/src/lib/types';
|
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 { cn } from '@/src/lib/utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildQrImageUrl,
|
buildQrImageUrl,
|
||||||
canSendTransferFiles,
|
canSendTransferFiles,
|
||||||
formatTransferSize,
|
formatTransferSize,
|
||||||
|
getTransferModeSummary,
|
||||||
resolveInitialTransferTab,
|
resolveInitialTransferTab,
|
||||||
} from './transfer-state';
|
} from './transfer-state';
|
||||||
import TransferReceive from './TransferReceive';
|
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<T>(payload: string): T | null {
|
function parseJsonPayload<T>(payload: string): T | null {
|
||||||
try {
|
try {
|
||||||
@@ -58,7 +65,22 @@ function parseJsonPayload<T>(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) {
|
switch (phase) {
|
||||||
case 'creating':
|
case 'creating':
|
||||||
return '正在创建快传会话并准备 P2P 连接...';
|
return '正在创建快传会话并准备 P2P 连接...';
|
||||||
@@ -85,6 +107,7 @@ export default function Transfer() {
|
|||||||
const [activeTab, setActiveTab] = useState(() => resolveInitialTransferTab(allowSend, sessionId));
|
const [activeTab, setActiveTab] = useState(() => resolveInitialTransferTab(allowSend, sessionId));
|
||||||
|
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
|
const [transferMode, setTransferMode] = useState<TransferMode>('ONLINE');
|
||||||
const [session, setSession] = useState<TransferSessionResponse | null>(null);
|
const [session, setSession] = useState<TransferSessionResponse | null>(null);
|
||||||
const [sendPhase, setSendPhase] = useState<SendPhase>('idle');
|
const [sendPhase, setSendPhase] = useState<SendPhase>('idle');
|
||||||
const [sendProgress, setSendProgress] = useState(0);
|
const [sendProgress, setSendProgress] = useState(0);
|
||||||
@@ -129,11 +152,20 @@ export default function Transfer() {
|
|||||||
}
|
}
|
||||||
}, [allowSend, sessionId]);
|
}, [allowSend, sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFiles.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrapTransfer(selectedFiles);
|
||||||
|
}, [transferMode]);
|
||||||
|
|
||||||
const totalSize = selectedFiles.reduce((sum, file) => sum + file.size, 0);
|
const totalSize = selectedFiles.reduce((sum, file) => sum + file.size, 0);
|
||||||
const shareLink = session
|
const shareLink = session
|
||||||
? buildTransferShareUrl(window.location.origin, session.sessionId, getTransferRouterMode())
|
? buildTransferShareUrl(window.location.origin, session.sessionId, getTransferRouterMode())
|
||||||
: '';
|
: '';
|
||||||
const qrImageUrl = shareLink ? buildQrImageUrl(shareLink) : '';
|
const qrImageUrl = shareLink ? buildQrImageUrl(shareLink) : '';
|
||||||
|
const transferModeSummary = getTransferModeSummary(transferMode);
|
||||||
|
|
||||||
function cleanupCurrentTransfer() {
|
function cleanupCurrentTransfer() {
|
||||||
if (pollTimerRef.current) {
|
if (pollTimerRef.current) {
|
||||||
@@ -229,12 +261,17 @@ export default function Transfer() {
|
|||||||
sentBytesRef.current = 0;
|
sentBytesRef.current = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const createdSession = await createTransferSession(files);
|
const createdSession = await createTransferSession(files, transferMode);
|
||||||
if (bootstrapIdRef.current !== bootstrapId) {
|
if (bootstrapIdRef.current !== bootstrapId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSession(createdSession);
|
setSession(createdSession);
|
||||||
|
if (createdSession.mode === 'OFFLINE') {
|
||||||
|
await uploadOfflineFiles(createdSession, files, bootstrapId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSendPhase('waiting');
|
setSendPhase('waiting');
|
||||||
await setupSenderPeer(createdSession, files, bootstrapId);
|
await setupSenderPeer(createdSession, files, bootstrapId);
|
||||||
} catch (error) {
|
} 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) {
|
async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
|
||||||
const connection = new RTCPeerConnection({
|
const connection = new RTCPeerConnection({
|
||||||
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
|
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
|
||||||
@@ -439,8 +512,8 @@ export default function Transfer() {
|
|||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-[#336EFF] via-blue-500 to-cyan-400 shadow-lg shadow-[#336EFF]/20 mb-6">
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-[#336EFF] via-blue-500 to-cyan-400 shadow-lg shadow-[#336EFF]/20 mb-6">
|
||||||
<Send className="w-8 h-8 text-white" />
|
<Send className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-3">P2P 快传</h1>
|
<h1 className="text-3xl font-bold text-white mb-3">快传</h1>
|
||||||
<p className="text-slate-400">二维码负责把对方带到网页,真正的文件内容在两个浏览器之间通过 P2P 直连传输。</p>
|
<p className="text-slate-400">在线快传走浏览器 P2P 一次性传输,离线快传会把文件存到站点存储里保留 7 天,可被反复接收。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-panel border border-white/10 rounded-3xl overflow-hidden bg-[#0f172a]/80 backdrop-blur-xl shadow-2xl">
|
<div className="glass-panel border border-white/10 rounded-3xl overflow-hidden bg-[#0f172a]/80 backdrop-blur-xl shadow-2xl">
|
||||||
@@ -490,6 +563,38 @@ export default function Transfer() {
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="flex-1 flex flex-col h-full min-w-0"
|
className="flex-1 flex flex-col h-full min-w-0"
|
||||||
>
|
>
|
||||||
|
<div className="mb-6 grid gap-3 md:grid-cols-2">
|
||||||
|
{(['ONLINE', 'OFFLINE'] as TransferMode[]).map((mode) => {
|
||||||
|
const summary = getTransferModeSummary(mode);
|
||||||
|
const active = transferMode === mode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTransferMode(mode)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl border p-4 text-left transition-colors',
|
||||||
|
active
|
||||||
|
? 'border-blue-400/40 bg-blue-500/10'
|
||||||
|
: 'border-white/10 bg-white/[0.03] hover:bg-white/[0.05]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-semibold text-white">{summary.title}</p>
|
||||||
|
<span className={cn(
|
||||||
|
'rounded-full px-2.5 py-1 text-[11px] font-medium',
|
||||||
|
active ? 'bg-blue-400/15 text-blue-100' : 'bg-white/10 text-slate-300',
|
||||||
|
)}>
|
||||||
|
{mode === 'ONLINE' ? '一次接收' : '7 天多次'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-400">{summary.description}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{selectedFiles.length === 0 ? (
|
{selectedFiles.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className="flex-1 border-2 border-dashed border-white/10 rounded-2xl flex flex-col items-center justify-center p-10 transition-colors hover:border-[#336EFF]/50 hover:bg-[#336EFF]/5"
|
className="flex-1 border-2 border-dashed border-white/10 rounded-2xl flex flex-col items-center justify-center p-10 transition-colors hover:border-[#336EFF]/50 hover:bg-[#336EFF]/5"
|
||||||
@@ -501,7 +606,7 @@ export default function Transfer() {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-medium text-white mb-2">拖拽文件或文件夹到此处</h3>
|
<h3 className="text-xl font-medium text-white mb-2">拖拽文件或文件夹到此处</h3>
|
||||||
<p className="text-slate-400 mb-8 text-center max-w-md">
|
<p className="text-slate-400 mb-8 text-center max-w-md">
|
||||||
选中文件后会自动创建一条公开接收链接,扫码打开网页就能在浏览器之间发起 P2P 下载。
|
{transferModeSummary.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||||
<Button onClick={() => fileInputRef.current?.click()} className="bg-[#336EFF] hover:bg-blue-600 text-white px-8">
|
<Button onClick={() => fileInputRef.current?.click()} className="bg-[#336EFF] hover:bg-blue-600 text-white px-8">
|
||||||
@@ -626,10 +731,10 @@ export default function Transfer() {
|
|||||||
? 'text-emerald-300'
|
? 'text-emerald-300'
|
||||||
: 'text-blue-300',
|
: 'text-blue-300',
|
||||||
)}>
|
)}>
|
||||||
{getPhaseMessage(sendPhase, sendError)}
|
{getPhaseMessage(transferMode, sendPhase, sendError)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
发送进度 {sendProgress}%{session ? ` · 会话有效期至 ${new Date(session.expiresAt).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'})}` : ''}
|
发送进度 {sendProgress}%{session ? ` · 会话有效期至 ${new Date(session.expiresAt).toLocaleString('zh-CN', {month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'})}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -680,8 +785,8 @@ export default function Transfer() {
|
|||||||
<Monitor className="w-5 h-5 text-cyan-400" />
|
<Monitor className="w-5 h-5 text-cyan-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-slate-200 mb-1">面向一次性分享</h4>
|
<h4 className="text-sm font-medium text-slate-200 mb-1">在线一次性,离线可重复</h4>
|
||||||
<p className="text-xs text-slate-500 leading-relaxed">更适合把压缩包、截图和临时资料从当前浏览器快速交给另一台设备。</p>
|
<p className="text-xs text-slate-500 leading-relaxed">在线模式适合临时快传,离线模式会保留 7 天,接收后文件也不会立刻消失。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,15 @@ import {
|
|||||||
type TransferFileDescriptor,
|
type TransferFileDescriptor,
|
||||||
} from '@/src/lib/transfer-protocol';
|
} from '@/src/lib/transfer-protocol';
|
||||||
import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling';
|
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 type { TransferSessionResponse } from '@/src/lib/types';
|
||||||
|
|
||||||
import { canArchiveTransferSelection, formatTransferSize, sanitizeReceiveCode } from './transfer-state';
|
import { canArchiveTransferSelection, formatTransferSize, sanitizeReceiveCode } from './transfer-state';
|
||||||
@@ -164,7 +172,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
|||||||
setArchiveName(buildTransferArchiveFileName('快传文件'));
|
setArchiveName(buildTransferArchiveFileName('快传文件'));
|
||||||
setArchiveUrl(null);
|
setArchiveUrl(null);
|
||||||
setSavingFileId(null);
|
setSavingFileId(null);
|
||||||
setSaveMessage('');
|
setSaveMessage('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const joinedSession = await joinTransferSession(sessionId);
|
const joinedSession = await joinTransferSession(sessionId);
|
||||||
@@ -175,6 +183,27 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
|||||||
setTransferSession(joinedSession);
|
setTransferSession(joinedSession);
|
||||||
setArchiveName(buildTransferArchiveFileName(`快传-${joinedSession.pickupCode}`));
|
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({
|
const connection = new RTCPeerConnection({
|
||||||
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
|
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
|
||||||
});
|
});
|
||||||
@@ -567,6 +596,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
|||||||
const canZipAllFiles = canArchiveTransferSelection(files);
|
const canZipAllFiles = canArchiveTransferSelection(files);
|
||||||
const hasSelectableFiles = selectedFiles.length > 0;
|
const hasSelectableFiles = selectedFiles.length > 0;
|
||||||
const canSubmitSelection = Boolean(dataChannelRef.current && dataChannelRef.current.readyState === 'open' && hasSelectableFiles);
|
const canSubmitSelection = Boolean(dataChannelRef.current && dataChannelRef.current.readyState === 'open' && hasSelectableFiles);
|
||||||
|
const isOfflineSession = transferSession?.mode === 'OFFLINE';
|
||||||
|
|
||||||
const panelContent = (
|
const panelContent = (
|
||||||
<>
|
<>
|
||||||
@@ -576,7 +606,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
|||||||
<DownloadCloud className="h-8 w-8 text-white" />
|
<DownloadCloud className="h-8 w-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold mb-3">网页接收页</h1>
|
<h1 className="text-3xl font-bold mb-3">网页接收页</h1>
|
||||||
<p className="text-slate-400">你现在打开的是公开接收链接,先选文件,再通过浏览器 P2P 通道接收并下载。</p>
|
<p className="text-slate-400">你现在打开的是公开接收链接。在线快传会走浏览器 P2P,离线快传会直接显示 7 天内可重复接收的文件。</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -650,11 +680,17 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
|||||||
: '文件清单已同步,请勾选要接收的文件。')}
|
: '文件清单已同步,请勾选要接收的文件。')}
|
||||||
{phase === 'connecting' && 'P2P 通道协商中...'}
|
{phase === 'connecting' && 'P2P 通道协商中...'}
|
||||||
{phase === 'receiving' && '文件正在接收...'}
|
{phase === 'receiving' && '文件正在接收...'}
|
||||||
{phase === 'completed' && (archiveUrl ? '接收完成,ZIP 已准备好下载' : '接收完成,下面可以下载文件')}
|
{phase === 'completed' && (isOfflineSession
|
||||||
|
? '离线文件已就绪,7 天内可以重复下载或存入网盘'
|
||||||
|
: archiveUrl
|
||||||
|
? '接收完成,ZIP 已准备好下载'
|
||||||
|
: '接收完成,下面可以下载文件')}
|
||||||
{phase === 'error' && '接收失败'}
|
{phase === 'error' && '接收失败'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
{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}%`)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -696,7 +732,9 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">可接收文件</h3>
|
<h3 className="text-lg font-medium">可接收文件</h3>
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
{requestSubmitted
|
{isOfflineSession
|
||||||
|
? `离线模式 · ${files.length} 项`
|
||||||
|
: requestSubmitted
|
||||||
? `已请求 ${requestedFiles.length} 项`
|
? `已请求 ${requestedFiles.length} 项`
|
||||||
: `已选择 ${selectedFiles.length} 项 · ${formatTransferSize(selectedSize)}`}
|
: `已选择 ${selectedFiles.length} 项 · ${formatTransferSize(selectedSize)}`}
|
||||||
</p>
|
</p>
|
||||||
@@ -749,7 +787,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{files.length === 0 ? (
|
{files.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-5 text-sm text-slate-500">
|
<div className="rounded-xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-5 text-sm text-slate-500">
|
||||||
连接建立后会先同步文件清单,你可以在这里先勾选想接收的内容。
|
{isOfflineSession ? '离线文件上传完成后,会直接在这里显示可下载清单。' : '连接建立后会先同步文件清单,你可以在这里先勾选想接收的内容。'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
files.map((file) => (
|
files.map((file) => (
|
||||||
@@ -831,15 +869,15 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
|||||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-emerald-500/10">
|
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-emerald-500/10">
|
||||||
<Shield className="h-5 w-5 text-emerald-400" />
|
<Shield className="h-5 w-5 text-emerald-400" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-sm font-medium text-slate-100 mb-1">后端只做信令</h4>
|
<h4 className="text-sm font-medium text-slate-100 mb-1">在线走 P2P,离线走存储</h4>
|
||||||
<p className="text-xs leading-6 text-slate-500">当前页面通过后端交换 offer、answer 和 ICE candidate,但文件字节不走服务器中转。</p>
|
<p className="text-xs leading-6 text-slate-500">在线快传继续通过信令交换建立浏览器直连;离线快传会直接从站点存储里下载。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-white/5 bg-white/[0.02] p-5">
|
<div className="rounded-2xl border border-white/5 bg-white/[0.02] p-5">
|
||||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-cyan-500/10">
|
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-cyan-500/10">
|
||||||
<Archive className="h-5 w-5 text-cyan-400" />
|
<Archive className="h-5 w-5 text-cyan-400" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-sm font-medium text-slate-100 mb-1">先选文件,再接收下载</h4>
|
<h4 className="text-sm font-medium text-slate-100 mb-1">离线文件保留 7 天</h4>
|
||||||
<p className="text-xs leading-6 text-slate-500">文件清单会先同步到页面,多文件可以勾选接收,整包接收时会在浏览器内直接生成 ZIP。</p>
|
<p className="text-xs leading-6 text-slate-500">离线快传接收之后文件也不会立刻消失,在有效期内还能再次打开链接重复接收。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -851,7 +889,11 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
|||||||
initialPath={saveRootPath}
|
initialPath={saveRootPath}
|
||||||
confirmLabel="存入这里"
|
confirmLabel="存入这里"
|
||||||
confirmPathPreview={(path) => {
|
confirmPathPreview={(path) => {
|
||||||
|
const offlineFile = savePathPickerFileId ? files.find((file) => file.id === savePathPickerFileId) : null;
|
||||||
const completedFile = savePathPickerFileId ? completedFilesRef.current.get(savePathPickerFileId) : null;
|
const completedFile = savePathPickerFileId ? completedFilesRef.current.get(savePathPickerFileId) : null;
|
||||||
|
if (offlineFile) {
|
||||||
|
return resolveNetdiskSaveDirectory(offlineFile.relativePath, path);
|
||||||
|
}
|
||||||
return completedFile ? resolveNetdiskSaveDirectory(completedFile.relativePath, path) : path;
|
return completedFile ? resolveNetdiskSaveDirectory(completedFile.relativePath, path) : path;
|
||||||
}}
|
}}
|
||||||
onClose={() => setSavePathPickerFileId(null)}
|
onClose={() => setSavePathPickerFileId(null)}
|
||||||
@@ -860,7 +902,22 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaveRootPath(path);
|
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);
|
setSavePathPickerFileId(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
canSendTransferFiles,
|
canSendTransferFiles,
|
||||||
createMockTransferCode,
|
createMockTransferCode,
|
||||||
formatTransferSize,
|
formatTransferSize,
|
||||||
|
getTransferModeSummary,
|
||||||
resolveInitialTransferTab,
|
resolveInitialTransferTab,
|
||||||
sanitizeReceiveCode,
|
sanitizeReceiveCode,
|
||||||
} from './transfer-state';
|
} from './transfer-state';
|
||||||
@@ -60,6 +61,13 @@ test('canSendTransferFiles requires an authenticated session', () => {
|
|||||||
assert.equal(canSendTransferFiles(false), false);
|
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', () => {
|
test('canArchiveTransferSelection is enabled for multi-file or folder downloads', () => {
|
||||||
assert.equal(canArchiveTransferSelection([
|
assert.equal(canArchiveTransferSelection([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { TransferMode } from '../lib/types';
|
||||||
import type { TransferFileDescriptor } from '../lib/transfer-protocol';
|
import type { TransferFileDescriptor } from '../lib/transfer-protocol';
|
||||||
|
|
||||||
export type TransferTab = 'send' | 'receive';
|
export type TransferTab = 'send' | 'receive';
|
||||||
@@ -36,6 +37,20 @@ export function canSendTransferFiles(isAuthenticated: boolean) {
|
|||||||
return isAuthenticated;
|
return isAuthenticated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTransferModeSummary(mode: TransferMode) {
|
||||||
|
if (mode === 'OFFLINE') {
|
||||||
|
return {
|
||||||
|
title: '发离线',
|
||||||
|
description: '文件先上传到站点存储,保留 7 天,到期自动销毁,可被多次接收。',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '发在线',
|
||||||
|
description: '文件通过浏览器 P2P 直连发送,只能被接收一次,适合双方都在线时快速传输。',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveInitialTransferTab(
|
export function resolveInitialTransferTab(
|
||||||
isAuthenticated: boolean,
|
isAuthenticated: boolean,
|
||||||
sessionId: string | null,
|
sessionId: string | null,
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
- 网盘已支持上传、下载、重命名、删除、移动、复制、公开分享、接收快传后存入
|
- 网盘已支持上传、下载、重命名、删除、移动、复制、公开分享、接收快传后存入
|
||||||
- 注册改成邀请码机制,邀请码单次使用后自动刷新,并在管理台展示与复制
|
- 注册改成邀请码机制,邀请码单次使用后自动刷新,并在管理台展示与复制
|
||||||
- 同账号仅允许一台设备同时登录,旧设备会在下一次访问受保护接口时失效
|
- 同账号仅允许一台设备同时登录,旧设备会在下一次访问受保护接口时失效
|
||||||
|
- 后端已补生产 CORS,默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz`,并已重新发布
|
||||||
- 根目录 README 已重写为中文公开版 GitHub 风格
|
- 根目录 README 已重写为中文公开版 GitHub 风格
|
||||||
- VS Code 工作区已补 `.vscode/settings.json`、`.vscode/extensions.json`、`lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor
|
- VS Code 工作区已补 `.vscode/settings.json`、`.vscode/extensions.json`、`lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor
|
||||||
- 进行中:
|
- 进行中:
|
||||||
- 继续观察 VS Code Java/Lombok 误报是否完全消失
|
- 继续观察 VS Code Java/Lombok 误报是否完全消失
|
||||||
|
- 继续排查 `api.yoyuzh.xyz` 在不同网络/设备下的 TLS/SNI 链路稳定性
|
||||||
- 后续如果再做 README/开源化展示,可以继续补 banner、截图和架构图
|
- 后续如果再做 README/开源化展示,可以继续补 banner、截图和架构图
|
||||||
- 待开始:
|
- 待开始:
|
||||||
- 如果用户继续提需求,优先沿当前网站主线迭代,不再回到旧教务方向
|
- 如果用户继续提需求,优先沿当前网站主线迭代,不再回到旧教务方向
|
||||||
@@ -27,11 +29,14 @@
|
|||||||
| 单设备登录通过“用户当前会话 ID + JWT sid claim”实现 | 新登录能立即顶掉旧 access token,而不仅仅是旧 refresh token | 只撤销 refresh token: 旧 access token 仍会继续有效一段时间 |
|
| 单设备登录通过“用户当前会话 ID + JWT sid claim”实现 | 新登录能立即顶掉旧 access token,而不仅仅是旧 refresh token | 只撤销 refresh token: 旧 access token 仍会继续有效一段时间 |
|
||||||
| 前端发布继续使用 `node scripts/deploy-front-oss.mjs` | 仓库已有正式 OSS 发布脚本,流程稳定 | 手动上传 OSS: 容易出错,也不利于复用 |
|
| 前端发布继续使用 `node scripts/deploy-front-oss.mjs` | 仓库已有正式 OSS 发布脚本,流程稳定 | 手动上传 OSS: 容易出错,也不利于复用 |
|
||||||
| 后端发布继续采用“本地打包 + SSH/ SCP 上传 jar + systemd 重启” | 当前线上就按这个方式运行 | 自创部署脚本: 仓库里没有现成正式脚本,容易和现网偏离 |
|
| 后端发布继续采用“本地打包 + SSH/ SCP 上传 jar + systemd 重启” | 当前线上就按这个方式运行 | 自创部署脚本: 仓库里没有现成正式脚本,容易和现网偏离 |
|
||||||
|
| 主站 CORS 默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz` | 前端生产环境托管在 OSS 域名下,必须允许主站跨域调用后端 API | 仅保留 localhost: 会导致生产站调用 API 时被浏览器拦截 |
|
||||||
|
|
||||||
## 待解决问题
|
## 待解决问题
|
||||||
- [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误
|
- [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误
|
||||||
- [ ] `front/README.md` 仍是旧模板风格说明,当前真实入口说明以根目录 `README.md` 为准,后续可继续整理
|
- [ ] `front/README.md` 仍是旧模板风格说明,当前真实入口说明以根目录 `README.md` 为准,后续可继续整理
|
||||||
- [ ] 前端构建仍有 chunk size warning,目前不阻塞发布,但后续可以考虑做更细的拆包
|
- [ ] 前端构建仍有 chunk size warning,目前不阻塞发布,但后续可以考虑做更细的拆包
|
||||||
|
- [ ] `api.yoyuzh.xyz` 仍存在“同机房 IP 直连可用,但带域名 TLS/SNI 有时失败”的链路问题;这不是后端业务代码错误
|
||||||
|
- [ ] 线上前端 bundle 当前仍内嵌 `https://api.yoyuzh.xyz/api`,API 子域名异常时会直接表现为“网络异常/登录失败”
|
||||||
|
|
||||||
## 关键约束
|
## 关键约束
|
||||||
(只写这个任务特有的限制,区别于项目通用规则)
|
(只写这个任务特有的限制,区别于项目通用规则)
|
||||||
@@ -42,6 +47,8 @@
|
|||||||
- 已知线上后端服务名是 `my-site-api.service`
|
- 已知线上后端服务名是 `my-site-api.service`
|
||||||
- 已知线上后端运行包路径是 `/opt/yoyuzh/yoyuzh-portal-backend.jar`
|
- 已知线上后端运行包路径是 `/opt/yoyuzh/yoyuzh-portal-backend.jar`
|
||||||
- 已知新服务器公网 IP 是 `1.14.49.201`
|
- 已知新服务器公网 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`,不要把内容写进文档或对外输出
|
- 服务器登录信息保存在本地 `账号密码.txt`,不要把内容写进文档或对外输出
|
||||||
|
|
||||||
## 参考资料
|
## 参考资料
|
||||||
@@ -55,5 +62,7 @@
|
|||||||
- 单设备登录: `backend/src/main/java/com/yoyuzh/auth/AuthService.java`
|
- 单设备登录: `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/auth/JwtTokenProvider.java`
|
||||||
- JWT 过滤器: `backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.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/Files.tsx`、`front/src/pages/files-tree.ts`
|
||||||
- 快传接收页: `front/src/pages/TransferReceive.tsx`
|
- 快传接收页: `front/src/pages/TransferReceive.tsx`
|
||||||
|
- 前端生产 API 基址: `front/.env.production`
|
||||||
|
|||||||
Reference in New Issue
Block a user