Enable dual-device login and mobile APK update checks
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
23
backend/src/main/java/com/yoyuzh/auth/AuthClientType.java
Normal file
23
backend/src/main/java/com/yoyuzh/auth/AuthClientType.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user