Enable dual-device login and mobile APK update checks

This commit is contained in:
yoyuzh
2026-04-03 16:28:09 +08:00
parent 56f2a9fe0d
commit 52b5bbfe8e
50 changed files with 1659 additions and 164 deletions

View File

@@ -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));
}

View File

@@ -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;
}
}

View File

@@ -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<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
return ApiResponse.success(authService.register(request));
public ApiResponse<AuthResponse> 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<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
return ApiResponse.success(authService.login(request));
public ApiResponse<AuthResponse> 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<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
return ApiResponse.success(authService.refresh(request.refreshToken()));
public ApiResponse<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request,
@RequestHeader(name = AuthClientType.HEADER_NAME, required = false) String clientTypeHeader) {
return ApiResponse.success(authService.refresh(request.refreshToken(), AuthClientType.fromHeader(clientTypeHeader)));
}
}

View File

@@ -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;

View File

@@ -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<AuthResponse> devLogin(@RequestParam(required = false) String username) {
return ApiResponse.success(authService.devLogin(username));
public ApiResponse<AuthResponse> devLogin(@RequestParam(required = false) String username,
@RequestHeader(name = AuthClientType.HEADER_NAME, required = false) String clientTypeHeader) {
return ApiResponse.success(authService.devLogin(username, AuthClientType.fromHeader(clientTypeHeader)));
}
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -23,4 +23,15 @@ public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long
where token.user.id = :userId and token.revoked = false
""")
int revokeAllActiveByUserId(@Param("userId") Long userId, @Param("revokedAt") LocalDateTime revokedAt);
@Modifying
@Query("""
update RefreshToken token
set token.revoked = true, token.revokedAt = :revokedAt
where token.user.id = :userId and token.revoked = false
and (token.clientType = :clientType or (:clientType = 'DESKTOP' and token.clientType is null))
""")
int revokeAllActiveByUserIdAndClientType(@Param("userId") Long userId,
@Param("clientType") String clientType,
@Param("revokedAt") LocalDateTime revokedAt);
}

View File

@@ -27,11 +27,17 @@ public class RefreshTokenService {
@Transactional
public String issueRefreshToken(User user) {
return issueRefreshToken(user, AuthClientType.DESKTOP);
}
@Transactional
public String issueRefreshToken(User user, AuthClientType clientType) {
String rawToken = generateRawToken();
RefreshToken refreshToken = new RefreshToken();
refreshToken.setUser(user);
refreshToken.setTokenHash(hashToken(rawToken));
refreshToken.setClientType(clientType.name());
refreshToken.setExpiresAt(LocalDateTime.now().plusSeconds(jwtProperties.getRefreshExpirationSeconds()));
refreshToken.setRevoked(false);
refreshTokenRepository.save(refreshToken);
@@ -54,11 +60,12 @@ public class RefreshTokenService {
}
User user = existing.getUser();
AuthClientType clientType = AuthClientType.fromHeader(existing.getClientType());
existing.revoke(LocalDateTime.now());
revokeAllForUser(user.getId());
revokeAllForUser(user.getId(), clientType);
String nextRefreshToken = issueRefreshToken(user);
return new RotatedRefreshToken(user, nextRefreshToken);
String nextRefreshToken = issueRefreshToken(user, clientType);
return new RotatedRefreshToken(user, nextRefreshToken, clientType);
}
@Transactional
@@ -66,6 +73,11 @@ public class RefreshTokenService {
refreshTokenRepository.revokeAllActiveByUserId(userId, LocalDateTime.now());
}
@Transactional
public void revokeAllForUser(Long userId, AuthClientType clientType) {
refreshTokenRepository.revokeAllActiveByUserIdAndClientType(userId, clientType.name(), LocalDateTime.now());
}
private String generateRawToken() {
byte[] bytes = new byte[REFRESH_TOKEN_BYTES];
secureRandom.nextBytes(bytes);
@@ -85,6 +97,6 @@ public class RefreshTokenService {
}
}
public record RotatedRefreshToken(User user, String refreshToken) {
public record RotatedRefreshToken(User user, String refreshToken, AuthClientType clientType) {
}
}

View File

@@ -63,6 +63,12 @@ public class User {
@Column(name = "active_session_id", length = 64)
private String activeSessionId;
@Column(name = "desktop_active_session_id", length = 64)
private String desktopActiveSessionId;
@Column(name = "mobile_active_session_id", length = 64)
private String mobileActiveSessionId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private UserRole role;
@@ -202,6 +208,22 @@ public class User {
this.activeSessionId = activeSessionId;
}
public String getDesktopActiveSessionId() {
return desktopActiveSessionId;
}
public void setDesktopActiveSessionId(String desktopActiveSessionId) {
this.desktopActiveSessionId = desktopActiveSessionId;
}
public String getMobileActiveSessionId() {
return mobileActiveSessionId;
}
public void setMobileActiveSessionId(String mobileActiveSessionId) {
this.mobileActiveSessionId = mobileActiveSessionId;
}
public UserRole getRole() {
return role;
}

View File

@@ -44,7 +44,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
return;
}
if (!jwtTokenProvider.hasMatchingSession(token, domainUser.getActiveSessionId())) {
if (!jwtTokenProvider.hasMatchingSession(token, domainUser)) {
filterChain.doFilter(request, response);
return;
}

View File

@@ -82,6 +82,18 @@ public class FileController {
return ApiResponse.success(fileService.recent(userDetailsService.loadDomainUser(userDetails.getUsername())));
}
@Operation(summary = "分页列出回收站")
@GetMapping("/recycle-bin")
public ApiResponse<PageResponse<RecycleBinItemResponse>> 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<FileMetadataResponse> restoreRecycleBinItem(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long fileId) {
return ApiResponse.success(fileService.restoreFromRecycleBin(
userDetailsService.loadDomainUser(userDetails.getUsername()),
fileId
));
}
}

View File

@@ -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<String> 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<FileMetadataResponse> recent(User user) {
return storedFileRepository.findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(user.getId())
return storedFileRepository.findTop12ByUserIdAndDirectoryFalseAndDeletedAtIsNullOrderByCreatedAtDesc(user.getId())
.stream()
.map(this::toResponse)
.toList();
}
public PageResponse<RecycleBinItemResponse> listRecycleBin(User user, int page, int size) {
Page<StoredFile> result = storedFileRepository.findRecycleBinRootsByUserId(user.getId(), PageRequest.of(page, size));
List<RecycleBinItemResponse> 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<StoredFile> filesToDelete = new ArrayList<>();
StoredFile storedFile = getOwnedActiveFile(user, fileId, "删除");
List<StoredFile> filesToRecycle = new ArrayList<>();
filesToRecycle.add(storedFile);
if (storedFile.isDirectory()) {
String logicalPath = buildLogicalPath(storedFile);
List<StoredFile> 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<FileBlob> blobsToDelete = collectBlobsToDelete(filesToDelete);
storedFileRepository.delete(storedFile);
moveToRecycleBin(filesToRecycle, storedFile.getId());
}
@Transactional
public FileMetadataResponse restoreFromRecycleBin(User user, Long fileId) {
StoredFile recycleRoot = getOwnedRecycleRootFile(user, fileId);
List<StoredFile> 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<StoredFile> expiredItems = storedFileRepository.findByDeletedAtBefore(LocalDateTime.now().minusDays(RECYCLE_BIN_RETENTION_DAYS));
if (expiredItems.isEmpty()) {
return;
}
List<FileBlob> 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<StoredFile> loadRecycleGroupItems(StoredFile recycleRoot) {
List<StoredFile> 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<StoredFile> 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<StoredFile> 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<StoredFile> 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<StoredFile> 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;

View File

@@ -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
) {
}

View File

@@ -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;
}
}

View File

@@ -22,6 +22,7 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
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<StoredFile, Long> {
@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<StoredFile, Long> {
@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<StoredFile> findByUserIdAndPathAndFilename(@Param("userId") Long userId,
@Param("path") String path,
@@ -50,7 +51,7 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
@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<StoredFile> findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc(@Param("userId") Long userId,
@@ -60,7 +61,7 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
@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<StoredFile> findByUserIdAndPathEqualsOrDescendant(@Param("userId") Long userId,
@@ -69,7 +70,7 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
@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<StoredFile, Long> {
long sumAllFileSize();
@EntityGraph(attributePaths = "blob")
List<StoredFile> findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(Long userId);
List<StoredFile> 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<StoredFile> 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<StoredFile> 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<StoredFile> findByDeletedAtBefore(@Param("cutoff") java.time.LocalDateTime cutoff);
@Query("""
select count(f)