From 52b5bbfe8e0ae7d33d70794f76e78973c81c3e3a Mon Sep 17 00:00:00 2001 From: yoyuzh Date: Fri, 3 Apr 2026 16:28:09 +0800 Subject: [PATCH] Enable dual-device login and mobile APK update checks --- .../java/com/yoyuzh/admin/AdminService.java | 4 + .../java/com/yoyuzh/auth/AuthClientType.java | 23 +++ .../java/com/yoyuzh/auth/AuthController.java | 16 +- .../java/com/yoyuzh/auth/AuthService.java | 66 ++++-- .../com/yoyuzh/auth/DevAuthController.java | 6 +- .../com/yoyuzh/auth/JwtTokenProvider.java | 21 ++ .../java/com/yoyuzh/auth/RefreshToken.java | 14 ++ .../yoyuzh/auth/RefreshTokenRepository.java | 11 + .../com/yoyuzh/auth/RefreshTokenService.java | 20 +- .../src/main/java/com/yoyuzh/auth/User.java | 22 ++ .../config/JwtAuthenticationFilter.java | 2 +- .../java/com/yoyuzh/files/FileController.java | 22 ++ .../java/com/yoyuzh/files/FileService.java | 190 ++++++++++++++++-- .../yoyuzh/files/RecycleBinItemResponse.java | 16 ++ .../java/com/yoyuzh/files/StoredFile.java | 48 ++++- .../yoyuzh/files/StoredFileRepository.java | 37 +++- .../auth/AuthControllerValidationTest.java | 4 +- .../java/com/yoyuzh/auth/AuthServiceTest.java | 18 +- .../auth/AuthSingleDeviceIntegrationTest.java | 66 +++++- .../com/yoyuzh/auth/JwtTokenProviderTest.java | 3 +- .../config/JwtAuthenticationFilterTest.java | 18 +- .../com/yoyuzh/files/FileServiceTest.java | 63 ++++-- .../RecycleBinControllerIntegrationTest.java | 171 ++++++++++++++++ docs/api-reference.md | 19 +- docs/architecture.md | 40 ++-- .../plans/2026-04-03-overview-apk-download.md | 45 +++++ .../plans/2026-04-03-recycle-bin.md | 59 ++++++ front/src/App.tsx | 2 + front/src/MobileApp.tsx | 2 + front/src/components/ui/card.test.tsx | 11 + front/src/components/ui/card.tsx | 2 +- front/src/index.css | 8 +- front/src/lib/api.test.ts | 1 + front/src/lib/api.ts | 22 +- front/src/lib/app-shell.test.ts | 21 +- front/src/lib/app-shell.ts | 64 ++++++ front/src/lib/types.ts | 12 ++ front/src/mobile-components/MobileLayout.tsx | 12 +- front/src/mobile-pages/MobileFiles.tsx | 46 +++-- front/src/mobile-pages/MobileOverview.tsx | 96 ++++++++- front/src/pages/Files.tsx | 43 +++- front/src/pages/Overview.tsx | 61 +++++- front/src/pages/RecycleBin.tsx | 165 +++++++++++++++ front/src/pages/overview-state.test.ts | 50 ++++- front/src/pages/overview-state.ts | 53 +++++ front/src/pages/recycle-bin-state.test.ts | 31 +++ front/src/pages/recycle-bin-state.ts | 27 +++ memory.md | 14 +- scripts/deploy-front-oss.mjs | 55 +++++ scripts/oss-deploy-lib.mjs | 1 + 50 files changed, 1659 insertions(+), 164 deletions(-) create mode 100644 backend/src/main/java/com/yoyuzh/auth/AuthClientType.java create mode 100644 backend/src/main/java/com/yoyuzh/files/RecycleBinItemResponse.java create mode 100644 backend/src/test/java/com/yoyuzh/files/RecycleBinControllerIntegrationTest.java create mode 100644 docs/superpowers/plans/2026-04-03-overview-apk-download.md create mode 100644 docs/superpowers/plans/2026-04-03-recycle-bin.md create mode 100644 front/src/components/ui/card.test.tsx create mode 100644 front/src/pages/RecycleBin.tsx create mode 100644 front/src/pages/recycle-bin-state.test.ts create mode 100644 front/src/pages/recycle-bin-state.ts diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminService.java b/backend/src/main/java/com/yoyuzh/admin/AdminService.java index eebcb4e..a3f5d00 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminService.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminService.java @@ -102,6 +102,8 @@ public class AdminService { User user = getRequiredUser(userId); user.setBanned(banned); user.setActiveSessionId(UUID.randomUUID().toString()); + user.setDesktopActiveSessionId(UUID.randomUUID().toString()); + user.setMobileActiveSessionId(UUID.randomUUID().toString()); refreshTokenService.revokeAllForUser(user.getId()); return toUserResponse(userRepository.save(user)); } @@ -114,6 +116,8 @@ public class AdminService { User user = getRequiredUser(userId); user.setPasswordHash(passwordEncoder.encode(newPassword)); user.setActiveSessionId(UUID.randomUUID().toString()); + user.setDesktopActiveSessionId(UUID.randomUUID().toString()); + user.setMobileActiveSessionId(UUID.randomUUID().toString()); refreshTokenService.revokeAllForUser(user.getId()); return toUserResponse(userRepository.save(user)); } diff --git a/backend/src/main/java/com/yoyuzh/auth/AuthClientType.java b/backend/src/main/java/com/yoyuzh/auth/AuthClientType.java new file mode 100644 index 0000000..59437a0 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/auth/AuthClientType.java @@ -0,0 +1,23 @@ +package com.yoyuzh.auth; + +import org.springframework.util.StringUtils; + +public enum AuthClientType { + DESKTOP, + MOBILE; + + public static final String HEADER_NAME = "X-Yoyuzh-Client"; + + public static AuthClientType fromHeader(String rawValue) { + if (!StringUtils.hasText(rawValue)) { + return DESKTOP; + } + + String normalized = rawValue.trim().toUpperCase(); + if ("MOBILE".equals(normalized)) { + return MOBILE; + } + + return DESKTOP; + } +} diff --git a/backend/src/main/java/com/yoyuzh/auth/AuthController.java b/backend/src/main/java/com/yoyuzh/auth/AuthController.java index 33ebd84..035ea94 100644 --- a/backend/src/main/java/com/yoyuzh/auth/AuthController.java +++ b/backend/src/main/java/com/yoyuzh/auth/AuthController.java @@ -8,6 +8,7 @@ import com.yoyuzh.common.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -22,19 +23,22 @@ public class AuthController { @Operation(summary = "用户注册") @PostMapping("/register") - public ApiResponse register(@Valid @RequestBody RegisterRequest request) { - return ApiResponse.success(authService.register(request)); + public ApiResponse register(@Valid @RequestBody RegisterRequest request, + @RequestHeader(name = AuthClientType.HEADER_NAME, required = false) String clientTypeHeader) { + return ApiResponse.success(authService.register(request, AuthClientType.fromHeader(clientTypeHeader))); } @Operation(summary = "用户登录") @PostMapping("/login") - public ApiResponse login(@Valid @RequestBody LoginRequest request) { - return ApiResponse.success(authService.login(request)); + public ApiResponse login(@Valid @RequestBody LoginRequest request, + @RequestHeader(name = AuthClientType.HEADER_NAME, required = false) String clientTypeHeader) { + return ApiResponse.success(authService.login(request, AuthClientType.fromHeader(clientTypeHeader))); } @Operation(summary = "刷新访问令牌") @PostMapping("/refresh") - public ApiResponse refresh(@Valid @RequestBody RefreshTokenRequest request) { - return ApiResponse.success(authService.refresh(request.refreshToken())); + public ApiResponse refresh(@Valid @RequestBody RefreshTokenRequest request, + @RequestHeader(name = AuthClientType.HEADER_NAME, required = false) String clientTypeHeader) { + return ApiResponse.success(authService.refresh(request.refreshToken(), AuthClientType.fromHeader(clientTypeHeader))); } } diff --git a/backend/src/main/java/com/yoyuzh/auth/AuthService.java b/backend/src/main/java/com/yoyuzh/auth/AuthService.java index 1fd3f32..5c62f14 100644 --- a/backend/src/main/java/com/yoyuzh/auth/AuthService.java +++ b/backend/src/main/java/com/yoyuzh/auth/AuthService.java @@ -50,6 +50,11 @@ public class AuthService { @Transactional public AuthResponse register(RegisterRequest request) { + return register(request, AuthClientType.DESKTOP); + } + + @Transactional + public AuthResponse register(RegisterRequest request, AuthClientType clientType) { if (userRepository.existsByUsername(request.username())) { throw new BusinessException(ErrorCode.UNKNOWN, "用户名已存在"); } @@ -72,11 +77,16 @@ public class AuthService { user.setPreferredLanguage("zh-CN"); User saved = userRepository.save(user); fileService.ensureDefaultDirectories(saved); - return issueFreshTokens(saved); + return issueFreshTokens(saved, clientType); } @Transactional public AuthResponse login(LoginRequest request) { + return login(request, AuthClientType.DESKTOP); + } + + @Transactional + public AuthResponse login(LoginRequest request, AuthClientType clientType) { try { authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.username(), request.password())); @@ -89,11 +99,16 @@ public class AuthService { User user = userRepository.findByUsername(request.username()) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在")); fileService.ensureDefaultDirectories(user); - return issueFreshTokens(user); + return issueFreshTokens(user, clientType); } @Transactional public AuthResponse devLogin(String username) { + return devLogin(username, AuthClientType.DESKTOP); + } + + @Transactional + public AuthResponse devLogin(String username, AuthClientType clientType) { String candidate = username == null ? "" : username.trim(); if (candidate.isEmpty()) { candidate = "1"; @@ -111,13 +126,19 @@ public class AuthService { return userRepository.save(created); }); fileService.ensureDefaultDirectories(user); - return issueFreshTokens(user); + return issueFreshTokens(user, clientType); } @Transactional public AuthResponse refresh(String refreshToken) { + return refresh(refreshToken, AuthClientType.DESKTOP); + } + + @Transactional + public AuthResponse refresh(String refreshToken, AuthClientType defaultClientType) { RefreshTokenService.RotatedRefreshToken rotated = refreshTokenService.rotateRefreshToken(refreshToken); - return issueTokens(rotated.user(), rotated.refreshToken()); + AuthClientType clientType = rotated.clientType() == null ? defaultClientType : rotated.clientType(); + return issueTokens(rotated.user(), rotated.refreshToken(), clientType); } public UserProfileResponse getProfile(String username) { @@ -158,7 +179,9 @@ public class AuthService { } user.setPasswordHash(passwordEncoder.encode(request.newPassword())); - return issueFreshTokens(user); + rotateAllActiveSessions(user); + refreshTokenService.revokeAllForUser(user.getId()); + return issueTokens(userRepository.save(user), refreshTokenService.issueRefreshToken(user), AuthClientType.DESKTOP); } public InitiateUploadResponse initiateAvatarUpload(String username, UpdateUserAvatarRequest request) { @@ -267,26 +290,43 @@ public class AuthService { ); } - private AuthResponse issueFreshTokens(User user) { - refreshTokenService.revokeAllForUser(user.getId()); - return issueTokens(user, refreshTokenService.issueRefreshToken(user)); + private AuthResponse issueFreshTokens(User user, AuthClientType clientType) { + refreshTokenService.revokeAllForUser(user.getId(), clientType); + return issueTokens(user, refreshTokenService.issueRefreshToken(user, clientType), clientType); } - private AuthResponse issueTokens(User user, String refreshToken) { - User sessionUser = rotateActiveSession(user); + private AuthResponse issueTokens(User user, String refreshToken, AuthClientType clientType) { + User sessionUser = rotateActiveSession(user, clientType); String accessToken = jwtTokenProvider.generateAccessToken( sessionUser.getId(), sessionUser.getUsername(), - sessionUser.getActiveSessionId() + getActiveSessionId(sessionUser, clientType), + clientType ); return AuthResponse.issued(accessToken, refreshToken, toProfile(sessionUser)); } - private User rotateActiveSession(User user) { - user.setActiveSessionId(UUID.randomUUID().toString()); + private User rotateActiveSession(User user, AuthClientType clientType) { + String nextSessionId = UUID.randomUUID().toString(); + if (clientType == AuthClientType.MOBILE) { + user.setMobileActiveSessionId(nextSessionId); + } else { + user.setDesktopActiveSessionId(nextSessionId); + user.setActiveSessionId(nextSessionId); + } return userRepository.save(user); } + private void rotateAllActiveSessions(User user) { + user.setActiveSessionId(UUID.randomUUID().toString()); + user.setDesktopActiveSessionId(UUID.randomUUID().toString()); + user.setMobileActiveSessionId(UUID.randomUUID().toString()); + } + + private String getActiveSessionId(User user, AuthClientType clientType) { + return clientType == AuthClientType.MOBILE ? user.getMobileActiveSessionId() : user.getDesktopActiveSessionId(); + } + private String normalizeOptionalText(String value) { if (value == null) { return null; diff --git a/backend/src/main/java/com/yoyuzh/auth/DevAuthController.java b/backend/src/main/java/com/yoyuzh/auth/DevAuthController.java index 23f6886..7e2db9e 100644 --- a/backend/src/main/java/com/yoyuzh/auth/DevAuthController.java +++ b/backend/src/main/java/com/yoyuzh/auth/DevAuthController.java @@ -5,6 +5,7 @@ import com.yoyuzh.common.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Profile; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -20,7 +21,8 @@ public class DevAuthController { @Operation(summary = "开发环境免密登录") @PostMapping("/dev-login") - public ApiResponse devLogin(@RequestParam(required = false) String username) { - return ApiResponse.success(authService.devLogin(username)); + public ApiResponse devLogin(@RequestParam(required = false) String username, + @RequestHeader(name = AuthClientType.HEADER_NAME, required = false) String clientTypeHeader) { + return ApiResponse.success(authService.devLogin(username, AuthClientType.fromHeader(clientTypeHeader))); } } diff --git a/backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java b/backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java index 7f9f4b5..c8ad2ef 100644 --- a/backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java +++ b/backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java @@ -41,10 +41,15 @@ public class JwtTokenProvider { } public String generateAccessToken(Long userId, String username, String sessionId) { + return generateAccessToken(userId, username, sessionId, AuthClientType.DESKTOP); + } + + public String generateAccessToken(Long userId, String username, String sessionId, AuthClientType clientType) { Instant now = Instant.now(); var builder = Jwts.builder() .subject(username) .claim("uid", userId) + .claim("client", clientType.name()) .issuedAt(Date.from(now)) .expiration(Date.from(now.plusSeconds(jwtProperties.getAccessExpirationSeconds()))) .signWith(secretKey); @@ -79,6 +84,11 @@ public class JwtTokenProvider { return sessionId == null ? null : sessionId.toString(); } + public AuthClientType getClientType(String token) { + Object clientType = parseClaims(token).get("client"); + return AuthClientType.fromHeader(clientType == null ? null : clientType.toString()); + } + public boolean hasMatchingSession(String token, String activeSessionId) { String tokenSessionId = getSessionId(token); @@ -89,6 +99,17 @@ public class JwtTokenProvider { return activeSessionId.equals(tokenSessionId); } + public boolean hasMatchingSession(String token, User user) { + String expectedSessionId = switch (getClientType(token)) { + case MOBILE -> user.getMobileActiveSessionId(); + case DESKTOP -> StringUtils.hasText(user.getDesktopActiveSessionId()) + ? user.getDesktopActiveSessionId() + : user.getActiveSessionId(); + }; + + return hasMatchingSession(token, expectedSessionId); + } + private Claims parseClaims(String token) { return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload(); } diff --git a/backend/src/main/java/com/yoyuzh/auth/RefreshToken.java b/backend/src/main/java/com/yoyuzh/auth/RefreshToken.java index 9aa845c..e43b70b 100644 --- a/backend/src/main/java/com/yoyuzh/auth/RefreshToken.java +++ b/backend/src/main/java/com/yoyuzh/auth/RefreshToken.java @@ -39,6 +39,9 @@ public class RefreshToken { @Column(name = "revoked", nullable = false) private boolean revoked; + @Column(name = "client_type", nullable = false, length = 16) + private String clientType; + @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; @@ -50,6 +53,9 @@ public class RefreshToken { if (createdAt == null) { createdAt = LocalDateTime.now(); } + if (clientType == null || clientType.isBlank()) { + clientType = AuthClientType.DESKTOP.name(); + } } public void revoke(LocalDateTime revokedAt) { @@ -97,6 +103,14 @@ public class RefreshToken { this.revoked = revoked; } + public String getClientType() { + return clientType; + } + + public void setClientType(String clientType) { + this.clientType = clientType; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/backend/src/main/java/com/yoyuzh/auth/RefreshTokenRepository.java b/backend/src/main/java/com/yoyuzh/auth/RefreshTokenRepository.java index 12f7165..686f9d6 100644 --- a/backend/src/main/java/com/yoyuzh/auth/RefreshTokenRepository.java +++ b/backend/src/main/java/com/yoyuzh/auth/RefreshTokenRepository.java @@ -23,4 +23,15 @@ public interface RefreshTokenRepository extends JpaRepository> listRecycleBin(@AuthenticationPrincipal UserDetails userDetails, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + return ApiResponse.success(fileService.listRecycleBin( + userDetailsService.loadDomainUser(userDetails.getUsername()), + page, + size + )); + } + @Operation(summary = "下载文件") @GetMapping("/download/{fileId}") public ResponseEntity download(@AuthenticationPrincipal UserDetails userDetails, @@ -162,4 +174,14 @@ public class FileController { fileService.delete(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId); return ApiResponse.success(); } + + @Operation(summary = "从回收站恢复文件") + @PostMapping("/recycle-bin/{fileId}/restore") + public ApiResponse restoreRecycleBinItem(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable Long fileId) { + return ApiResponse.success(fileService.restoreFromRecycleBin( + userDetailsService.loadDomainUser(userDetails.getUsername()), + fileId + )); + } } diff --git a/backend/src/main/java/com/yoyuzh/files/FileService.java b/backend/src/main/java/com/yoyuzh/files/FileService.java index 6b6d049..ef03667 100644 --- a/backend/src/main/java/com/yoyuzh/files/FileService.java +++ b/backend/src/main/java/com/yoyuzh/files/FileService.java @@ -12,6 +12,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,12 +24,14 @@ import java.io.IOException; import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.zip.ZipEntry; @@ -37,6 +40,8 @@ import java.util.zip.ZipOutputStream; @Service public class FileService { private static final List DEFAULT_DIRECTORIES = List.of("下载", "文档", "图片"); + private static final String RECYCLE_BIN_PATH_PREFIX = "/.recycle"; + private static final long RECYCLE_BIN_RETENTION_DAYS = 10L; private final StoredFileRepository storedFileRepository; private final FileBlobRepository fileBlobRepository; @@ -145,12 +150,18 @@ public class FileService { } public List recent(User user) { - return storedFileRepository.findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(user.getId()) + return storedFileRepository.findTop12ByUserIdAndDirectoryFalseAndDeletedAtIsNullOrderByCreatedAtDesc(user.getId()) .stream() .map(this::toResponse) .toList(); } + public PageResponse listRecycleBin(User user, int page, int size) { + Page result = storedFileRepository.findRecycleBinRootsByUserId(user.getId(), PageRequest.of(page, size)); + List items = result.getContent().stream().map(this::toRecycleBinResponse).toList(); + return new PageResponse<>(items, result.getTotalElements(), page, size); + } + @Transactional public void ensureDefaultDirectories(User user) { for (String directoryName : DEFAULT_DIRECTORIES) { @@ -174,26 +185,58 @@ public class FileService { @Transactional public void delete(User user, Long fileId) { - StoredFile storedFile = getOwnedFile(user, fileId, "删除"); - List filesToDelete = new ArrayList<>(); + StoredFile storedFile = getOwnedActiveFile(user, fileId, "删除"); + List filesToRecycle = new ArrayList<>(); + filesToRecycle.add(storedFile); if (storedFile.isDirectory()) { String logicalPath = buildLogicalPath(storedFile); List descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), logicalPath); - filesToDelete.addAll(descendants.stream().filter(descendant -> !descendant.isDirectory()).toList()); - if (!descendants.isEmpty()) { - storedFileRepository.deleteAll(descendants); - } - } else { - filesToDelete.add(storedFile); + filesToRecycle.addAll(descendants); } - List blobsToDelete = collectBlobsToDelete(filesToDelete); - storedFileRepository.delete(storedFile); + moveToRecycleBin(filesToRecycle, storedFile.getId()); + } + + @Transactional + public FileMetadataResponse restoreFromRecycleBin(User user, Long fileId) { + StoredFile recycleRoot = getOwnedRecycleRootFile(user, fileId); + List recycleGroupItems = loadRecycleGroupItems(recycleRoot); + long additionalBytes = recycleGroupItems.stream() + .filter(item -> !item.isDirectory()) + .mapToLong(StoredFile::getSize) + .sum(); + ensureWithinStorageQuota(user, additionalBytes); + validateRecycleRestoreTargets(user.getId(), recycleGroupItems); + ensureRecycleRestoreParentHierarchy(user, recycleRoot); + + for (StoredFile item : recycleGroupItems) { + item.setPath(requireRecycleOriginalPath(item)); + item.setDeletedAt(null); + item.setRecycleOriginalPath(null); + item.setRecycleGroupId(null); + item.setRecycleRoot(false); + } + storedFileRepository.saveAll(recycleGroupItems); + return toResponse(recycleRoot); + } + + @Scheduled(fixedDelay = 60 * 60 * 1000L) + @Transactional + public void pruneExpiredRecycleBinItems() { + List expiredItems = storedFileRepository.findByDeletedAtBefore(LocalDateTime.now().minusDays(RECYCLE_BIN_RETENTION_DAYS)); + if (expiredItems.isEmpty()) { + return; + } + + List blobsToDelete = collectBlobsToDelete( + expiredItems.stream().filter(item -> !item.isDirectory()).toList() + ); + storedFileRepository.deleteAll(expiredItems); deleteBlobs(blobsToDelete); } @Transactional public FileMetadataResponse rename(User user, Long fileId, String nextFilename) { - StoredFile storedFile = getOwnedFile(user, fileId, "重命名"); + StoredFile storedFile = getOwnedActiveFile(user, fileId, "重命名"); String sanitizedFilename = normalizeLeafName(nextFilename); if (sanitizedFilename.equals(storedFile.getFilename())) { return toResponse(storedFile); @@ -228,7 +271,7 @@ public class FileService { @Transactional public FileMetadataResponse move(User user, Long fileId, String nextPath) { - StoredFile storedFile = getOwnedFile(user, fileId, "移动"); + StoredFile storedFile = getOwnedActiveFile(user, fileId, "移动"); String normalizedTargetPath = normalizeDirectoryPath(nextPath); if (normalizedTargetPath.equals(storedFile.getPath())) { return toResponse(storedFile); @@ -268,7 +311,7 @@ public class FileService { @Transactional public FileMetadataResponse copy(User user, Long fileId, String nextPath) { - StoredFile storedFile = getOwnedFile(user, fileId, "复制"); + StoredFile storedFile = getOwnedActiveFile(user, fileId, "复制"); String normalizedTargetPath = normalizeDirectoryPath(nextPath); ensureExistingDirectoryPath(user.getId(), normalizedTargetPath); if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedTargetPath, storedFile.getFilename())) { @@ -322,7 +365,7 @@ public class FileService { } public ResponseEntity download(User user, Long fileId) { - StoredFile storedFile = getOwnedFile(user, fileId, "下载"); + StoredFile storedFile = getOwnedActiveFile(user, fileId, "下载"); if (storedFile.isDirectory()) { return downloadDirectory(user, storedFile); } @@ -344,7 +387,7 @@ public class FileService { } public DownloadUrlResponse getDownloadUrl(User user, Long fileId) { - StoredFile storedFile = getOwnedFile(user, fileId, "下载"); + StoredFile storedFile = getOwnedActiveFile(user, fileId, "下载"); if (storedFile.isDirectory()) { throw new BusinessException(ErrorCode.UNKNOWN, "目录不支持下载"); } @@ -362,7 +405,7 @@ public class FileService { @Transactional public CreateFileShareLinkResponse createShareLink(User user, Long fileId) { - StoredFile storedFile = getOwnedFile(user, fileId, "分享"); + StoredFile storedFile = getOwnedActiveFile(user, fileId, "分享"); if (storedFile.isDirectory()) { throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持分享链接"); } @@ -500,6 +543,25 @@ public class FileService { .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "分享链接不存在")); } + private RecycleBinItemResponse toRecycleBinResponse(StoredFile storedFile) { + LocalDateTime deletedAt = storedFile.getDeletedAt(); + if (deletedAt == null) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "回收站文件不存在"); + } + + return new RecycleBinItemResponse( + storedFile.getId(), + storedFile.getFilename(), + requireRecycleOriginalPath(storedFile), + storedFile.getSize(), + storedFile.getContentType(), + storedFile.isDirectory(), + storedFile.getCreatedAt(), + deletedAt, + deletedAt.plusDays(RECYCLE_BIN_RETENTION_DAYS) + ); + } + private StoredFile getOwnedFile(User user, Long fileId, String action) { StoredFile storedFile = storedFileRepository.findDetailedById(fileId) .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在")); @@ -509,6 +571,30 @@ public class FileService { return storedFile; } + private StoredFile getOwnedActiveFile(User user, Long fileId, String action) { + StoredFile storedFile = getOwnedFile(user, fileId, action); + if (storedFile.getDeletedAt() != null) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"); + } + return storedFile; + } + + private StoredFile getOwnedRecycleRootFile(User user, Long fileId) { + StoredFile storedFile = getOwnedFile(user, fileId, "恢复"); + if (storedFile.getDeletedAt() == null || !storedFile.isRecycleRoot()) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "回收站文件不存在"); + } + return storedFile; + } + + private List loadRecycleGroupItems(StoredFile recycleRoot) { + List items = storedFileRepository.findByRecycleGroupId(recycleRoot.getRecycleGroupId()); + if (items.isEmpty()) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "回收站文件不存在"); + } + return items; + } + private void validateUpload(User user, String normalizedPath, String filename, long size) { long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes()); if (size > effectiveMaxUploadSize) { @@ -541,7 +627,11 @@ public class FileService { String currentPath = "/"; for (String segment : segments) { - if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), currentPath, segment)) { + Optional existing = storedFileRepository.findByUserIdAndPathAndFilename(user.getId(), currentPath, segment); + if (existing.isPresent()) { + if (!existing.get().isDirectory()) { + throw new BusinessException(ErrorCode.UNKNOWN, "目标路径不是目录"); + } currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment; continue; } @@ -562,6 +652,70 @@ public class FileService { } } + private void moveToRecycleBin(List filesToRecycle, Long recycleRootId) { + if (filesToRecycle.isEmpty()) { + return; + } + + StoredFile recycleRoot = filesToRecycle.stream() + .filter(item -> recycleRootId.equals(item.getId())) + .findFirst() + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在")); + String recycleGroupId = UUID.randomUUID().toString().replace("-", ""); + LocalDateTime deletedAt = LocalDateTime.now(); + String rootLogicalPath = buildLogicalPath(recycleRoot); + String recycleRootPath = buildRecycleBinPath(recycleGroupId, recycleRoot.getPath()); + String recycleRootLogicalPath = buildTargetLogicalPath(recycleRootPath, recycleRoot.getFilename()); + + List orderedItems = filesToRecycle.stream() + .sorted(Comparator + .comparingInt((StoredFile item) -> buildLogicalPath(item).length()) + .thenComparing(item -> item.isDirectory() ? 0 : 1) + .thenComparing(StoredFile::getFilename)) + .toList(); + + for (StoredFile item : orderedItems) { + String originalPath = item.getPath(); + String recyclePath = recycleRootId.equals(item.getId()) + ? recycleRootPath + : remapCopiedPath(item.getPath(), rootLogicalPath, recycleRootLogicalPath); + item.setDeletedAt(deletedAt); + item.setRecycleOriginalPath(originalPath); + item.setRecycleGroupId(recycleGroupId); + item.setRecycleRoot(recycleRootId.equals(item.getId())); + item.setPath(recyclePath); + } + + storedFileRepository.saveAll(orderedItems); + } + + private String buildRecycleBinPath(String recycleGroupId, String originalPath) { + if ("/".equals(originalPath)) { + return RECYCLE_BIN_PATH_PREFIX + "/" + recycleGroupId; + } + return RECYCLE_BIN_PATH_PREFIX + "/" + recycleGroupId + originalPath; + } + + private String requireRecycleOriginalPath(StoredFile storedFile) { + if (!StringUtils.hasText(storedFile.getRecycleOriginalPath())) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "回收站文件不存在"); + } + return storedFile.getRecycleOriginalPath(); + } + + private void validateRecycleRestoreTargets(Long userId, List recycleGroupItems) { + for (StoredFile item : recycleGroupItems) { + String originalPath = requireRecycleOriginalPath(item); + if (storedFileRepository.existsByUserIdAndPathAndFilename(userId, originalPath, item.getFilename())) { + throw new BusinessException(ErrorCode.UNKNOWN, "原目录已存在同名文件,无法恢复"); + } + } + } + + private void ensureRecycleRestoreParentHierarchy(User user, StoredFile recycleRoot) { + ensureDirectoryHierarchy(user, requireRecycleOriginalPath(recycleRoot)); + } + private void ensureExistingDirectoryPath(Long userId, String normalizedPath) { if ("/".equals(normalizedPath)) { return; diff --git a/backend/src/main/java/com/yoyuzh/files/RecycleBinItemResponse.java b/backend/src/main/java/com/yoyuzh/files/RecycleBinItemResponse.java new file mode 100644 index 0000000..83e500b --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/RecycleBinItemResponse.java @@ -0,0 +1,16 @@ +package com.yoyuzh.files; + +import java.time.LocalDateTime; + +public record RecycleBinItemResponse( + Long id, + String filename, + String path, + long size, + String contentType, + boolean directory, + LocalDateTime createdAt, + LocalDateTime deletedAt, + LocalDateTime expiresAt +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/StoredFile.java b/backend/src/main/java/com/yoyuzh/files/StoredFile.java index 0921716..2a1bfc8 100644 --- a/backend/src/main/java/com/yoyuzh/files/StoredFile.java +++ b/backend/src/main/java/com/yoyuzh/files/StoredFile.java @@ -18,7 +18,9 @@ import java.time.LocalDateTime; @Entity @Table(name = "portal_file", indexes = { @Index(name = "uk_file_user_path_name", columnList = "user_id,path,filename", unique = true), - @Index(name = "idx_file_created_at", columnList = "created_at") + @Index(name = "idx_file_created_at", columnList = "created_at"), + @Index(name = "idx_file_deleted_at", columnList = "deleted_at"), + @Index(name = "idx_file_recycle_group", columnList = "recycle_group_id") }) public class StoredFile { @@ -55,6 +57,18 @@ public class StoredFile { @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Column(name = "recycle_original_path", length = 512) + private String recycleOriginalPath; + + @Column(name = "recycle_group_id", length = 64) + private String recycleGroupId; + + @Column(name = "is_recycle_root", nullable = false) + private boolean recycleRoot; + @PrePersist public void prePersist() { if (createdAt == null) { @@ -141,4 +155,36 @@ public class StoredFile { public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } + + public String getRecycleOriginalPath() { + return recycleOriginalPath; + } + + public void setRecycleOriginalPath(String recycleOriginalPath) { + this.recycleOriginalPath = recycleOriginalPath; + } + + public String getRecycleGroupId() { + return recycleGroupId; + } + + public void setRecycleGroupId(String recycleGroupId) { + this.recycleGroupId = recycleGroupId; + } + + public boolean isRecycleRoot() { + return recycleRoot; + } + + public void setRecycleRoot(boolean recycleRoot) { + this.recycleRoot = recycleRoot; + } } diff --git a/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java b/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java index 7f584c8..35ef52f 100644 --- a/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java +++ b/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java @@ -22,6 +22,7 @@ public interface StoredFileRepository extends JpaRepository { where (:query is null or :query = '' or lower(f.filename) like lower(concat('%', :query, '%')) or lower(f.path) like lower(concat('%', :query, '%'))) + and f.deletedAt is null and (:ownerQuery is null or :ownerQuery = '' or lower(u.username) like lower(concat('%', :ownerQuery, '%')) or lower(u.email) like lower(concat('%', :ownerQuery, '%'))) @@ -33,7 +34,7 @@ public interface StoredFileRepository extends JpaRepository { @Query(""" select case when count(f) > 0 then true else false end from StoredFile f - where f.user.id = :userId and f.path = :path and f.filename = :filename + where f.user.id = :userId and f.path = :path and f.filename = :filename and f.deletedAt is null """) boolean existsByUserIdAndPathAndFilename(@Param("userId") Long userId, @Param("path") String path, @@ -41,7 +42,7 @@ public interface StoredFileRepository extends JpaRepository { @Query(""" select f from StoredFile f - where f.user.id = :userId and f.path = :path and f.filename = :filename + where f.user.id = :userId and f.path = :path and f.filename = :filename and f.deletedAt is null """) Optional findByUserIdAndPathAndFilename(@Param("userId") Long userId, @Param("path") String path, @@ -50,7 +51,7 @@ public interface StoredFileRepository extends JpaRepository { @EntityGraph(attributePaths = "blob") @Query(""" select f from StoredFile f - where f.user.id = :userId and f.path = :path + where f.user.id = :userId and f.path = :path and f.deletedAt is null order by f.directory desc, f.createdAt desc """) Page findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc(@Param("userId") Long userId, @@ -60,7 +61,7 @@ public interface StoredFileRepository extends JpaRepository { @EntityGraph(attributePaths = "blob") @Query(""" select f from StoredFile f - where f.user.id = :userId and (f.path = :path or f.path like concat(:path, '/%')) + where f.user.id = :userId and f.deletedAt is null and (f.path = :path or f.path like concat(:path, '/%')) order by f.createdAt asc """) List findByUserIdAndPathEqualsOrDescendant(@Param("userId") Long userId, @@ -69,7 +70,7 @@ public interface StoredFileRepository extends JpaRepository { @Query(""" select coalesce(sum(f.size), 0) from StoredFile f - where f.user.id = :userId and f.directory = false + where f.user.id = :userId and f.directory = false and f.deletedAt is null """) long sumFileSizeByUserId(@Param("userId") Long userId); @@ -81,7 +82,31 @@ public interface StoredFileRepository extends JpaRepository { long sumAllFileSize(); @EntityGraph(attributePaths = "blob") - List findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(Long userId); + List findTop12ByUserIdAndDirectoryFalseAndDeletedAtIsNullOrderByCreatedAtDesc(Long userId); + + @EntityGraph(attributePaths = "blob") + @Query(""" + select f from StoredFile f + where f.user.id = :userId and f.deletedAt is not null and f.recycleRoot = true + order by f.deletedAt desc + """) + Page findRecycleBinRootsByUserId(@Param("userId") Long userId, Pageable pageable); + + @EntityGraph(attributePaths = "blob") + @Query(""" + select f from StoredFile f + where f.recycleGroupId = :groupId + order by length(coalesce(f.recycleOriginalPath, f.path)) asc, f.directory desc, f.createdAt asc + """) + List findByRecycleGroupId(@Param("groupId") String groupId); + + @EntityGraph(attributePaths = "blob") + @Query(""" + select f from StoredFile f + where f.deletedAt is not null and f.deletedAt < :cutoff + order by f.deletedAt asc + """) + List findByDeletedAtBefore(@Param("cutoff") java.time.LocalDateTime cutoff); @Query(""" select count(f) diff --git a/backend/src/test/java/com/yoyuzh/auth/AuthControllerValidationTest.java b/backend/src/test/java/com/yoyuzh/auth/AuthControllerValidationTest.java index 94e1010..3b9e054 100644 --- a/backend/src/test/java/com/yoyuzh/auth/AuthControllerValidationTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/AuthControllerValidationTest.java @@ -120,7 +120,7 @@ class AuthControllerValidationTest { "new-refresh-token", new UserProfileResponse(7L, "alice", "alice@example.com", LocalDateTime.now()) ); - when(authService.refresh("refresh-1")).thenReturn(response); + when(authService.refresh("refresh-1", AuthClientType.DESKTOP)).thenReturn(response); mockMvc.perform(post("/api/auth/refresh") .contentType(MediaType.APPLICATION_JSON) @@ -134,6 +134,6 @@ class AuthControllerValidationTest { .andExpect(jsonPath("$.data.refreshToken").value("new-refresh-token")) .andExpect(jsonPath("$.data.user.username").value("alice")); - verify(authService).refresh("refresh-1"); + verify(authService).refresh("refresh-1", AuthClientType.DESKTOP); } } diff --git a/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java b/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java index 281875d..4745304 100644 --- a/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java @@ -84,8 +84,8 @@ class AuthServiceTest { user.setCreatedAt(LocalDateTime.now()); return user; }); - when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("access-token"); - when(refreshTokenService.issueRefreshToken(any(User.class))).thenReturn("refresh-token"); + when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString(), eq(AuthClientType.DESKTOP))).thenReturn("access-token"); + when(refreshTokenService.issueRefreshToken(any(User.class), eq(AuthClientType.DESKTOP))).thenReturn("refresh-token"); AuthResponse response = authService.register(request); @@ -166,8 +166,8 @@ class AuthServiceTest { user.setCreatedAt(LocalDateTime.now()); when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); when(userRepository.save(user)).thenReturn(user); - when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("access-token"); - when(refreshTokenService.issueRefreshToken(user)).thenReturn("refresh-token"); + when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString(), eq(AuthClientType.DESKTOP))).thenReturn("access-token"); + when(refreshTokenService.issueRefreshToken(user, AuthClientType.DESKTOP)).thenReturn("refresh-token"); AuthResponse response = authService.login(request); @@ -188,9 +188,9 @@ class AuthServiceTest { user.setEmail("alice@example.com"); user.setCreatedAt(LocalDateTime.now()); when(refreshTokenService.rotateRefreshToken("old-refresh")) - .thenReturn(new RefreshTokenService.RotatedRefreshToken(user, "new-refresh")); + .thenReturn(new RefreshTokenService.RotatedRefreshToken(user, "new-refresh", AuthClientType.DESKTOP)); when(userRepository.save(user)).thenReturn(user); - when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("new-access"); + when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString(), eq(AuthClientType.DESKTOP))).thenReturn("new-access"); AuthResponse response = authService.refresh("old-refresh"); @@ -232,8 +232,8 @@ class AuthServiceTest { user.setCreatedAt(LocalDateTime.now()); return user; }); - when(jwtTokenProvider.generateAccessToken(eq(9L), eq("demo"), anyString())).thenReturn("access-token"); - when(refreshTokenService.issueRefreshToken(any(User.class))).thenReturn("refresh-token"); + when(jwtTokenProvider.generateAccessToken(eq(9L), eq("demo"), anyString(), eq(AuthClientType.DESKTOP))).thenReturn("access-token"); + when(refreshTokenService.issueRefreshToken(any(User.class), eq(AuthClientType.DESKTOP))).thenReturn("refresh-token"); AuthResponse response = authService.devLogin("demo"); @@ -296,7 +296,7 @@ class AuthServiceTest { when(passwordEncoder.matches("OldPass1!", "encoded-old")).thenReturn(true); when(passwordEncoder.encode("NewPass1!A")).thenReturn("encoded-new"); when(userRepository.save(user)).thenReturn(user); - when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("new-access"); + when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString(), eq(AuthClientType.DESKTOP))).thenReturn("new-access"); when(refreshTokenService.issueRefreshToken(user)).thenReturn("new-refresh"); AuthResponse response = authService.changePassword("alice", request); diff --git a/backend/src/test/java/com/yoyuzh/auth/AuthSingleDeviceIntegrationTest.java b/backend/src/test/java/com/yoyuzh/auth/AuthSingleDeviceIntegrationTest.java index 5b776d4..6ae30a7 100644 --- a/backend/src/test/java/com/yoyuzh/auth/AuthSingleDeviceIntegrationTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/AuthSingleDeviceIntegrationTest.java @@ -38,16 +38,78 @@ class AuthSingleDeviceIntegrationTest { @Autowired private UserRepository userRepository; + @Autowired + private com.yoyuzh.files.StoredFileRepository storedFileRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + @Autowired private PasswordEncoder passwordEncoder; @BeforeEach void setUp() { + storedFileRepository.deleteAll(); + refreshTokenRepository.deleteAll(); userRepository.deleteAll(); } @Test - void shouldInvalidatePreviousAccessTokenAfterLoggingInAgain() throws Exception { + void shouldKeepDesktopAndMobileAccessTokensValidAtTheSameTime() throws Exception { + User user = new User(); + user.setUsername("alice"); + user.setDisplayName("Alice"); + user.setEmail("alice@example.com"); + user.setPhoneNumber("13800138000"); + user.setPasswordHash(passwordEncoder.encode("StrongPass1!")); + user.setPreferredLanguage("zh-CN"); + user.setRole(UserRole.USER); + user.setCreatedAt(LocalDateTime.now()); + userRepository.save(user); + + String loginRequest = """ + { + "username": "alice", + "password": "StrongPass1!" + } + """; + + String desktopLoginResponse = mockMvc.perform(post("/api/auth/login") + .contentType("application/json") + .header("X-Yoyuzh-Client", "desktop") + .content(loginRequest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) + .andReturn() + .getResponse() + .getContentAsString(); + + String mobileLoginResponse = mockMvc.perform(post("/api/auth/login") + .contentType("application/json") + .header("X-Yoyuzh-Client", "mobile") + .content(loginRequest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) + .andReturn() + .getResponse() + .getContentAsString(); + + String desktopAccessToken = JsonPath.read(desktopLoginResponse, "$.data.accessToken"); + String mobileAccessToken = JsonPath.read(mobileLoginResponse, "$.data.accessToken"); + + mockMvc.perform(get("/api/user/profile") + .header("Authorization", "Bearer " + desktopAccessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.username").value("alice")); + + mockMvc.perform(get("/api/user/profile") + .header("Authorization", "Bearer " + mobileAccessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.username").value("alice")); + } + + @Test + void shouldInvalidatePreviousAccessTokenAfterLoggingInAgainOnTheSameClientType() throws Exception { User user = new User(); user.setUsername("alice"); user.setDisplayName("Alice"); @@ -68,6 +130,7 @@ class AuthSingleDeviceIntegrationTest { String firstLoginResponse = mockMvc.perform(post("/api/auth/login") .contentType("application/json") + .header("X-Yoyuzh-Client", "desktop") .content(loginRequest)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) @@ -77,6 +140,7 @@ class AuthSingleDeviceIntegrationTest { String secondLoginResponse = mockMvc.perform(post("/api/auth/login") .contentType("application/json") + .header("X-Yoyuzh-Client", "desktop") .content(loginRequest)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) diff --git a/backend/src/test/java/com/yoyuzh/auth/JwtTokenProviderTest.java b/backend/src/test/java/com/yoyuzh/auth/JwtTokenProviderTest.java index 59452e6..2a35341 100644 --- a/backend/src/test/java/com/yoyuzh/auth/JwtTokenProviderTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/JwtTokenProviderTest.java @@ -59,7 +59,7 @@ class JwtTokenProviderTest { JwtTokenProvider provider = new JwtTokenProvider(properties); provider.init(); - String token = provider.generateAccessToken(7L, "alice", "session-1"); + String token = provider.generateAccessToken(7L, "alice", "session-1", AuthClientType.MOBILE); SecretKey secretKey = Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8)); Instant expiration = Jwts.parser().verifyWith(secretKey).build() .parseSignedClaims(token) @@ -71,6 +71,7 @@ class JwtTokenProviderTest { assertThat(provider.getUsername(token)).isEqualTo("alice"); assertThat(provider.getUserId(token)).isEqualTo(7L); assertThat(provider.getSessionId(token)).isEqualTo("session-1"); + assertThat(provider.getClientType(token)).isEqualTo(AuthClientType.MOBILE); assertThat(provider.hasMatchingSession(token, "session-1")).isTrue(); assertThat(provider.hasMatchingSession(token, "session-2")).isFalse(); assertThat(expiration).isAfter(Instant.now().plusSeconds(850)); diff --git a/backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java b/backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java index 9536275..741df3a 100644 --- a/backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java +++ b/backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java @@ -104,11 +104,11 @@ class JwtAuthenticationFilterTest { MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader("Authorization", "Bearer valid-token"); MockHttpServletResponse response = new MockHttpServletResponse(); - User domainUser = createDomainUser("alice", "session-1"); + User domainUser = createDomainUser("alice", "session-1", null); when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true); when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice"); when(userDetailsService.loadDomainUser("alice")).thenReturn(domainUser); - when(jwtTokenProvider.hasMatchingSession("valid-token", "session-1")).thenReturn(false); + when(jwtTokenProvider.hasMatchingSession("valid-token", domainUser)).thenReturn(false); filter.doFilterInternal(request, response, filterChain); @@ -121,7 +121,7 @@ class JwtAuthenticationFilterTest { MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader("Authorization", "Bearer valid-token"); MockHttpServletResponse response = new MockHttpServletResponse(); - User domainUser = createDomainUser("alice", "session-1"); + User domainUser = createDomainUser("alice", "session-1", null); UserDetails disabledUserDetails = org.springframework.security.core.userdetails.User.builder() .username("alice") .password("hashed") @@ -131,7 +131,7 @@ class JwtAuthenticationFilterTest { when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true); when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice"); when(userDetailsService.loadDomainUser("alice")).thenReturn(domainUser); - when(jwtTokenProvider.hasMatchingSession("valid-token", "session-1")).thenReturn(true); + when(jwtTokenProvider.hasMatchingSession("valid-token", domainUser)).thenReturn(true); when(userDetailsService.loadUserByUsername("alice")).thenReturn(disabledUserDetails); filter.doFilterInternal(request, response, filterChain); @@ -145,7 +145,7 @@ class JwtAuthenticationFilterTest { MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader("Authorization", "Bearer valid-token"); MockHttpServletResponse response = new MockHttpServletResponse(); - User domainUser = createDomainUser("alice", "session-1"); + User domainUser = createDomainUser("alice", "session-1", null); UserDetails activeUserDetails = org.springframework.security.core.userdetails.User.builder() .username("alice") .password("hashed") @@ -154,7 +154,7 @@ class JwtAuthenticationFilterTest { when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true); when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice"); when(userDetailsService.loadDomainUser("alice")).thenReturn(domainUser); - when(jwtTokenProvider.hasMatchingSession("valid-token", "session-1")).thenReturn(true); + when(jwtTokenProvider.hasMatchingSession("valid-token", domainUser)).thenReturn(true); when(userDetailsService.loadUserByUsername("alice")).thenReturn(activeUserDetails); filter.doFilterInternal(request, response, filterChain); @@ -165,13 +165,15 @@ class JwtAuthenticationFilterTest { verify(adminMetricsService).recordUserOnline(1L, "alice"); } - private User createDomainUser(String username, String sessionId) { + private User createDomainUser(String username, String desktopSessionId, String mobileSessionId) { User user = new User(); user.setId(1L); user.setUsername(username); user.setEmail(username + "@example.com"); user.setPasswordHash("hashed"); - user.setActiveSessionId(sessionId); + user.setActiveSessionId(desktopSessionId); + user.setDesktopActiveSessionId(desktopSessionId); + user.setMobileActiveSessionId(mobileSessionId); user.setCreatedAt(LocalDateTime.now()); return user; } diff --git a/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java b/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java index 2147a7a..6fa3194 100644 --- a/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java @@ -158,7 +158,8 @@ class FileServiceTest { MockMultipartFile multipartFile = new MockMultipartFile( "file", "notes.txt", "text/plain", "hello".getBytes()); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false); - when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "docs")).thenReturn(true); + when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs")) + .thenReturn(Optional.of(createDirectory(20L, user, "/", "docs"))); when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0)); doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class)); @@ -174,7 +175,8 @@ class FileServiceTest { void shouldDeleteCompletedUploadBlobWhenMetadataSaveFails() { User user = createUser(7L); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false); - when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "docs")).thenReturn(true); + when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs")) + .thenReturn(Optional.of(createDirectory(21L, user, "/", "docs"))); when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0)); doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class)); @@ -190,8 +192,8 @@ class FileServiceTest { void shouldCreateMissingDirectoriesBeforeCompletingNestedUpload() { User user = createUser(7L); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects/site", "logo.png")).thenReturn(false); - when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "projects")).thenReturn(false); - when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects", "site")).thenReturn(false); + when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "projects")).thenReturn(Optional.empty()); + when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/projects", "site")).thenReturn(Optional.empty()); when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); @@ -384,52 +386,74 @@ class FileServiceTest { } @Test - void shouldDeleteDirectoryWithNestedFilesViaStorage() { + void shouldMoveDeletedDirectoryAndDescendantsIntoRecycleBinGroup() { User user = createUser(7L); StoredFile directory = createDirectory(10L, user, "/docs", "archive"); + StoredFile nestedDirectory = createDirectory(12L, user, "/docs/archive", "nested"); FileBlob blob = createBlob(60L, "blobs/blob-delete", 5L, "text/plain"); StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt", blob); when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); - when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile)); - when(storedFileRepository.countByBlobId(60L)).thenReturn(1L); + when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(nestedDirectory, childFile)); fileService.delete(user, 10L); - verify(fileContentStorage).deleteBlob("blobs/blob-delete"); - verify(fileBlobRepository).delete(blob); - verify(storedFileRepository).deleteAll(List.of(childFile)); - verify(storedFileRepository).delete(directory); + assertThat(directory.getDeletedAt()).isNotNull(); + assertThat(directory.isRecycleRoot()).isTrue(); + assertThat(directory.getRecycleGroupId()).isNotBlank(); + assertThat(directory.getRecycleOriginalPath()).isEqualTo("/docs"); + assertThat(directory.getPath()).startsWith("/.recycle/"); + + assertThat(nestedDirectory.getDeletedAt()).isEqualTo(directory.getDeletedAt()); + assertThat(nestedDirectory.isRecycleRoot()).isFalse(); + assertThat(nestedDirectory.getRecycleGroupId()).isEqualTo(directory.getRecycleGroupId()); + assertThat(nestedDirectory.getRecycleOriginalPath()).isEqualTo("/docs/archive"); + + assertThat(childFile.getDeletedAt()).isEqualTo(directory.getDeletedAt()); + assertThat(childFile.isRecycleRoot()).isFalse(); + assertThat(childFile.getRecycleGroupId()).isEqualTo(directory.getRecycleGroupId()); + assertThat(childFile.getRecycleOriginalPath()).isEqualTo("/docs/archive"); + + verify(fileContentStorage, never()).deleteBlob(any()); + verify(fileBlobRepository, never()).delete(any()); + verify(storedFileRepository, never()).deleteAll(any()); + verify(storedFileRepository, never()).delete(any()); } @Test - void shouldDeleteSharedBlobOnlyWhenLastReferenceIsRemoved() { + void shouldKeepSharedBlobWhenFileMovesIntoRecycleBin() { User user = createUser(7L); FileBlob blob = createBlob(70L, "blobs/blob-shared", 5L, "text/plain"); StoredFile storedFile = createFile(15L, user, "/docs", "shared.txt", blob); when(storedFileRepository.findDetailedById(15L)).thenReturn(Optional.of(storedFile)); - when(storedFileRepository.countByBlobId(70L)).thenReturn(2L); fileService.delete(user, 15L); + assertThat(storedFile.getDeletedAt()).isNotNull(); + assertThat(storedFile.isRecycleRoot()).isTrue(); verify(fileContentStorage, never()).deleteBlob(any()); verify(fileBlobRepository, never()).delete(any()); - verify(storedFileRepository).delete(storedFile); + verify(storedFileRepository, never()).delete(any()); } @Test - void shouldDeleteBlobObjectWhenLastReferenceIsRemoved() { + void shouldDeleteExpiredRecycleBinBlobWhenLastReferenceIsRemoved() { User user = createUser(7L); FileBlob blob = createBlob(71L, "blobs/blob-last", 5L, "text/plain"); StoredFile storedFile = createFile(16L, user, "/docs", "last.txt", blob); - when(storedFileRepository.findDetailedById(16L)).thenReturn(Optional.of(storedFile)); + storedFile.setDeletedAt(LocalDateTime.now().minusDays(11)); + storedFile.setRecycleRoot(true); + storedFile.setRecycleGroupId("recycle-group-1"); + storedFile.setRecycleOriginalPath("/docs"); + storedFile.setPath("/.recycle/recycle-group-1/docs"); + when(storedFileRepository.findByDeletedAtBefore(any(LocalDateTime.class))).thenReturn(List.of(storedFile)); when(storedFileRepository.countByBlobId(71L)).thenReturn(1L); - fileService.delete(user, 16L); + fileService.pruneExpiredRecycleBinItems(); verify(fileContentStorage).deleteBlob("blobs/blob-last"); verify(fileBlobRepository).delete(blob); - verify(storedFileRepository).delete(storedFile); + verify(storedFileRepository).deleteAll(List.of(storedFile)); } @Test @@ -582,7 +606,8 @@ class FileServiceTest { User recipient = createUser(8L); byte[] content = "hello".getBytes(StandardCharsets.UTF_8); when(storedFileRepository.existsByUserIdAndPathAndFilename(8L, "/下载", "notes.txt")).thenReturn(false); - when(storedFileRepository.existsByUserIdAndPathAndFilename(8L, "/", "下载")).thenReturn(true); + when(storedFileRepository.findByUserIdAndPathAndFilename(8L, "/", "下载")) + .thenReturn(Optional.of(createDirectory(22L, recipient, "/", "下载"))); when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0)); doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class)); diff --git a/backend/src/test/java/com/yoyuzh/files/RecycleBinControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/files/RecycleBinControllerIntegrationTest.java new file mode 100644 index 0000000..8656473 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/RecycleBinControllerIntegrationTest.java @@ -0,0 +1,171 @@ +package com.yoyuzh.files; + +import com.yoyuzh.PortalBackendApplication; +import com.yoyuzh.auth.User; +import com.yoyuzh.auth.UserRepository; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.BeforeEach; +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 java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest( + classes = PortalBackendApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:recycle_bin_api_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", + "app.storage.root-dir=./target/test-storage-recycle-bin" + } +) +@AutoConfigureMockMvc +class RecycleBinControllerIntegrationTest { + + private static final Path STORAGE_ROOT = Path.of("./target/test-storage-recycle-bin").toAbsolutePath().normalize(); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StoredFileRepository storedFileRepository; + + @Autowired + private FileBlobRepository fileBlobRepository; + + private Long deletedFileId; + + @BeforeEach + void setUp() throws Exception { + storedFileRepository.deleteAll(); + fileBlobRepository.deleteAll(); + userRepository.deleteAll(); + + if (Files.exists(STORAGE_ROOT)) { + try (var paths = Files.walk(STORAGE_ROOT)) { + paths.sorted((left, right) -> right.compareTo(left)).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }); + } + } + Files.createDirectories(STORAGE_ROOT); + + User owner = new User(); + owner.setUsername("alice"); + owner.setEmail("alice@example.com"); + owner.setPhoneNumber("13800138000"); + owner.setPasswordHash("encoded-password"); + owner.setCreatedAt(LocalDateTime.now()); + owner = userRepository.save(owner); + + StoredFile docsDirectory = new StoredFile(); + docsDirectory.setUser(owner); + docsDirectory.setFilename("docs"); + docsDirectory.setPath("/"); + docsDirectory.setContentType("directory"); + docsDirectory.setSize(0L); + docsDirectory.setDirectory(true); + storedFileRepository.save(docsDirectory); + + FileBlob blob = new FileBlob(); + blob.setObjectKey("blobs/recycle-notes"); + blob.setContentType("text/plain"); + blob.setSize(5L); + blob.setCreatedAt(LocalDateTime.now()); + blob = fileBlobRepository.save(blob); + + StoredFile file = new StoredFile(); + file.setUser(owner); + file.setFilename("notes.txt"); + file.setPath("/docs"); + file.setContentType("text/plain"); + file.setSize(5L); + file.setDirectory(false); + file.setBlob(blob); + deletedFileId = storedFileRepository.save(file).getId(); + + Path blobPath = STORAGE_ROOT.resolve("blobs").resolve("recycle-notes"); + Files.createDirectories(blobPath.getParent()); + Files.writeString(blobPath, "hello", StandardCharsets.UTF_8); + } + + @Test + void shouldDeleteListAndRestoreFileThroughRecycleBinApi() throws Exception { + mockMvc.perform(delete("/api/files/{fileId}", deletedFileId) + .with(user("alice"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + mockMvc.perform(get("/api/files/list") + .with(user("alice")) + .param("path", "/docs") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isEmpty()); + + String recycleResponse = mockMvc.perform(get("/api/files/recycle-bin") + .with(user("alice")) + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].filename").value("notes.txt")) + .andExpect(jsonPath("$.data.items[0].path").value("/docs")) + .andExpect(jsonPath("$.data.items[0].deletedAt").isNotEmpty()) + .andReturn() + .getResponse() + .getContentAsString(); + + Number recycleRootId = JsonPath.read(recycleResponse, "$.data.items[0].id"); + + mockMvc.perform(post("/api/files/recycle-bin/{fileId}/restore", recycleRootId) + .with(user("alice"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.filename").value("notes.txt")) + .andExpect(jsonPath("$.data.path").value("/docs")); + + mockMvc.perform(get("/api/files/recycle-bin") + .with(user("alice")) + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isEmpty()); + + mockMvc.perform(get("/api/files/list") + .with(user("alice")) + .param("path", "/docs") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].filename").value("notes.txt")); + + StoredFile restoredFile = storedFileRepository.findById(deletedFileId).orElseThrow(); + assertThat(restoredFile.getDeletedAt()).isNull(); + assertThat(restoredFile.getRecycleGroupId()).isNull(); + assertThat(restoredFile.getRecycleOriginalPath()).isNull(); + } +} diff --git a/docs/api-reference.md b/docs/api-reference.md index f357648..1b68baf 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -33,8 +33,10 @@ - 采用 `Authorization: Bearer ` - `refreshToken` 通过 `/api/auth/refresh` 换取新的登录态 -- 当前实现为“单账号单设备在线” - - 新设备登录后,旧设备的 access token 会失效 +- 当前实现为“按客户端类型拆分会话” + - 桌面端与移动端可以同时在线 + - 同一端类型再次登录后,该端旧 access token 会失效 + - `/api/auth/register`、`/api/auth/login`、`/api/auth/refresh` 与开发环境 `/api/auth/dev-login` 支持可选请求头 `X-Yoyuzh-Client: desktop|mobile` ### 权限分层 @@ -64,6 +66,7 @@ - 使用邀请码注册 - 注册成功后直接返回登录态 - 邀请码成功使用后会自动刷新 +- 若请求未显式带 `X-Yoyuzh-Client`,后端默认按 `desktop` 处理 请求重点字段: @@ -90,6 +93,11 @@ - `refreshToken` - `user` +补充说明: + +- 可选请求头 `X-Yoyuzh-Client` 用于声明当前登录来自桌面端还是移动端 +- 同账号桌面端与移动端可同时保持登录,但同类型端再次登录会顶掉旧会话 + ### 2.3 刷新登录态 `POST /api/auth/refresh` @@ -102,6 +110,7 @@ - 刷新后会返回新的 access token 与 refresh token - 当前系统会让旧 refresh token 失效 +- 刷新会沿用该 refresh token 原本所属的客户端类型;请求头缺省时仍按 `desktop` 兜底 ### 2.4 开发环境登录 @@ -111,6 +120,7 @@ - 仅用于开发联调 - 是否可用取决于当前环境配置 +- 同样支持可选请求头 `X-Yoyuzh-Client: desktop|mobile` ### 2.5 获取用户资料 @@ -188,6 +198,8 @@ - `PATCH /api/files/{fileId}/move` - `POST /api/files/{fileId}/copy` - `DELETE /api/files/{fileId}` +- `GET /api/files/recycle-bin` +- `POST /api/files/recycle-bin/{fileId}/restore` 说明: @@ -195,6 +207,9 @@ - `copy` 用于复制到目标路径 - 文件和文件夹都支持移动 / 复制 - 普通文件的 `move` / `rename` / `copy` 只改逻辑元数据;`copy` 会复用原有 `FileBlob`,不会复制底层对象 +- `DELETE /api/files/{fileId}` 现在语义是“移入回收站”,不会立刻物理删除;删除的文件或整个目录树会保留 10 天 +- `GET /api/files/recycle-bin` 返回当前用户回收站根条目分页列表,包含删除时间和预计清理时间 +- `POST /api/files/recycle-bin/{fileId}/restore` 用于把某个回收站根条目恢复到原目录;若原位置已有同名文件,或当前剩余空间不足,则恢复失败 ### 3.5 分享链接 diff --git a/docs/architecture.md b/docs/architecture.md index eca464e..b66116c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,15 +107,16 @@ - 用户资料查询和修改 - 用户自行修改密码 - 头像上传 -- 单设备登录控制 +- 按客户端类型拆分的登录会话控制 - 邀请码消费与轮换 关键实现说明: - access token 使用 JWT - refresh token 持久化到数据库 -- 当前会话通过 `activeSessionId + JWT sid claim` 绑定 -- 新登录会挤掉旧设备 +- 当前会话通过“客户端类型 + 会话 ID”绑定:JWT 同时携带 `sid` 和 `client` claim +- 用户表分别记录桌面端与移动端活跃会话;桌面端仍同步回写旧的 `activeSessionId` 以兼容存量逻辑 +- 同账号现在允许桌面端与移动端同时在线,但同一端类型再次登录仍会挤掉旧会话 - 当前密码策略统一为“至少 8 位且包含大写字母” ### 3.2 网盘模块 @@ -132,6 +133,7 @@ - 文件/文件夹上传、下载、删除、重命名 - 目录创建与分页列表 - 移动、复制 +- 回收站列表、恢复与过期清理 - 分享链接与导入 - 前端树状目录导航 @@ -143,10 +145,13 @@ - 支持本地磁盘和 S3 兼容对象存储 - 分享导入与网盘复制会直接复用源文件的 `FileBlob`,不会再次写入字节内容 - 文件重命名、移动只更新 `StoredFile` 元数据,不会移动底层对象 -- 删除文件时会先删除 `StoredFile` 引用;只有最后一个引用消失时,才真正删除 `FileBlob` 对应的底层对象 +- 删除文件时不会立刻物理删除,而是把 `StoredFile` 及其目录树标记为回收站条目;根条目会记录 `deletedAt`、原始父路径和回收分组 ID,回收站保留期固定为 10 天 +- 回收站恢复会把整组条目恢复到原路径,并在恢复前检查同名冲突和用户剩余配额 +- 定时清理任务会删除超过 10 天的回收站条目;只有当某个 `FileBlob` 的最后一个逻辑引用随之消失时,才真正删除底层对象 - 应用启动时会把旧 `portal_file.storage_name` 行自动回填到新的 `blob_id` 引用,保证存量数据能继续读取 - 当前线上网盘文件存储已切到多吉云对象存储,后端先通过多吉云临时密钥 API 换取短期 S3 会话,再访问底层 COS 兼容桶 - 前端会缓存目录列表和最后访问路径 +- 桌面网盘页在左侧树状目录栏底部固定展示回收站入口;移动端在网盘页顶部提供回收站入口;两端共用独立 `RecycleBin` 页面调用 `/api/files/recycle-bin` 与恢复接口 Android 壳补充说明: @@ -156,6 +161,9 @@ Android 壳补充说明: - 后端 CORS 默认放行 `http://localhost`、`https://localhost`、`http://127.0.0.1`、`https://127.0.0.1` 与 `capacitor://localhost`,以兼容 Web 开发环境和 Android WebView 壳 - Web 端构建完成后,通过 `npx cap sync android` 把静态资源复制到 `front/android/app/src/main/assets/public` - Android 调试包当前通过 `cd front/android && ./gradlew assembleDebug` 生成,输出路径是 `front/android/app/build/outputs/apk/debug/app-debug.apk` +- 前端总览页会在 Web 环境展示稳定 APK 下载入口 `/downloads/yoyuzh-portal.apk` +- Capacitor 原生壳内的移动端总览页会改为“检查更新”入口;前端会先对 OSS 上的 APK 做 `HEAD` 探测并读取最新修改时间,再直接打开下载链接完成更新 +- 前端 OSS 发布脚本会在上传 `front/dist` 后,额外把 `front/android/app/build/outputs/apk/debug/app-debug.apk` 上传到同一个静态站桶里的 `downloads/yoyuzh-portal.apk`;这里刻意不把 APK 放进 `front/dist`,以避免后续 `npx cap sync android` 时把旧 APK 再次打进新的 Android 包 - 由于当前开发机直连 `dl.google.com` 与 Google Android Maven 仓库存在 TLS 握手失败,本地 Android 构建仓库源已切到可访问镜像;如果后续重新生成 Capacitor 工程,需要重新确认镜像配置仍存在 ### 3.3 快传模块 @@ -228,10 +236,11 @@ Android 壳补充说明: 1. 前端登录页调用 `/api/auth/login` 2. 后端鉴权成功后签发 access token + refresh token -3. 后端刷新 `activeSessionId` -4. 前端本地存储 `portal-session` -5. 后续请求通过 `Authorization: Bearer ` 访问 -6. JWT 过滤器校验 token、用户状态和会话 ID 是否仍匹配 +3. 前端同时上送 `X-Yoyuzh-Client` 标记当前是 `desktop` 还是 `mobile` +4. 后端按客户端类型刷新对应的活跃会话 ID 与 refresh token 集合 +5. 前端本地存储 `portal-session` +6. 后续请求通过 `Authorization: Bearer ` 访问,并继续带上 `X-Yoyuzh-Client` +7. JWT 过滤器校验 token、用户状态,以及当前客户端类型对应的会话 ID 是否仍匹配 补充说明: @@ -295,7 +304,7 @@ Android 壳补充说明: 1. 管理台调用 `PUT /api/admin/users/{userId}/password` 2. 后端按统一密码规则校验新密码 3. 后端重算密码哈希并写回用户表 -4. 后端刷新 `activeSessionId` 并撤销该用户全部 refresh token +4. 后端刷新桌面端与移动端全部活跃会话,并撤销该用户全部 refresh token 5. 旧密码后续登录应失败,新密码登录成功 ## 5. 前端路由架构 @@ -330,14 +339,15 @@ Android 壳补充说明: - `GET /api/files/share-links/{token}` 公开 - `/api/files/**`、`/api/user/**`、`/api/admin/**` 需登录 -### 6.2 单设备登录 +### 6.2 分端单会话登录 -当前实现不是只撤销 refresh token,而是同时控制 access token: +当前实现不是只撤销 refresh token,而是同时控制 access token,并按客户端类型拆分: -- 用户表记录 `activeSessionId` -- JWT 里包含 `sid` -- 过滤器每次请求都会比对当前用户的 `activeSessionId` -- 新登录成功后,旧设备 token 会失效 +- 前端会在鉴权与上传请求里附带 `X-Yoyuzh-Client: desktop|mobile` +- 用户表记录 `desktopActiveSessionId` 与 `mobileActiveSessionId` +- JWT 里同时包含 `sid` 和 `client` +- 过滤器每次请求都会按 token 里的 `client` 去比对对应端的活跃会话 ID +- 桌面端与移动端可以同时在线,但同一端再次登录成功后,该端旧 token 会失效 ## 7. 存储架构 diff --git a/docs/superpowers/plans/2026-04-03-overview-apk-download.md b/docs/superpowers/plans/2026-04-03-overview-apk-download.md new file mode 100644 index 0000000..2a76a25 --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-overview-apk-download.md @@ -0,0 +1,45 @@ +# Overview APK Download 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:** 在总览界面增加 Android APK 下载入口,并让前端 OSS 发布流程稳定上传最新调试 APK。 + +**Architecture:** 总览页使用固定的前端静态下载路径展示 APK 下载按钮,避免引入额外后端接口。OSS 发布仍沿用现有 `scripts/deploy-front-oss.mjs`,只额外把 `front/android/app/build/outputs/apk/debug/app-debug.apk` 上传到稳定对象 key,避免把 APK 混入 `front/dist` 进而被 Capacitor 再次打包进 Android 壳。 + +**Tech Stack:** React 19, TypeScript, Vite 6, Node.js ESM scripts + +--- + +### Task 1: 总览页下载入口状态与链接 + +**Files:** +- Modify: `front/src/lib/app-shell.ts` +- Modify: `front/src/lib/app-shell.test.ts` +- Modify: `front/src/pages/overview-state.ts` +- Modify: `front/src/pages/overview-state.test.ts` + +- [ ] **Step 1: Write the failing test** +- [ ] **Step 2: Run `cd front && npm run test` to verify the new state tests fail for the missing helpers** +- [ ] **Step 3: Add native-shell detection and APK download path helpers** +- [ ] **Step 4: Run `cd front && npm run test` to verify the helper tests pass** + +### Task 2: 桌面端与移动端总览入口 + +**Files:** +- Modify: `front/src/pages/Overview.tsx` +- Modify: `front/src/mobile-pages/MobileOverview.tsx` + +- [ ] **Step 1: Render the APK download entry only in web contexts where it is useful** +- [ ] **Step 2: Keep the current overview information architecture intact and reuse the shared helper** +- [ ] **Step 3: Run `cd front && npm run lint` to verify the React/TypeScript changes type-check** + +### Task 3: OSS 发布时上传 APK + +**Files:** +- Modify: `scripts/deploy-front-oss.mjs` +- Modify: `memory.md` +- Modify: `docs/architecture.md` + +- [ ] **Step 1: Extend the existing OSS deploy script to upload the built APK to a stable key** +- [ ] **Step 2: Verify with `node scripts/deploy-front-oss.mjs --dry-run` if credentials are available, otherwise document the gap** +- [ ] **Step 3: Update project memory and architecture notes for the new APK distribution path** diff --git a/docs/superpowers/plans/2026-04-03-recycle-bin.md b/docs/superpowers/plans/2026-04-03-recycle-bin.md new file mode 100644 index 0000000..ad8c76d --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-recycle-bin.md @@ -0,0 +1,59 @@ +# Recycle Bin 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:** 为网盘增加回收站,删除后的文件或目录自动进入回收站,保留 10 天,可在保留期内恢复,并在网盘左侧目录侧栏底部提供入口。 + +**Architecture:** 后端把删除操作从“物理删除”改成“回收站软删除”,为 `StoredFile` 增加删除时间、原始路径/名称、回收站分组信息,并提供“列出回收站”“恢复”“清空过期项”能力。前端在 `Files` 页目录树侧栏底部增加回收站入口,并新增回收站页面/状态,复用现有文件卡片风格展示删除时间、原始位置和恢复操作。 + +**Tech Stack:** Spring Boot 3.3, JPA/Hibernate update schema, React 19, TypeScript, Vite + +--- + +### Task 1: 后端回收站模型与服务 + +**Files:** +- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFile.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/FileService.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/FileController.java` +- Create: `backend/src/main/java/com/yoyuzh/files/RecycleBinItemResponse.java` +- Create: `backend/src/main/java/com/yoyuzh/files/RestoreRecycleBinItemRequest.java` + +- [ ] **Step 1: 写后端失败测试,覆盖删除进入回收站、回收站列表、恢复、10 天过期清理** +- [ ] **Step 2: 运行 `cd backend && mvn test` 确认新增测试先失败** +- [ ] **Step 3: 给 `StoredFile` 增加回收站字段,并让普通文件列表/最近文件默认排除已删除项** +- [ ] **Step 4: 把删除改成软删除,目录删除时按组放入回收站,恢复时按组恢复** +- [ ] **Step 5: 增加回收站列表、恢复接口和定时清理过期项** +- [ ] **Step 6: 运行 `cd backend && mvn test` 确认后端通过** + +### Task 2: 前端回收站入口与页面 + +**Files:** +- Modify: `front/src/components/layout/Layout.tsx` +- Modify: `front/src/components/layout/Layout.test.ts` +- Modify: `front/src/App.tsx` +- Modify: `front/src/pages/Files.tsx` +- Create: `front/src/pages/RecycleBin.tsx` +- Create: `front/src/pages/recycle-bin-state.ts` +- Create: `front/src/pages/recycle-bin-state.test.ts` +- Modify: `front/src/lib/types.ts` +- Modify: `front/src/MobileApp.tsx` +- Modify: `front/src/mobile-pages/MobileFiles.tsx` + +- [ ] **Step 1: 写前端失败测试,锁住侧栏底部入口和回收站状态变换** +- [ ] **Step 2: 运行 `cd front && npm run test` 确认前端新增测试先失败** +- [ ] **Step 3: 新增回收站页面和状态,接后端列表/恢复接口** +- [ ] **Step 4: 在桌面网盘左侧侧栏最下方加回收站入口,并把删除确认文案改为“移入回收站”** +- [ ] **Step 5: 给移动端补可访问的回收站入口和路由** +- [ ] **Step 6: 运行 `cd front && npm run test`、`cd front && npm run lint`、`cd front && npm run build`** + +### Task 3: 文档与项目记忆 + +**Files:** +- Modify: `memory.md` +- Modify: `docs/architecture.md` +- Modify: `docs/api-reference.md` + +- [ ] **Step 1: 记录回收站行为、保留周期、入口位置和恢复约束** +- [ ] **Step 2: 在交付前确认验证命令和已知限制写入文档** diff --git a/front/src/App.tsx b/front/src/App.tsx index 7a5b661..d81752b 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -5,6 +5,7 @@ import { useAuth } from './auth/AuthProvider'; import Login from './pages/Login'; import Overview from './pages/Overview'; import Files from './pages/Files'; +import RecycleBin from './pages/RecycleBin'; import Transfer from './pages/Transfer'; import FileShare from './pages/FileShare'; import Games from './pages/Games'; @@ -58,6 +59,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/front/src/MobileApp.tsx b/front/src/MobileApp.tsx index 60a922a..69785fe 100644 --- a/front/src/MobileApp.tsx +++ b/front/src/MobileApp.tsx @@ -11,6 +11,7 @@ import MobileOverview from './mobile-pages/MobileOverview'; import MobileFiles from './mobile-pages/MobileFiles'; import MobileTransfer from './mobile-pages/MobileTransfer'; import MobileFileShare from './mobile-pages/MobileFileShare'; +import RecycleBin from './pages/RecycleBin'; function LegacyTransferRedirect() { const location = useLocation(); @@ -53,6 +54,7 @@ function MobileAppRoutes() { } /> } /> } /> + } /> } /> diff --git a/front/src/components/ui/card.test.tsx b/front/src/components/ui/card.test.tsx new file mode 100644 index 0000000..80d47a0 --- /dev/null +++ b/front/src/components/ui/card.test.tsx @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { Card } from './card'; + +test('Card applies the shared elevated shadow styling', () => { + const html = renderToStaticMarkup(demo); + + assert.match(html, /shadow-\[0_12px_32px_rgba\(15,23,42,0\.18\)\]/); +}); diff --git a/front/src/components/ui/card.tsx b/front/src/components/ui/card.tsx index ffcfe0d..c4a6ad5 100644 --- a/front/src/components/ui/card.tsx +++ b/front/src/components/ui/card.tsx @@ -8,7 +8,7 @@ const Card = React.forwardRef<
{ @@ -148,6 +149,10 @@ function normalizePath(path: string) { return path.startsWith('/') ? path : `/${path}`; } +function shouldAttachPortalClientHeader(path: string) { + return !/^https?:\/\//.test(path); +} + function shouldAttemptTokenRefresh(path: string) { const normalizedPath = normalizePath(path); return ![ @@ -189,12 +194,15 @@ async function refreshAccessToken() { refreshRequestPromise = (async () => { try { + const headers = new Headers({ + Accept: 'application/json', + 'Content-Type': 'application/json', + }); + headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType()); + const response = await fetch(resolveUrl(AUTH_REFRESH_PATH), { method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, + headers, body: JSON.stringify({ refreshToken: currentSession.refreshToken, }), @@ -269,6 +277,9 @@ async function performRequest(path: string, init: ApiRequestInit = {}, allowRefr if (session?.token) { headers.set('Authorization', `Bearer ${session.token}`); } + if (shouldAttachPortalClientHeader(path) && !headers.has(PORTAL_CLIENT_HEADER)) { + headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType()); + } if (requestBody && !(requestBody instanceof FormData) && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } @@ -341,6 +352,9 @@ function apiUploadRequestInternal(path: string, init: ApiUploadRequestInit, a if (session?.token) { headers.set('Authorization', `Bearer ${session.token}`); } + if (shouldAttachPortalClientHeader(path) && !headers.has(PORTAL_CLIENT_HEADER)) { + headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType()); + } if (!headers.has('Accept')) { headers.set('Accept', 'application/json'); } diff --git a/front/src/lib/app-shell.test.ts b/front/src/lib/app-shell.test.ts index c517c9c..4e87bb8 100644 --- a/front/src/lib/app-shell.test.ts +++ b/front/src/lib/app-shell.test.ts @@ -1,10 +1,29 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { MOBILE_APP_MAX_WIDTH, shouldUseMobileApp } from './app-shell'; +import { + MOBILE_APP_MAX_WIDTH, + isNativeAppShellLocation, + resolvePortalClientType, + shouldUseMobileApp, +} from './app-shell'; test('shouldUseMobileApp enables the mobile shell below the width breakpoint', () => { assert.equal(shouldUseMobileApp(MOBILE_APP_MAX_WIDTH - 1), true); assert.equal(shouldUseMobileApp(MOBILE_APP_MAX_WIDTH), false); assert.equal(shouldUseMobileApp(1280), false); }); + +test('isNativeAppShellLocation matches Capacitor localhost origins', () => { + assert.equal(isNativeAppShellLocation(new URL('https://localhost')), true); + assert.equal(isNativeAppShellLocation(new URL('http://127.0.0.1')), true); + assert.equal(isNativeAppShellLocation(new URL('capacitor://localhost')), true); + assert.equal(isNativeAppShellLocation(new URL('http://localhost:3000')), false); + assert.equal(isNativeAppShellLocation(new URL('https://yoyuzh.xyz')), false); +}); + +test('resolvePortalClientType distinguishes desktop web from mobile shell or narrow screens', () => { + assert.equal(resolvePortalClientType({ location: new URL('https://yoyuzh.xyz'), viewportWidth: 1280 }), 'desktop'); + assert.equal(resolvePortalClientType({ location: new URL('https://yoyuzh.xyz'), viewportWidth: 390 }), 'mobile'); + assert.equal(resolvePortalClientType({ location: new URL('https://localhost') }), 'mobile'); +}); diff --git a/front/src/lib/app-shell.ts b/front/src/lib/app-shell.ts index 6f3cabb..431aec5 100644 --- a/front/src/lib/app-shell.ts +++ b/front/src/lib/app-shell.ts @@ -1,5 +1,69 @@ export const MOBILE_APP_MAX_WIDTH = 768; +export const PORTAL_CLIENT_HEADER = 'X-Yoyuzh-Client'; + +export type PortalClientType = 'desktop' | 'mobile'; export function shouldUseMobileApp(width: number) { return width < MOBILE_APP_MAX_WIDTH; } + +export function isNativeAppShellLocation(location: Location | URL | null) { + if (!location) { + return false; + } + + const hostname = location.hostname || ''; + const protocol = location.protocol || ''; + const port = location.port || ''; + + if (protocol === 'capacitor:') { + return true; + } + + const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1'; + const isCapacitorLocalScheme = protocol === 'http:' || protocol === 'https:'; + + return isLocalhostHost && isCapacitorLocalScheme && port === ''; +} + +function resolveRuntimeViewportWidth() { + if (typeof globalThis.innerWidth === 'number' && Number.isFinite(globalThis.innerWidth)) { + return globalThis.innerWidth; + } + + if (typeof window !== 'undefined' && typeof window.innerWidth === 'number') { + return window.innerWidth; + } + + return null; +} + +function resolveRuntimeLocation() { + if (typeof globalThis.location !== 'undefined') { + return globalThis.location; + } + + if (typeof window !== 'undefined') { + return window.location; + } + + return null; +} + +export function resolvePortalClientType({ + location = resolveRuntimeLocation(), + viewportWidth = resolveRuntimeViewportWidth(), +}: { + location?: Location | URL | null; + viewportWidth?: number | null; +} = {}): PortalClientType { + if (isNativeAppShellLocation(location)) { + return 'mobile'; + } + + if (typeof viewportWidth === 'number' && shouldUseMobileApp(viewportWidth)) { + return 'mobile'; + } + + return 'desktop'; +} diff --git a/front/src/lib/types.ts b/front/src/lib/types.ts index a93c1cc..0909f2d 100644 --- a/front/src/lib/types.ts +++ b/front/src/lib/types.ts @@ -106,6 +106,18 @@ export interface FileMetadata { createdAt: string; } +export interface RecycleBinItem { + id: number; + filename: string; + path: string; + size: number; + contentType: string | null; + directory: boolean; + createdAt: string; + deletedAt: string; + expiresAt: string; +} + export interface InitiateUploadResponse { direct: boolean; uploadUrl: string; diff --git a/front/src/mobile-components/MobileLayout.tsx b/front/src/mobile-components/MobileLayout.tsx index f0d9b43..9d30e79 100644 --- a/front/src/mobile-components/MobileLayout.tsx +++ b/front/src/mobile-components/MobileLayout.tsx @@ -17,6 +17,7 @@ import { AnimatePresence, motion } from 'motion/react'; import { useAuth } from '@/src/auth/AuthProvider'; import { apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api'; +import { isNativeAppShellLocation } from '@/src/lib/app-shell'; import { createSession, readStoredSession, saveStoredSession } from '@/src/lib/session'; import type { AuthResponse, InitiateUploadResponse, UserProfile } from '@/src/lib/types'; import { cn } from '@/src/lib/utils'; @@ -33,16 +34,7 @@ const NAV_ITEMS = [ ] as const; export function isNativeMobileShellLocation(location: Location | URL | null) { - if (!location) { - return false; - } - - const hostname = location.hostname || ''; - const protocol = location.protocol || ''; - const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1'; - const isCapacitorScheme = protocol === 'http:' || protocol === 'https:' || protocol === 'capacitor:'; - - return isLocalhostHost && isCapacitorScheme; + return isNativeAppShellLocation(location); } export function getMobileViewportOffsetClassNames(isNativeShell = false) { diff --git a/front/src/mobile-pages/MobileFiles.tsx b/front/src/mobile-pages/MobileFiles.tsx index 5e584bc..d3979a1 100644 --- a/front/src/mobile-pages/MobileFiles.tsx +++ b/front/src/mobile-pages/MobileFiles.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; +import { useNavigate } from 'react-router-dom'; import { ChevronRight, Folder, @@ -13,7 +14,8 @@ import { Edit2, Trash2, FolderPlus, - ChevronLeft + ChevronLeft, + RotateCcw, } from 'lucide-react'; import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal'; @@ -66,6 +68,7 @@ import { import { toDirectoryPath, } from '@/src/pages/files-tree'; +import { RECYCLE_BIN_RETENTION_DAYS, RECYCLE_BIN_ROUTE } from '@/src/pages/recycle-bin-state'; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -119,6 +122,7 @@ interface UiFile { type NetdiskTargetAction = 'move' | 'copy'; export default function MobileFiles() { + const navigate = useNavigate(); const initialPath = readCachedValue(getFilesLastPathCacheKey()) ?? []; const initialCachedFiles = readCachedValue(getFilesListCacheKey(toBackendPath(initialPath))) ?? []; @@ -445,19 +449,29 @@ export default function MobileFiles() { {/* Top Header - Path navigation */}
-
- {currentPath.length > 0 && ( - - )} - - {currentPath.map((pathItem, index) => ( - - - - - ))} +
+
+ {currentPath.length > 0 && ( + + )} + + {currentPath.map((pathItem, index) => ( + + + + + ))} +
+
@@ -584,10 +598,10 @@ export default function MobileFiles() { setDeleteModalOpen(false)} />

确认删除

-

你确实要彻底删除 {fileToDelete?.name} 吗?

+

确定要将 {fileToDelete?.name} 移入回收站吗?文件会保留 {RECYCLE_BIN_RETENTION_DAYS} 天,期间可以恢复。

- +
diff --git a/front/src/mobile-pages/MobileOverview.tsx b/front/src/mobile-pages/MobileOverview.tsx index 0c6eb17..b22e3e6 100644 --- a/front/src/mobile-pages/MobileOverview.tsx +++ b/front/src/mobile-pages/MobileOverview.tsx @@ -9,6 +9,7 @@ import { FolderPlus, Mail, Send, + Smartphone, Upload, User, Zap, @@ -25,7 +26,14 @@ import { getOverviewCacheKey } from '@/src/lib/page-cache'; import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session'; import type { FileMetadata, PageResponse, UserProfile } from '@/src/lib/types'; -import { getOverviewLoadErrorMessage } from '@/src/pages/overview-state'; +import { + APK_DOWNLOAD_PUBLIC_URL, + APK_DOWNLOAD_PATH, + getMobileOverviewApkEntryMode, + getOverviewLoadErrorMessage, + getOverviewStorageQuotaLabel, + shouldShowOverviewApkDownload, +} from '@/src/pages/overview-state'; function formatFileSize(size: number) { if (size <= 0) return '0 B'; @@ -56,6 +64,8 @@ export default function MobileOverview() { const [loadingError, setLoadingError] = useState(''); const [retryToken, setRetryToken] = useState(0); const [avatarUrl, setAvatarUrl] = useState(null); + const [apkActionMessage, setApkActionMessage] = useState(''); + const [checkingApkUpdate, setCheckingApkUpdate] = useState(false); const currentHour = new Date().getHours(); let greeting = '晚上好'; @@ -72,6 +82,46 @@ export default function MobileOverview() { const latestFile = recentFiles[0] ?? null; const profileDisplayName = profile?.displayName || profile?.username || '未登录'; const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase(); + const showApkDownload = shouldShowOverviewApkDownload(); + const apkEntryMode = getMobileOverviewApkEntryMode(); + + const handleCheckApkUpdate = async () => { + setCheckingApkUpdate(true); + setApkActionMessage(''); + + try { + const response = await fetch(APK_DOWNLOAD_PUBLIC_URL, { + method: 'HEAD', + cache: 'no-store', + }); + if (!response.ok) { + throw new Error(`检查更新失败 (${response.status})`); + } + + const lastModified = response.headers.get('last-modified'); + setApkActionMessage( + lastModified + ? `发现最新安装包,更新时间 ${new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(lastModified))},正在打开下载链接。` + : '发现最新安装包,正在打开下载链接。' + ); + + if (typeof window !== 'undefined') { + const openedWindow = window.open(APK_DOWNLOAD_PUBLIC_URL, '_blank', 'noopener,noreferrer'); + if (!openedWindow) { + window.location.href = APK_DOWNLOAD_PUBLIC_URL; + } + } + } catch (error) { + setApkActionMessage(error instanceof Error ? error.message : '检查更新失败,请稍后重试'); + } finally { + setCheckingApkUpdate(false); + } + }; useEffect(() => { let cancelled = false; @@ -166,7 +216,7 @@ export default function MobileOverview() { - +
{/* 快捷操作区 */} @@ -182,6 +232,48 @@ export default function MobileOverview() { + {showApkDownload || apkEntryMode === 'update' ? ( + +
+ +
+
+ +
+
+

Android 客户端

+

+ {apkEntryMode === 'update' + ? '在 App 内检查 OSS 上的最新安装包,并跳转到更新下载链接。' + : '总览页可直接下载最新 APK,安装包与前端站点一起托管在 OSS。'} +

+
+
+ {apkEntryMode === 'update' ? ( + + ) : ( + + 下载 APK + + )} + {apkActionMessage ? ( +

{apkActionMessage}

+ ) : null} +
+ + ) : null} + {/* 近期文件 (精简版) */} diff --git a/front/src/pages/Files.tsx b/front/src/pages/Files.tsx index e516ba2..13973df 100644 --- a/front/src/pages/Files.tsx +++ b/front/src/pages/Files.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; +import { useLocation, useNavigate } from 'react-router-dom'; import { ChevronDown, Folder, @@ -16,6 +17,7 @@ import { X, Edit2, Trash2, + RotateCcw, } from 'lucide-react'; import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal'; @@ -74,6 +76,7 @@ import { type DirectoryChildrenMap, type DirectoryTreeNode, } from './files-tree'; +import { getFilesSidebarFooterEntries, RECYCLE_BIN_RETENTION_DAYS, RECYCLE_BIN_ROUTE } from './recycle-bin-state'; function sleep(ms: number) { return new Promise((resolve) => { @@ -180,6 +183,8 @@ interface UiFile { type NetdiskTargetAction = 'move' | 'copy'; export default function Files() { + const navigate = useNavigate(); + const location = useLocation(); const initialPath = readCachedValue(getFilesLastPathCacheKey()) ?? []; const initialCachedFiles = readCachedValue(getFilesListCacheKey(toBackendPath(initialPath))) ?? []; const fileInputRef = useRef(null); @@ -752,11 +757,11 @@ export default function Files() { return (
{/* Left Sidebar */} - - -
+ + +

网盘目录

-
+
-
+
{directoryTree.map((node) => (
+
+ {getFilesSidebarFooterEntries().map((entry) => { + const isActive = location.pathname === entry.path || location.pathname === RECYCLE_BIN_ROUTE; + return ( + + ); + })} +
@@ -1136,7 +1165,7 @@ export default function Files() {

- 确定要删除 {fileToDelete?.name} 吗?此操作无法撤销。 + 确定要将 {fileToDelete?.name} 移入回收站吗?文件会保留 {RECYCLE_BIN_RETENTION_DAYS} 天,期间可以恢复。

diff --git a/front/src/pages/Overview.tsx b/front/src/pages/Overview.tsx index 3f74f7f..47cc939 100644 --- a/front/src/pages/Overview.tsx +++ b/front/src/pages/Overview.tsx @@ -9,6 +9,7 @@ import { FolderPlus, Mail, Send, + Smartphone, Upload, User, Zap, @@ -25,7 +26,14 @@ import { getOverviewCacheKey } from '@/src/lib/page-cache'; import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session'; import type { FileMetadata, PageResponse, UserProfile } from '@/src/lib/types'; -import { getOverviewLoadErrorMessage } from './overview-state'; +import { + APK_DOWNLOAD_PATH, + getDesktopOverviewSectionColumns, + getDesktopOverviewStretchSection, + getOverviewLoadErrorMessage, + getOverviewStorageQuotaLabel, + shouldShowOverviewApkDownload, +} from './overview-state'; function formatFileSize(size: number) { if (size <= 0) { @@ -89,6 +97,9 @@ export default function Overview() { const latestFile = recentFiles[0] ?? null; const profileDisplayName = profile?.displayName || profile?.username || '未登录'; const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase(); + const showApkDownload = shouldShowOverviewApkDownload(); + const desktopSections = getDesktopOverviewSectionColumns(showApkDownload); + const desktopStretchSection = getDesktopOverviewStretchSection(showApkDownload); useEffect(() => { let cancelled = false; @@ -242,8 +253,8 @@ export default function Overview() { />
-
-
+
+
最近文件 @@ -297,9 +308,9 @@ export default function Overview() { - + -
+
@@ -326,9 +337,45 @@ export default function Overview() {
+ + {desktopSections.main.includes('apk-download') ? ( + + +
+
+
+
+
+ + Android 客户端 +
+
+

下载 APK 安装包

+

+ 当前 Android 安装包会随前端站点一起发布到 OSS,可直接从这里下载最新版本。 +

+
+
+ 稳定路径 + OSS 托管 + 一键下载 +
+
+ + 下载 APK + +
+
+ + + ) : null}
-
+
快捷操作 @@ -353,7 +400,7 @@ export default function Overview() {

{usedGb.toFixed(2)} GB

-

已使用 / 共 50 GB

+

{getOverviewStorageQuotaLabel(storageQuotaBytes)}

{storagePercent.toFixed(1)}%
diff --git a/front/src/pages/RecycleBin.tsx b/front/src/pages/RecycleBin.tsx new file mode 100644 index 0000000..3c7b6d2 --- /dev/null +++ b/front/src/pages/RecycleBin.tsx @@ -0,0 +1,165 @@ +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Clock3, Folder, RefreshCw, RotateCcw, Trash2 } from 'lucide-react'; + +import { Button } from '@/src/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card'; +import { apiRequest } from '@/src/lib/api'; +import type { PageResponse, RecycleBinItem } from '@/src/lib/types'; + +import { formatRecycleBinExpiresLabel, RECYCLE_BIN_RETENTION_DAYS } from './recycle-bin-state'; + +function formatFileSize(size: number) { + if (size <= 0) { + return '—'; + } + + const units = ['B', 'KB', 'MB', 'GB']; + const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1); + const value = size / 1024 ** index; + return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; +} + +function formatDateTime(value: string) { + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(value)); +} + +export default function RecycleBin() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [restoringId, setRestoringId] = useState(null); + + const loadRecycleBin = async () => { + setLoading(true); + setError(''); + try { + const response = await apiRequest>('/files/recycle-bin?page=0&size=100'); + setItems(response.items); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : '回收站加载失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadRecycleBin(); + }, []); + + const handleRestore = async (itemId: number) => { + setRestoringId(itemId); + setError(''); + try { + await apiRequest(`/files/recycle-bin/${itemId}/restore`, { + method: 'POST', + }); + setItems((previous) => previous.filter((item) => item.id !== itemId)); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : '恢复失败'); + } finally { + setRestoringId(null); + } + }; + + return ( +
+ + +
+
+ + 回收站保留 {RECYCLE_BIN_RETENTION_DAYS} 天 +
+ 网盘回收站 +

+ 删除的文件会先进入回收站,{RECYCLE_BIN_RETENTION_DAYS} 天内可恢复,到期后自动清理。 +

+
+
+ + + 返回网盘 + +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {loading ? ( +
+ 正在加载回收站... +
+ ) : items.length === 0 ? ( +
+
+ +
+
+

回收站为空

+

删除后的文件会在这里保留 10 天。

+
+
+ ) : ( +
+ {items.map((item) => ( +
+
+
+
+ +
+
+

{item.filename}

+

{item.path}

+
+
+
+ {item.directory ? '文件夹' : formatFileSize(item.size)} + 删除于 {formatDateTime(item.deletedAt)} + + + {formatRecycleBinExpiresLabel(item.expiresAt)} + +
+
+ +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/front/src/pages/overview-state.test.ts b/front/src/pages/overview-state.test.ts index 0cef8f3..19ab382 100644 --- a/front/src/pages/overview-state.test.ts +++ b/front/src/pages/overview-state.test.ts @@ -1,7 +1,16 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { getOverviewLoadErrorMessage } from './overview-state'; +import { + APK_DOWNLOAD_PATH, + APK_DOWNLOAD_PUBLIC_URL, + getDesktopOverviewSectionColumns, + getDesktopOverviewStretchSection, + getMobileOverviewApkEntryMode, + getOverviewLoadErrorMessage, + getOverviewStorageQuotaLabel, + shouldShowOverviewApkDownload, +} from './overview-state'; test('post-login failures are presented as overview initialization issues', () => { assert.equal( @@ -16,3 +25,42 @@ test('generic overview failures stay generic when not coming right after login', '总览数据加载失败,请稍后重试。' ); }); + +test('overview exposes a stable apk download path for oss hosting', () => { + assert.equal(APK_DOWNLOAD_PATH, '/downloads/yoyuzh-portal.apk'); + assert.equal(APK_DOWNLOAD_PUBLIC_URL, 'https://yoyuzh.xyz/downloads/yoyuzh-portal.apk'); +}); + +test('overview hides the apk download entry inside the native app shell', () => { + assert.equal(shouldShowOverviewApkDownload(new URL('https://yoyuzh.xyz')), true); + assert.equal(shouldShowOverviewApkDownload(new URL('https://localhost')), false); +}); + +test('mobile overview switches from download mode to update mode inside the native shell', () => { + assert.equal(getMobileOverviewApkEntryMode(new URL('https://yoyuzh.xyz')), 'download'); + assert.equal(getMobileOverviewApkEntryMode(new URL('https://localhost')), 'update'); +}); + +test('desktop overview places the apk card in the main column to avoid empty left-side space', () => { + assert.deepEqual(getDesktopOverviewSectionColumns(true), { + main: ['recent-files', 'transfer-workbench', 'apk-download'], + sidebar: ['quick-actions', 'storage', 'account'], + }); +}); + +test('desktop overview omits the apk card entirely when the download entry is hidden', () => { + assert.deepEqual(getDesktopOverviewSectionColumns(false), { + main: ['recent-files', 'transfer-workbench'], + sidebar: ['quick-actions', 'storage', 'account'], + }); +}); + +test('desktop overview stretches the last visible main card to keep column bottoms aligned', () => { + assert.equal(getDesktopOverviewStretchSection(true), 'apk-download'); + assert.equal(getDesktopOverviewStretchSection(false), 'transfer-workbench'); +}); + +test('overview storage quota label uses the real quota instead of a fixed 50 GB copy', () => { + assert.equal(getOverviewStorageQuotaLabel(50 * 1024 * 1024 * 1024), '已使用 / 共 50 GB'); + assert.equal(getOverviewStorageQuotaLabel(100 * 1024 * 1024 * 1024), '已使用 / 共 100 GB'); +}); diff --git a/front/src/pages/overview-state.ts b/front/src/pages/overview-state.ts index 4fcc3af..35b1ec4 100644 --- a/front/src/pages/overview-state.ts +++ b/front/src/pages/overview-state.ts @@ -1,3 +1,36 @@ +import { isNativeAppShellLocation } from '@/src/lib/app-shell'; + +export const APK_DOWNLOAD_PATH = '/downloads/yoyuzh-portal.apk'; +export const APK_DOWNLOAD_PUBLIC_URL = 'https://yoyuzh.xyz/downloads/yoyuzh-portal.apk'; + +function formatOverviewStorageSize(size: number) { + if (size <= 0) { + return '0 B'; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1); + const value = size / 1024 ** index; + return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; +} + +export function getDesktopOverviewSectionColumns(showApkDownload: boolean) { + return { + main: showApkDownload + ? ['recent-files', 'transfer-workbench', 'apk-download'] + : ['recent-files', 'transfer-workbench'], + sidebar: ['quick-actions', 'storage', 'account'], + }; +} + +export function getDesktopOverviewStretchSection(showApkDownload: boolean) { + return showApkDownload ? 'apk-download' : 'transfer-workbench'; +} + +export function getOverviewStorageQuotaLabel(storageQuotaBytes: number) { + return `已使用 / 共 ${formatOverviewStorageSize(storageQuotaBytes)}`; +} + export function getOverviewLoadErrorMessage(isPostLoginFailure: boolean) { if (isPostLoginFailure) { return '登录已成功,但总览数据加载失败,请稍后重试。'; @@ -5,3 +38,23 @@ export function getOverviewLoadErrorMessage(isPostLoginFailure: boolean) { return '总览数据加载失败,请稍后重试。'; } + +function resolveOverviewLocation() { + if (typeof globalThis.location !== 'undefined') { + return globalThis.location; + } + + if (typeof window !== 'undefined') { + return window.location; + } + + return null; +} + +export function shouldShowOverviewApkDownload(location: Location | URL | null = resolveOverviewLocation()) { + return !isNativeAppShellLocation(location); +} + +export function getMobileOverviewApkEntryMode(location: Location | URL | null = resolveOverviewLocation()) { + return isNativeAppShellLocation(location) ? 'update' : 'download'; +} diff --git a/front/src/pages/recycle-bin-state.test.ts b/front/src/pages/recycle-bin-state.test.ts new file mode 100644 index 0000000..8fb4c69 --- /dev/null +++ b/front/src/pages/recycle-bin-state.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + RECYCLE_BIN_ROUTE, + RECYCLE_BIN_RETENTION_DAYS, + formatRecycleBinExpiresLabel, + getFilesSidebarFooterEntries, +} from './recycle-bin-state'; + +test('files sidebar keeps the recycle bin entry at the bottom footer area', () => { + const footerEntries = getFilesSidebarFooterEntries(); + + assert.equal(footerEntries.at(-1)?.path, RECYCLE_BIN_ROUTE); + assert.equal(footerEntries.at(-1)?.label, '回收站'); +}); + +test('recycle bin retention stays fixed at ten days', () => { + assert.equal(RECYCLE_BIN_RETENTION_DAYS, 10); +}); + +test('recycle bin expiry labels show the remaining days before purge', () => { + assert.equal( + formatRecycleBinExpiresLabel('2026-04-13T10:00:00', new Date('2026-04-03T10:00:00')), + '10 天后清理' + ); + assert.equal( + formatRecycleBinExpiresLabel('2026-04-04T09:00:00', new Date('2026-04-03T10:00:00')), + '1 天后清理' + ); +}); diff --git a/front/src/pages/recycle-bin-state.ts b/front/src/pages/recycle-bin-state.ts new file mode 100644 index 0000000..362867c --- /dev/null +++ b/front/src/pages/recycle-bin-state.ts @@ -0,0 +1,27 @@ +export const RECYCLE_BIN_ROUTE = '/recycle-bin'; +export const RECYCLE_BIN_RETENTION_DAYS = 10; + +export interface FilesSidebarFooterEntry { + label: string; + path: string; +} + +export function getFilesSidebarFooterEntries(): FilesSidebarFooterEntry[] { + return [ + { + label: '回收站', + path: RECYCLE_BIN_ROUTE, + }, + ]; +} + +export function formatRecycleBinExpiresLabel(expiresAt: string, now = new Date()) { + const expiresAtDate = new Date(expiresAt); + const diffMs = expiresAtDate.getTime() - now.getTime(); + if (Number.isNaN(expiresAtDate.getTime()) || diffMs <= 0) { + return '今天清理'; + } + + const remainingDays = Math.max(1, Math.ceil(diffMs / (24 * 60 * 60 * 1000))); + return `${remainingDays} 天后清理`; +} diff --git a/memory.md b/memory.md index 71df66d..70547a7 100644 --- a/memory.md +++ b/memory.md @@ -7,7 +7,7 @@ - 快传模块已整合进主站,支持取件码、分享链接、P2P 传输、部分文件接收、ZIP 下载、存入网盘 - 网盘已支持上传、下载、重命名、删除、移动、复制、公开分享、接收快传后存入 - 注册改成邀请码机制,邀请码单次使用后自动刷新,并在管理台展示与复制 - - 同账号仅允许一台设备同时登录,旧设备会在下一次访问受保护接口时失效 + - 同账号现已允许桌面端与移动端同时在线,但同一端类型仍只保留一个有效会话;同端再次登录会在下一次受保护请求时挤掉旧会话 - 后端已补生产 CORS,默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz` - 线上文件存储与前端静态托管已迁到多吉云对象存储,后端通过临时密钥 API 获取短期 S3 会话访问底层 COS 兼容桶 - 管理台 dashboard 已显示总存储量、下载流量、今日请求次数、快传使用量、离线快传占用和请求折线图,并支持调整离线快传总上限 @@ -30,6 +30,10 @@ - 2026-04-03 Android 打包已确认走“Vite 产物 -> `npx cap sync android` -> Gradle `assembleDebug`”链路;当前应用包名为 `xyz.yoyuzh.portal` - 2026-04-03 Android WebView 壳内的前端 API 基址已改成运行时判断:Web 站点继续走相对 `/api`,Capacitor `localhost` 壳在 `http://localhost` 与 `https://localhost` 下都会默认直连 `https://api.yoyuzh.xyz/api`,避免 APK 把请求误打到应用内本地地址;后端 CORS 也同步放行了 `https://localhost` - 2026-04-03 由于这台机器直连 `dl.google.com` / Android Maven 仓库会 TLS 握手失败,Android 构建已改走阿里云 Google Maven 镜像,并通过 `redirector.gvt1.com` 手动落本机 SDK 包 + - 2026-04-03 总览页已新增 Android APK 下载入口;Web 桌面端和移动端总览都会展示稳定下载链接 `/downloads/yoyuzh-portal.apk` + - 2026-04-03 鉴权链路已按客户端类型拆分会话:前端请求会带 `X-Yoyuzh-Client`,后端分别维护桌面和移动的活跃 `sid` 与 refresh token 集合,因此桌面 Web 与移动端 APK 可同时登录;移动端总览页在 Capacitor 原生壳内会显示“检查更新”,通过探测 OSS 上 APK 最新修改时间并直接跳转下载链接完成更新 + - 2026-04-03 前端 OSS 发布脚本已支持额外上传 `front/android/app/build/outputs/apk/debug/app-debug.apk` 到对象存储稳定 key `downloads/yoyuzh-portal.apk`;这样不会把 APK 混进 `front/dist`,也不会在后续 `npx cap sync android` 时被再次打包进 Android 壳 + - 2026-04-03 网盘已新增回收站:`DELETE /api/files/{id}` 现在会把文件或整个目录树软删除进回收站,默认保留 10 天;前端桌面网盘页在左侧目录栏最下方新增“回收站”入口,移动端网盘页头也可进入回收站查看并恢复 - 根目录 README 已重写为中文公开版 GitHub 风格 - VS Code 工作区已补 `.vscode/settings.json`、`.vscode/extensions.json`、`lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor - 进行中: @@ -47,7 +51,7 @@ | 快传接收页收口回原 `/transfer` 页面 | 用户不需要单独进入专门的接收页面,入口更统一 | 独立接收页: 路径分散、用户心智更差 | | 网盘侧边栏改成单一树状目录结构 | 更像真实网盘,层级关系清晰 | 保留“快速访问 + 目录”双区块: 结构割裂 | | 注册邀请码改成单次使用后自动刷新 | 更适合私域邀请式注册,管理台也能直接查看当前邀请码 | 固定邀请码: 容易扩散且不可控 | -| 单设备登录通过“用户当前会话 ID + JWT sid claim”实现 | 新登录能立即顶掉旧 access token,而不仅仅是旧 refresh token | 只撤销 refresh token: 旧 access token 仍会继续有效一段时间 | +| 登录态通过“按客户端类型拆分的会话 ID + JWT sid/client claim”实现 | 桌面 Web 和移动 APK 可以同时在线,但同一端再次登录仍会立即挤掉旧 access token,而不仅仅是旧 refresh token | 只保留全局单会话: 会让桌面/移动互相顶下线;只撤销 refresh token: 旧 access token 仍会继续有效一段时间 | | 前端发布继续使用 `node scripts/deploy-front-oss.mjs` | 仓库已有正式静态站发布脚本,现已切到多吉云临时密钥 + S3 兼容上传流程 | 手动上传对象存储: 容易出错,也不利于复用 | | 后端发布继续采用“本地打包 + SSH/ SCP 上传 jar + systemd 重启” | 当前线上就按这个方式运行 | 自创部署脚本: 仓库里没有现成正式脚本,容易和现网偏离 | | 主站 CORS 默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz` | 前端生产环境托管在独立静态站域名下,必须允许主站跨域调用后端 API | 仅保留 localhost: 会导致生产站调用 API 时被浏览器拦截 | @@ -58,6 +62,8 @@ | 前端主入口按宽度自动切换到移动壳 | 不需要单独维护 `/m` 路由,用户在小屏设备上直接进入移动端布局 | 独立 `/m` 路由: 需要额外记忆入口且与主站状态分叉 | | 管理台上线记录按“JWT 鉴权成功的每日去重用户”统计,并只保留 7 天 | 后台需要回答“每天多少人上线、具体是谁”,同时不必引入更重的行为埋点系统 | 只统计登录接口: 无法覆盖 refresh 之后的真实活跃访问;无限保留历史: 超出当前管理需求 | | Android 客户端先采用 Capacitor 包裹现有前端站点 | 现有 React/Vite 页面、鉴权和 API 调用可以直接复用,成本最低 | 重新单写原生 Android WebView 壳: 会引入额外原生维护面;改成 React Native / Flutter: 超出当前需求 | +| APK 发布通过前端 OSS 脚本额外上传稳定对象 key,而不是进入 `front/dist` | 既能让总览页长期使用固定下载地址,也能避免 `npx cap sync android` 把旧 APK 再次塞进新的 APK 资产里 | 把 APK 直接放进 `front/public` 或 `front/dist`: 会污染前端静态产物,并可能导致 Android 包体递归膨胀 | +| 网盘删除采用“回收站软删除 + 10 天过期清理” | 用户删错文件后需要可恢复,同时共享 blob 仍要等最后引用真正过期后才删除底层对象 | 继续立即物理删除: 不可恢复且误删成本高;额外建独立归档表: 当前需求下实现过重 | ## 待解决问题 - [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误 @@ -92,6 +98,7 @@ - `cd front && npx cap sync android` - `cd front/android && ./gradlew assembleDebug` - Android 调试 APK 当前输出路径:`front/android/app/build/outputs/apk/debug/app-debug.apk` +- 前端 OSS 发布现会额外把调试 APK 上传到稳定对象 key:`downloads/yoyuzh-portal.apk` - 服务器登录信息保存在本地 `账号密码.txt`,不要把内容写进文档或对外输出 ## 参考资料 @@ -102,7 +109,7 @@ - 前端/后端工作区配置: `.vscode/settings.json`、`.vscode/extensions.json` - Lombok 配置: `lombok.config` - 最近关键实现位置: - - 单设备登录: `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/config/JwtAuthenticationFilter.java` - CORS 配置: `backend/src/main/java/com/yoyuzh/config/CorsProperties.java`、`backend/src/main/resources/application.yml` @@ -116,5 +123,6 @@ - 管理台统计与 7 天上线记录: `backend/src/main/java/com/yoyuzh/admin/AdminMetricsService.java`、`backend/src/main/java/com/yoyuzh/admin/AdminDailyActiveUserEntity.java`、`backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java` - 管理台 dashboard 展示与请求折线图: `front/src/admin/dashboard.tsx`、`front/src/admin/dashboard-state.ts` - 网盘 blob 模型与回填: `backend/src/main/java/com/yoyuzh/files/FileService.java`、`backend/src/main/java/com/yoyuzh/files/FileBlob.java`、`backend/src/main/java/com/yoyuzh/files/FileBlobBackfillService.java` + - 网盘回收站与恢复: `backend/src/main/java/com/yoyuzh/files/FileService.java`、`backend/src/main/java/com/yoyuzh/files/FileController.java`、`backend/src/main/java/com/yoyuzh/files/StoredFile.java`、`front/src/pages/RecycleBin.tsx`、`front/src/pages/recycle-bin-state.ts` - 前端生产 API 基址: `front/.env.production` - Capacitor Android 入口与配置: `front/capacitor.config.ts`、`front/android/` diff --git a/scripts/deploy-front-oss.mjs b/scripts/deploy-front-oss.mjs index ce5b481..49323aa 100755 --- a/scripts/deploy-front-oss.mjs +++ b/scripts/deploy-front-oss.mjs @@ -23,6 +23,8 @@ const repoRoot = process.cwd(); const frontDir = path.join(repoRoot, 'front'); const distDir = path.join(frontDir, 'dist'); const envFilePath = path.join(repoRoot, '.env.oss.local'); +const apkSourcePath = path.join(frontDir, 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk'); +const apkObjectPath = 'downloads/yoyuzh-portal.apk'; function parseArgs(argv) { return { @@ -153,6 +155,48 @@ async function uploadSpaAliases({ } } +async function uploadApkIfPresent({ + bucket, + endpoint, + region, + accessKeyId, + secretAccessKey, + sessionToken, + remotePrefix, + dryRun, +}) { + try { + await fs.access(apkSourcePath); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + console.warn(`skip apk upload: not found at ${apkSourcePath}`); + return; + } + + throw error; + } + + const objectKey = buildObjectKey(remotePrefix, apkObjectPath); + + if (dryRun) { + console.log(`[dry-run] upload ${apkObjectPath} -> ${objectKey}`); + return; + } + + await uploadFile({ + bucket, + endpoint, + region, + objectKey, + filePath: apkSourcePath, + contentTypeOverride: 'application/vnd.android.package-archive', + accessKeyId, + secretAccessKey, + sessionToken, + }); + console.log(`uploaded ${objectKey}`); +} + async function main() { const {dryRun, skipBuild} = parseArgs(process.argv.slice(2)); @@ -221,6 +265,17 @@ async function main() { remotePrefix, dryRun, }); + + await uploadApkIfPresent({ + bucket, + endpoint, + region, + accessKeyId, + secretAccessKey, + sessionToken, + remotePrefix, + dryRun, + }); } main().catch((error) => { diff --git a/scripts/oss-deploy-lib.mjs b/scripts/oss-deploy-lib.mjs index 7707d40..a69c83f 100644 --- a/scripts/oss-deploy-lib.mjs +++ b/scripts/oss-deploy-lib.mjs @@ -21,6 +21,7 @@ const FRONTEND_SPA_ALIASES = [ 't', 'overview', 'files', + 'recycle-bin', 'transfer', 'games', 'login',