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 user = getRequiredUser(userId);
|
||||||
user.setBanned(banned);
|
user.setBanned(banned);
|
||||||
user.setActiveSessionId(UUID.randomUUID().toString());
|
user.setActiveSessionId(UUID.randomUUID().toString());
|
||||||
|
user.setDesktopActiveSessionId(UUID.randomUUID().toString());
|
||||||
|
user.setMobileActiveSessionId(UUID.randomUUID().toString());
|
||||||
refreshTokenService.revokeAllForUser(user.getId());
|
refreshTokenService.revokeAllForUser(user.getId());
|
||||||
return toUserResponse(userRepository.save(user));
|
return toUserResponse(userRepository.save(user));
|
||||||
}
|
}
|
||||||
@@ -114,6 +116,8 @@ public class AdminService {
|
|||||||
User user = getRequiredUser(userId);
|
User user = getRequiredUser(userId);
|
||||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||||
user.setActiveSessionId(UUID.randomUUID().toString());
|
user.setActiveSessionId(UUID.randomUUID().toString());
|
||||||
|
user.setDesktopActiveSessionId(UUID.randomUUID().toString());
|
||||||
|
user.setMobileActiveSessionId(UUID.randomUUID().toString());
|
||||||
refreshTokenService.revokeAllForUser(user.getId());
|
refreshTokenService.revokeAllForUser(user.getId());
|
||||||
return toUserResponse(userRepository.save(user));
|
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 io.swagger.v3.oas.annotations.Operation;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -22,19 +23,22 @@ public class AuthController {
|
|||||||
|
|
||||||
@Operation(summary = "用户注册")
|
@Operation(summary = "用户注册")
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ApiResponse<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
public ApiResponse<AuthResponse> register(@Valid @RequestBody RegisterRequest request,
|
||||||
return ApiResponse.success(authService.register(request));
|
@RequestHeader(name = AuthClientType.HEADER_NAME, required = false) String clientTypeHeader) {
|
||||||
|
return ApiResponse.success(authService.register(request, AuthClientType.fromHeader(clientTypeHeader)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "用户登录")
|
@Operation(summary = "用户登录")
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ApiResponse<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
|
public ApiResponse<AuthResponse> login(@Valid @RequestBody LoginRequest request,
|
||||||
return ApiResponse.success(authService.login(request));
|
@RequestHeader(name = AuthClientType.HEADER_NAME, required = false) String clientTypeHeader) {
|
||||||
|
return ApiResponse.success(authService.login(request, AuthClientType.fromHeader(clientTypeHeader)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "刷新访问令牌")
|
@Operation(summary = "刷新访问令牌")
|
||||||
@PostMapping("/refresh")
|
@PostMapping("/refresh")
|
||||||
public ApiResponse<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
|
public ApiResponse<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request,
|
||||||
return ApiResponse.success(authService.refresh(request.refreshToken()));
|
@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
|
@Transactional
|
||||||
public AuthResponse register(RegisterRequest request) {
|
public AuthResponse register(RegisterRequest request) {
|
||||||
|
return register(request, AuthClientType.DESKTOP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public AuthResponse register(RegisterRequest request, AuthClientType clientType) {
|
||||||
if (userRepository.existsByUsername(request.username())) {
|
if (userRepository.existsByUsername(request.username())) {
|
||||||
throw new BusinessException(ErrorCode.UNKNOWN, "用户名已存在");
|
throw new BusinessException(ErrorCode.UNKNOWN, "用户名已存在");
|
||||||
}
|
}
|
||||||
@@ -72,11 +77,16 @@ public class AuthService {
|
|||||||
user.setPreferredLanguage("zh-CN");
|
user.setPreferredLanguage("zh-CN");
|
||||||
User saved = userRepository.save(user);
|
User saved = userRepository.save(user);
|
||||||
fileService.ensureDefaultDirectories(saved);
|
fileService.ensureDefaultDirectories(saved);
|
||||||
return issueFreshTokens(saved);
|
return issueFreshTokens(saved, clientType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AuthResponse login(LoginRequest request) {
|
public AuthResponse login(LoginRequest request) {
|
||||||
|
return login(request, AuthClientType.DESKTOP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public AuthResponse login(LoginRequest request, AuthClientType clientType) {
|
||||||
try {
|
try {
|
||||||
authenticationManager.authenticate(
|
authenticationManager.authenticate(
|
||||||
new UsernamePasswordAuthenticationToken(request.username(), request.password()));
|
new UsernamePasswordAuthenticationToken(request.username(), request.password()));
|
||||||
@@ -89,11 +99,16 @@ public class AuthService {
|
|||||||
User user = userRepository.findByUsername(request.username())
|
User user = userRepository.findByUsername(request.username())
|
||||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||||
fileService.ensureDefaultDirectories(user);
|
fileService.ensureDefaultDirectories(user);
|
||||||
return issueFreshTokens(user);
|
return issueFreshTokens(user, clientType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AuthResponse devLogin(String username) {
|
public AuthResponse devLogin(String username) {
|
||||||
|
return devLogin(username, AuthClientType.DESKTOP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public AuthResponse devLogin(String username, AuthClientType clientType) {
|
||||||
String candidate = username == null ? "" : username.trim();
|
String candidate = username == null ? "" : username.trim();
|
||||||
if (candidate.isEmpty()) {
|
if (candidate.isEmpty()) {
|
||||||
candidate = "1";
|
candidate = "1";
|
||||||
@@ -111,13 +126,19 @@ public class AuthService {
|
|||||||
return userRepository.save(created);
|
return userRepository.save(created);
|
||||||
});
|
});
|
||||||
fileService.ensureDefaultDirectories(user);
|
fileService.ensureDefaultDirectories(user);
|
||||||
return issueFreshTokens(user);
|
return issueFreshTokens(user, clientType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AuthResponse refresh(String refreshToken) {
|
public AuthResponse refresh(String refreshToken) {
|
||||||
|
return refresh(refreshToken, AuthClientType.DESKTOP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public AuthResponse refresh(String refreshToken, AuthClientType defaultClientType) {
|
||||||
RefreshTokenService.RotatedRefreshToken rotated = refreshTokenService.rotateRefreshToken(refreshToken);
|
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) {
|
public UserProfileResponse getProfile(String username) {
|
||||||
@@ -158,7 +179,9 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.setPasswordHash(passwordEncoder.encode(request.newPassword()));
|
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) {
|
public InitiateUploadResponse initiateAvatarUpload(String username, UpdateUserAvatarRequest request) {
|
||||||
@@ -267,26 +290,43 @@ public class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AuthResponse issueFreshTokens(User user) {
|
private AuthResponse issueFreshTokens(User user, AuthClientType clientType) {
|
||||||
refreshTokenService.revokeAllForUser(user.getId());
|
refreshTokenService.revokeAllForUser(user.getId(), clientType);
|
||||||
return issueTokens(user, refreshTokenService.issueRefreshToken(user));
|
return issueTokens(user, refreshTokenService.issueRefreshToken(user, clientType), clientType);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AuthResponse issueTokens(User user, String refreshToken) {
|
private AuthResponse issueTokens(User user, String refreshToken, AuthClientType clientType) {
|
||||||
User sessionUser = rotateActiveSession(user);
|
User sessionUser = rotateActiveSession(user, clientType);
|
||||||
String accessToken = jwtTokenProvider.generateAccessToken(
|
String accessToken = jwtTokenProvider.generateAccessToken(
|
||||||
sessionUser.getId(),
|
sessionUser.getId(),
|
||||||
sessionUser.getUsername(),
|
sessionUser.getUsername(),
|
||||||
sessionUser.getActiveSessionId()
|
getActiveSessionId(sessionUser, clientType),
|
||||||
|
clientType
|
||||||
);
|
);
|
||||||
return AuthResponse.issued(accessToken, refreshToken, toProfile(sessionUser));
|
return AuthResponse.issued(accessToken, refreshToken, toProfile(sessionUser));
|
||||||
}
|
}
|
||||||
|
|
||||||
private User rotateActiveSession(User user) {
|
private User rotateActiveSession(User user, AuthClientType clientType) {
|
||||||
user.setActiveSessionId(UUID.randomUUID().toString());
|
String nextSessionId = UUID.randomUUID().toString();
|
||||||
|
if (clientType == AuthClientType.MOBILE) {
|
||||||
|
user.setMobileActiveSessionId(nextSessionId);
|
||||||
|
} else {
|
||||||
|
user.setDesktopActiveSessionId(nextSessionId);
|
||||||
|
user.setActiveSessionId(nextSessionId);
|
||||||
|
}
|
||||||
return userRepository.save(user);
|
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) {
|
private String normalizeOptionalText(String value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.yoyuzh.common.ApiResponse;
|
|||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.context.annotation.Profile;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@@ -20,7 +21,8 @@ public class DevAuthController {
|
|||||||
|
|
||||||
@Operation(summary = "开发环境免密登录")
|
@Operation(summary = "开发环境免密登录")
|
||||||
@PostMapping("/dev-login")
|
@PostMapping("/dev-login")
|
||||||
public ApiResponse<AuthResponse> devLogin(@RequestParam(required = false) String username) {
|
public ApiResponse<AuthResponse> devLogin(@RequestParam(required = false) String username,
|
||||||
return ApiResponse.success(authService.devLogin(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) {
|
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();
|
Instant now = Instant.now();
|
||||||
var builder = Jwts.builder()
|
var builder = Jwts.builder()
|
||||||
.subject(username)
|
.subject(username)
|
||||||
.claim("uid", userId)
|
.claim("uid", userId)
|
||||||
|
.claim("client", clientType.name())
|
||||||
.issuedAt(Date.from(now))
|
.issuedAt(Date.from(now))
|
||||||
.expiration(Date.from(now.plusSeconds(jwtProperties.getAccessExpirationSeconds())))
|
.expiration(Date.from(now.plusSeconds(jwtProperties.getAccessExpirationSeconds())))
|
||||||
.signWith(secretKey);
|
.signWith(secretKey);
|
||||||
@@ -79,6 +84,11 @@ public class JwtTokenProvider {
|
|||||||
return sessionId == null ? null : sessionId.toString();
|
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) {
|
public boolean hasMatchingSession(String token, String activeSessionId) {
|
||||||
String tokenSessionId = getSessionId(token);
|
String tokenSessionId = getSessionId(token);
|
||||||
|
|
||||||
@@ -89,6 +99,17 @@ public class JwtTokenProvider {
|
|||||||
return activeSessionId.equals(tokenSessionId);
|
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) {
|
private Claims parseClaims(String token) {
|
||||||
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload();
|
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public class RefreshToken {
|
|||||||
@Column(name = "revoked", nullable = false)
|
@Column(name = "revoked", nullable = false)
|
||||||
private boolean revoked;
|
private boolean revoked;
|
||||||
|
|
||||||
|
@Column(name = "client_type", nullable = false, length = 16)
|
||||||
|
private String clientType;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
@@ -50,6 +53,9 @@ public class RefreshToken {
|
|||||||
if (createdAt == null) {
|
if (createdAt == null) {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now();
|
||||||
}
|
}
|
||||||
|
if (clientType == null || clientType.isBlank()) {
|
||||||
|
clientType = AuthClientType.DESKTOP.name();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void revoke(LocalDateTime revokedAt) {
|
public void revoke(LocalDateTime revokedAt) {
|
||||||
@@ -97,6 +103,14 @@ public class RefreshToken {
|
|||||||
this.revoked = revoked;
|
this.revoked = revoked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getClientType() {
|
||||||
|
return clientType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setClientType(String clientType) {
|
||||||
|
this.clientType = clientType;
|
||||||
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public LocalDateTime getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,4 +23,15 @@ public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long
|
|||||||
where token.user.id = :userId and token.revoked = false
|
where token.user.id = :userId and token.revoked = false
|
||||||
""")
|
""")
|
||||||
int revokeAllActiveByUserId(@Param("userId") Long userId, @Param("revokedAt") LocalDateTime revokedAt);
|
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
|
@Transactional
|
||||||
public String issueRefreshToken(User user) {
|
public String issueRefreshToken(User user) {
|
||||||
|
return issueRefreshToken(user, AuthClientType.DESKTOP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public String issueRefreshToken(User user, AuthClientType clientType) {
|
||||||
String rawToken = generateRawToken();
|
String rawToken = generateRawToken();
|
||||||
|
|
||||||
RefreshToken refreshToken = new RefreshToken();
|
RefreshToken refreshToken = new RefreshToken();
|
||||||
refreshToken.setUser(user);
|
refreshToken.setUser(user);
|
||||||
refreshToken.setTokenHash(hashToken(rawToken));
|
refreshToken.setTokenHash(hashToken(rawToken));
|
||||||
|
refreshToken.setClientType(clientType.name());
|
||||||
refreshToken.setExpiresAt(LocalDateTime.now().plusSeconds(jwtProperties.getRefreshExpirationSeconds()));
|
refreshToken.setExpiresAt(LocalDateTime.now().plusSeconds(jwtProperties.getRefreshExpirationSeconds()));
|
||||||
refreshToken.setRevoked(false);
|
refreshToken.setRevoked(false);
|
||||||
refreshTokenRepository.save(refreshToken);
|
refreshTokenRepository.save(refreshToken);
|
||||||
@@ -54,11 +60,12 @@ public class RefreshTokenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
User user = existing.getUser();
|
User user = existing.getUser();
|
||||||
|
AuthClientType clientType = AuthClientType.fromHeader(existing.getClientType());
|
||||||
existing.revoke(LocalDateTime.now());
|
existing.revoke(LocalDateTime.now());
|
||||||
revokeAllForUser(user.getId());
|
revokeAllForUser(user.getId(), clientType);
|
||||||
|
|
||||||
String nextRefreshToken = issueRefreshToken(user);
|
String nextRefreshToken = issueRefreshToken(user, clientType);
|
||||||
return new RotatedRefreshToken(user, nextRefreshToken);
|
return new RotatedRefreshToken(user, nextRefreshToken, clientType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -66,6 +73,11 @@ public class RefreshTokenService {
|
|||||||
refreshTokenRepository.revokeAllActiveByUserId(userId, LocalDateTime.now());
|
refreshTokenRepository.revokeAllActiveByUserId(userId, LocalDateTime.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void revokeAllForUser(Long userId, AuthClientType clientType) {
|
||||||
|
refreshTokenRepository.revokeAllActiveByUserIdAndClientType(userId, clientType.name(), LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
private String generateRawToken() {
|
private String generateRawToken() {
|
||||||
byte[] bytes = new byte[REFRESH_TOKEN_BYTES];
|
byte[] bytes = new byte[REFRESH_TOKEN_BYTES];
|
||||||
secureRandom.nextBytes(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)
|
@Column(name = "active_session_id", length = 64)
|
||||||
private String activeSessionId;
|
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)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false, length = 32)
|
@Column(nullable = false, length = 32)
|
||||||
private UserRole role;
|
private UserRole role;
|
||||||
@@ -202,6 +208,22 @@ public class User {
|
|||||||
this.activeSessionId = activeSessionId;
|
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() {
|
public UserRole getRole() {
|
||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!jwtTokenProvider.hasMatchingSession(token, domainUser.getActiveSessionId())) {
|
if (!jwtTokenProvider.hasMatchingSession(token, domainUser)) {
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,18 @@ public class FileController {
|
|||||||
return ApiResponse.success(fileService.recent(userDetailsService.loadDomainUser(userDetails.getUsername())));
|
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 = "下载文件")
|
@Operation(summary = "下载文件")
|
||||||
@GetMapping("/download/{fileId}")
|
@GetMapping("/download/{fileId}")
|
||||||
public ResponseEntity<?> download(@AuthenticationPrincipal UserDetails userDetails,
|
public ResponseEntity<?> download(@AuthenticationPrincipal UserDetails userDetails,
|
||||||
@@ -162,4 +174,14 @@ public class FileController {
|
|||||||
fileService.delete(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId);
|
fileService.delete(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId);
|
||||||
return ApiResponse.success();
|
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.data.domain.PageRequest;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -23,12 +24,14 @@ import java.io.IOException;
|
|||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipEntry;
|
||||||
@@ -37,6 +40,8 @@ import java.util.zip.ZipOutputStream;
|
|||||||
@Service
|
@Service
|
||||||
public class FileService {
|
public class FileService {
|
||||||
private static final List<String> DEFAULT_DIRECTORIES = List.of("下载", "文档", "图片");
|
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 StoredFileRepository storedFileRepository;
|
||||||
private final FileBlobRepository fileBlobRepository;
|
private final FileBlobRepository fileBlobRepository;
|
||||||
@@ -145,12 +150,18 @@ public class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<FileMetadataResponse> recent(User user) {
|
public List<FileMetadataResponse> recent(User user) {
|
||||||
return storedFileRepository.findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(user.getId())
|
return storedFileRepository.findTop12ByUserIdAndDirectoryFalseAndDeletedAtIsNullOrderByCreatedAtDesc(user.getId())
|
||||||
.stream()
|
.stream()
|
||||||
.map(this::toResponse)
|
.map(this::toResponse)
|
||||||
.toList();
|
.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
|
@Transactional
|
||||||
public void ensureDefaultDirectories(User user) {
|
public void ensureDefaultDirectories(User user) {
|
||||||
for (String directoryName : DEFAULT_DIRECTORIES) {
|
for (String directoryName : DEFAULT_DIRECTORIES) {
|
||||||
@@ -174,26 +185,58 @@ public class FileService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void delete(User user, Long fileId) {
|
public void delete(User user, Long fileId) {
|
||||||
StoredFile storedFile = getOwnedFile(user, fileId, "删除");
|
StoredFile storedFile = getOwnedActiveFile(user, fileId, "删除");
|
||||||
List<StoredFile> filesToDelete = new ArrayList<>();
|
List<StoredFile> filesToRecycle = new ArrayList<>();
|
||||||
|
filesToRecycle.add(storedFile);
|
||||||
if (storedFile.isDirectory()) {
|
if (storedFile.isDirectory()) {
|
||||||
String logicalPath = buildLogicalPath(storedFile);
|
String logicalPath = buildLogicalPath(storedFile);
|
||||||
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), logicalPath);
|
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), logicalPath);
|
||||||
filesToDelete.addAll(descendants.stream().filter(descendant -> !descendant.isDirectory()).toList());
|
filesToRecycle.addAll(descendants);
|
||||||
if (!descendants.isEmpty()) {
|
|
||||||
storedFileRepository.deleteAll(descendants);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
filesToDelete.add(storedFile);
|
|
||||||
}
|
}
|
||||||
List<FileBlob> blobsToDelete = collectBlobsToDelete(filesToDelete);
|
moveToRecycleBin(filesToRecycle, storedFile.getId());
|
||||||
storedFileRepository.delete(storedFile);
|
}
|
||||||
|
|
||||||
|
@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);
|
deleteBlobs(blobsToDelete);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public FileMetadataResponse rename(User user, Long fileId, String nextFilename) {
|
public FileMetadataResponse rename(User user, Long fileId, String nextFilename) {
|
||||||
StoredFile storedFile = getOwnedFile(user, fileId, "重命名");
|
StoredFile storedFile = getOwnedActiveFile(user, fileId, "重命名");
|
||||||
String sanitizedFilename = normalizeLeafName(nextFilename);
|
String sanitizedFilename = normalizeLeafName(nextFilename);
|
||||||
if (sanitizedFilename.equals(storedFile.getFilename())) {
|
if (sanitizedFilename.equals(storedFile.getFilename())) {
|
||||||
return toResponse(storedFile);
|
return toResponse(storedFile);
|
||||||
@@ -228,7 +271,7 @@ public class FileService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public FileMetadataResponse move(User user, Long fileId, String nextPath) {
|
public FileMetadataResponse move(User user, Long fileId, String nextPath) {
|
||||||
StoredFile storedFile = getOwnedFile(user, fileId, "移动");
|
StoredFile storedFile = getOwnedActiveFile(user, fileId, "移动");
|
||||||
String normalizedTargetPath = normalizeDirectoryPath(nextPath);
|
String normalizedTargetPath = normalizeDirectoryPath(nextPath);
|
||||||
if (normalizedTargetPath.equals(storedFile.getPath())) {
|
if (normalizedTargetPath.equals(storedFile.getPath())) {
|
||||||
return toResponse(storedFile);
|
return toResponse(storedFile);
|
||||||
@@ -268,7 +311,7 @@ public class FileService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public FileMetadataResponse copy(User user, Long fileId, String nextPath) {
|
public FileMetadataResponse copy(User user, Long fileId, String nextPath) {
|
||||||
StoredFile storedFile = getOwnedFile(user, fileId, "复制");
|
StoredFile storedFile = getOwnedActiveFile(user, fileId, "复制");
|
||||||
String normalizedTargetPath = normalizeDirectoryPath(nextPath);
|
String normalizedTargetPath = normalizeDirectoryPath(nextPath);
|
||||||
ensureExistingDirectoryPath(user.getId(), normalizedTargetPath);
|
ensureExistingDirectoryPath(user.getId(), normalizedTargetPath);
|
||||||
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedTargetPath, storedFile.getFilename())) {
|
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedTargetPath, storedFile.getFilename())) {
|
||||||
@@ -322,7 +365,7 @@ public class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ResponseEntity<?> download(User user, Long fileId) {
|
public ResponseEntity<?> download(User user, Long fileId) {
|
||||||
StoredFile storedFile = getOwnedFile(user, fileId, "下载");
|
StoredFile storedFile = getOwnedActiveFile(user, fileId, "下载");
|
||||||
if (storedFile.isDirectory()) {
|
if (storedFile.isDirectory()) {
|
||||||
return downloadDirectory(user, storedFile);
|
return downloadDirectory(user, storedFile);
|
||||||
}
|
}
|
||||||
@@ -344,7 +387,7 @@ public class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public DownloadUrlResponse getDownloadUrl(User user, Long fileId) {
|
public DownloadUrlResponse getDownloadUrl(User user, Long fileId) {
|
||||||
StoredFile storedFile = getOwnedFile(user, fileId, "下载");
|
StoredFile storedFile = getOwnedActiveFile(user, fileId, "下载");
|
||||||
if (storedFile.isDirectory()) {
|
if (storedFile.isDirectory()) {
|
||||||
throw new BusinessException(ErrorCode.UNKNOWN, "目录不支持下载");
|
throw new BusinessException(ErrorCode.UNKNOWN, "目录不支持下载");
|
||||||
}
|
}
|
||||||
@@ -362,7 +405,7 @@ public class FileService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public CreateFileShareLinkResponse createShareLink(User user, Long fileId) {
|
public CreateFileShareLinkResponse createShareLink(User user, Long fileId) {
|
||||||
StoredFile storedFile = getOwnedFile(user, fileId, "分享");
|
StoredFile storedFile = getOwnedActiveFile(user, fileId, "分享");
|
||||||
if (storedFile.isDirectory()) {
|
if (storedFile.isDirectory()) {
|
||||||
throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持分享链接");
|
throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持分享链接");
|
||||||
}
|
}
|
||||||
@@ -500,6 +543,25 @@ public class FileService {
|
|||||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "分享链接不存在"));
|
.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) {
|
private StoredFile getOwnedFile(User user, Long fileId, String action) {
|
||||||
StoredFile storedFile = storedFileRepository.findDetailedById(fileId)
|
StoredFile storedFile = storedFileRepository.findDetailedById(fileId)
|
||||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
|
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
|
||||||
@@ -509,6 +571,30 @@ public class FileService {
|
|||||||
return storedFile;
|
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) {
|
private void validateUpload(User user, String normalizedPath, String filename, long size) {
|
||||||
long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes());
|
long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes());
|
||||||
if (size > effectiveMaxUploadSize) {
|
if (size > effectiveMaxUploadSize) {
|
||||||
@@ -541,7 +627,11 @@ public class FileService {
|
|||||||
String currentPath = "/";
|
String currentPath = "/";
|
||||||
|
|
||||||
for (String segment : segments) {
|
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;
|
currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment;
|
||||||
continue;
|
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) {
|
private void ensureExistingDirectoryPath(Long userId, String normalizedPath) {
|
||||||
if ("/".equals(normalizedPath)) {
|
if ("/".equals(normalizedPath)) {
|
||||||
return;
|
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
|
@Entity
|
||||||
@Table(name = "portal_file", indexes = {
|
@Table(name = "portal_file", indexes = {
|
||||||
@Index(name = "uk_file_user_path_name", columnList = "user_id,path,filename", unique = true),
|
@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 {
|
public class StoredFile {
|
||||||
|
|
||||||
@@ -55,6 +57,18 @@ public class StoredFile {
|
|||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private LocalDateTime createdAt;
|
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
|
@PrePersist
|
||||||
public void prePersist() {
|
public void prePersist() {
|
||||||
if (createdAt == null) {
|
if (createdAt == null) {
|
||||||
@@ -141,4 +155,36 @@ public class StoredFile {
|
|||||||
public void setCreatedAt(LocalDateTime createdAt) {
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
this.createdAt = 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 = ''
|
where (:query is null or :query = ''
|
||||||
or lower(f.filename) like lower(concat('%', :query, '%'))
|
or lower(f.filename) like lower(concat('%', :query, '%'))
|
||||||
or lower(f.path) like lower(concat('%', :query, '%')))
|
or lower(f.path) like lower(concat('%', :query, '%')))
|
||||||
|
and f.deletedAt is null
|
||||||
and (:ownerQuery is null or :ownerQuery = ''
|
and (:ownerQuery is null or :ownerQuery = ''
|
||||||
or lower(u.username) like lower(concat('%', :ownerQuery, '%'))
|
or lower(u.username) like lower(concat('%', :ownerQuery, '%'))
|
||||||
or lower(u.email) like lower(concat('%', :ownerQuery, '%')))
|
or lower(u.email) like lower(concat('%', :ownerQuery, '%')))
|
||||||
@@ -33,7 +34,7 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
|||||||
@Query("""
|
@Query("""
|
||||||
select case when count(f) > 0 then true else false end
|
select case when count(f) > 0 then true else false end
|
||||||
from StoredFile 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
|
||||||
""")
|
""")
|
||||||
boolean existsByUserIdAndPathAndFilename(@Param("userId") Long userId,
|
boolean existsByUserIdAndPathAndFilename(@Param("userId") Long userId,
|
||||||
@Param("path") String path,
|
@Param("path") String path,
|
||||||
@@ -41,7 +42,7 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
|||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
select f from StoredFile f
|
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,
|
Optional<StoredFile> findByUserIdAndPathAndFilename(@Param("userId") Long userId,
|
||||||
@Param("path") String path,
|
@Param("path") String path,
|
||||||
@@ -50,7 +51,7 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
|||||||
@EntityGraph(attributePaths = "blob")
|
@EntityGraph(attributePaths = "blob")
|
||||||
@Query("""
|
@Query("""
|
||||||
select f from StoredFile f
|
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
|
order by f.directory desc, f.createdAt desc
|
||||||
""")
|
""")
|
||||||
Page<StoredFile> findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc(@Param("userId") Long userId,
|
Page<StoredFile> findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc(@Param("userId") Long userId,
|
||||||
@@ -60,7 +61,7 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
|||||||
@EntityGraph(attributePaths = "blob")
|
@EntityGraph(attributePaths = "blob")
|
||||||
@Query("""
|
@Query("""
|
||||||
select f from StoredFile f
|
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
|
order by f.createdAt asc
|
||||||
""")
|
""")
|
||||||
List<StoredFile> findByUserIdAndPathEqualsOrDescendant(@Param("userId") Long userId,
|
List<StoredFile> findByUserIdAndPathEqualsOrDescendant(@Param("userId") Long userId,
|
||||||
@@ -69,7 +70,7 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
|||||||
@Query("""
|
@Query("""
|
||||||
select coalesce(sum(f.size), 0)
|
select coalesce(sum(f.size), 0)
|
||||||
from StoredFile f
|
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);
|
long sumFileSizeByUserId(@Param("userId") Long userId);
|
||||||
|
|
||||||
@@ -81,7 +82,31 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
|||||||
long sumAllFileSize();
|
long sumAllFileSize();
|
||||||
|
|
||||||
@EntityGraph(attributePaths = "blob")
|
@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("""
|
@Query("""
|
||||||
select count(f)
|
select count(f)
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ class AuthControllerValidationTest {
|
|||||||
"new-refresh-token",
|
"new-refresh-token",
|
||||||
new UserProfileResponse(7L, "alice", "alice@example.com", LocalDateTime.now())
|
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")
|
mockMvc.perform(post("/api/auth/refresh")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -134,6 +134,6 @@ class AuthControllerValidationTest {
|
|||||||
.andExpect(jsonPath("$.data.refreshToken").value("new-refresh-token"))
|
.andExpect(jsonPath("$.data.refreshToken").value("new-refresh-token"))
|
||||||
.andExpect(jsonPath("$.data.user.username").value("alice"));
|
.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());
|
user.setCreatedAt(LocalDateTime.now());
|
||||||
return user;
|
return user;
|
||||||
});
|
});
|
||||||
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("access-token");
|
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString(), eq(AuthClientType.DESKTOP))).thenReturn("access-token");
|
||||||
when(refreshTokenService.issueRefreshToken(any(User.class))).thenReturn("refresh-token");
|
when(refreshTokenService.issueRefreshToken(any(User.class), eq(AuthClientType.DESKTOP))).thenReturn("refresh-token");
|
||||||
|
|
||||||
AuthResponse response = authService.register(request);
|
AuthResponse response = authService.register(request);
|
||||||
|
|
||||||
@@ -166,8 +166,8 @@ class AuthServiceTest {
|
|||||||
user.setCreatedAt(LocalDateTime.now());
|
user.setCreatedAt(LocalDateTime.now());
|
||||||
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
|
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
|
||||||
when(userRepository.save(user)).thenReturn(user);
|
when(userRepository.save(user)).thenReturn(user);
|
||||||
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("access-token");
|
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString(), eq(AuthClientType.DESKTOP))).thenReturn("access-token");
|
||||||
when(refreshTokenService.issueRefreshToken(user)).thenReturn("refresh-token");
|
when(refreshTokenService.issueRefreshToken(user, AuthClientType.DESKTOP)).thenReturn("refresh-token");
|
||||||
|
|
||||||
AuthResponse response = authService.login(request);
|
AuthResponse response = authService.login(request);
|
||||||
|
|
||||||
@@ -188,9 +188,9 @@ class AuthServiceTest {
|
|||||||
user.setEmail("alice@example.com");
|
user.setEmail("alice@example.com");
|
||||||
user.setCreatedAt(LocalDateTime.now());
|
user.setCreatedAt(LocalDateTime.now());
|
||||||
when(refreshTokenService.rotateRefreshToken("old-refresh"))
|
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(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");
|
AuthResponse response = authService.refresh("old-refresh");
|
||||||
|
|
||||||
@@ -232,8 +232,8 @@ class AuthServiceTest {
|
|||||||
user.setCreatedAt(LocalDateTime.now());
|
user.setCreatedAt(LocalDateTime.now());
|
||||||
return user;
|
return user;
|
||||||
});
|
});
|
||||||
when(jwtTokenProvider.generateAccessToken(eq(9L), eq("demo"), anyString())).thenReturn("access-token");
|
when(jwtTokenProvider.generateAccessToken(eq(9L), eq("demo"), anyString(), eq(AuthClientType.DESKTOP))).thenReturn("access-token");
|
||||||
when(refreshTokenService.issueRefreshToken(any(User.class))).thenReturn("refresh-token");
|
when(refreshTokenService.issueRefreshToken(any(User.class), eq(AuthClientType.DESKTOP))).thenReturn("refresh-token");
|
||||||
|
|
||||||
AuthResponse response = authService.devLogin("demo");
|
AuthResponse response = authService.devLogin("demo");
|
||||||
|
|
||||||
@@ -296,7 +296,7 @@ class AuthServiceTest {
|
|||||||
when(passwordEncoder.matches("OldPass1!", "encoded-old")).thenReturn(true);
|
when(passwordEncoder.matches("OldPass1!", "encoded-old")).thenReturn(true);
|
||||||
when(passwordEncoder.encode("NewPass1!A")).thenReturn("encoded-new");
|
when(passwordEncoder.encode("NewPass1!A")).thenReturn("encoded-new");
|
||||||
when(userRepository.save(user)).thenReturn(user);
|
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");
|
when(refreshTokenService.issueRefreshToken(user)).thenReturn("new-refresh");
|
||||||
|
|
||||||
AuthResponse response = authService.changePassword("alice", request);
|
AuthResponse response = authService.changePassword("alice", request);
|
||||||
|
|||||||
@@ -38,16 +38,78 @@ class AuthSingleDeviceIntegrationTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private UserRepository userRepository;
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.yoyuzh.files.StoredFileRepository storedFileRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RefreshTokenRepository refreshTokenRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private PasswordEncoder passwordEncoder;
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
|
storedFileRepository.deleteAll();
|
||||||
|
refreshTokenRepository.deleteAll();
|
||||||
userRepository.deleteAll();
|
userRepository.deleteAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 user = new User();
|
||||||
user.setUsername("alice");
|
user.setUsername("alice");
|
||||||
user.setDisplayName("Alice");
|
user.setDisplayName("Alice");
|
||||||
@@ -68,6 +130,7 @@ class AuthSingleDeviceIntegrationTest {
|
|||||||
|
|
||||||
String firstLoginResponse = mockMvc.perform(post("/api/auth/login")
|
String firstLoginResponse = mockMvc.perform(post("/api/auth/login")
|
||||||
.contentType("application/json")
|
.contentType("application/json")
|
||||||
|
.header("X-Yoyuzh-Client", "desktop")
|
||||||
.content(loginRequest))
|
.content(loginRequest))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
|
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
|
||||||
@@ -77,6 +140,7 @@ class AuthSingleDeviceIntegrationTest {
|
|||||||
|
|
||||||
String secondLoginResponse = mockMvc.perform(post("/api/auth/login")
|
String secondLoginResponse = mockMvc.perform(post("/api/auth/login")
|
||||||
.contentType("application/json")
|
.contentType("application/json")
|
||||||
|
.header("X-Yoyuzh-Client", "desktop")
|
||||||
.content(loginRequest))
|
.content(loginRequest))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
|
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class JwtTokenProviderTest {
|
|||||||
JwtTokenProvider provider = new JwtTokenProvider(properties);
|
JwtTokenProvider provider = new JwtTokenProvider(properties);
|
||||||
provider.init();
|
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));
|
SecretKey secretKey = Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8));
|
||||||
Instant expiration = Jwts.parser().verifyWith(secretKey).build()
|
Instant expiration = Jwts.parser().verifyWith(secretKey).build()
|
||||||
.parseSignedClaims(token)
|
.parseSignedClaims(token)
|
||||||
@@ -71,6 +71,7 @@ class JwtTokenProviderTest {
|
|||||||
assertThat(provider.getUsername(token)).isEqualTo("alice");
|
assertThat(provider.getUsername(token)).isEqualTo("alice");
|
||||||
assertThat(provider.getUserId(token)).isEqualTo(7L);
|
assertThat(provider.getUserId(token)).isEqualTo(7L);
|
||||||
assertThat(provider.getSessionId(token)).isEqualTo("session-1");
|
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-1")).isTrue();
|
||||||
assertThat(provider.hasMatchingSession(token, "session-2")).isFalse();
|
assertThat(provider.hasMatchingSession(token, "session-2")).isFalse();
|
||||||
assertThat(expiration).isAfter(Instant.now().plusSeconds(850));
|
assertThat(expiration).isAfter(Instant.now().plusSeconds(850));
|
||||||
|
|||||||
@@ -104,11 +104,11 @@ class JwtAuthenticationFilterTest {
|
|||||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
request.addHeader("Authorization", "Bearer valid-token");
|
request.addHeader("Authorization", "Bearer valid-token");
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
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.validateToken("valid-token")).thenReturn(true);
|
||||||
when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice");
|
when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice");
|
||||||
when(userDetailsService.loadDomainUser("alice")).thenReturn(domainUser);
|
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);
|
filter.doFilterInternal(request, response, filterChain);
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ class JwtAuthenticationFilterTest {
|
|||||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
request.addHeader("Authorization", "Bearer valid-token");
|
request.addHeader("Authorization", "Bearer valid-token");
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
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()
|
UserDetails disabledUserDetails = org.springframework.security.core.userdetails.User.builder()
|
||||||
.username("alice")
|
.username("alice")
|
||||||
.password("hashed")
|
.password("hashed")
|
||||||
@@ -131,7 +131,7 @@ class JwtAuthenticationFilterTest {
|
|||||||
when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true);
|
when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true);
|
||||||
when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice");
|
when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice");
|
||||||
when(userDetailsService.loadDomainUser("alice")).thenReturn(domainUser);
|
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);
|
when(userDetailsService.loadUserByUsername("alice")).thenReturn(disabledUserDetails);
|
||||||
|
|
||||||
filter.doFilterInternal(request, response, filterChain);
|
filter.doFilterInternal(request, response, filterChain);
|
||||||
@@ -145,7 +145,7 @@ class JwtAuthenticationFilterTest {
|
|||||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
request.addHeader("Authorization", "Bearer valid-token");
|
request.addHeader("Authorization", "Bearer valid-token");
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
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()
|
UserDetails activeUserDetails = org.springframework.security.core.userdetails.User.builder()
|
||||||
.username("alice")
|
.username("alice")
|
||||||
.password("hashed")
|
.password("hashed")
|
||||||
@@ -154,7 +154,7 @@ class JwtAuthenticationFilterTest {
|
|||||||
when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true);
|
when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true);
|
||||||
when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice");
|
when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice");
|
||||||
when(userDetailsService.loadDomainUser("alice")).thenReturn(domainUser);
|
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);
|
when(userDetailsService.loadUserByUsername("alice")).thenReturn(activeUserDetails);
|
||||||
|
|
||||||
filter.doFilterInternal(request, response, filterChain);
|
filter.doFilterInternal(request, response, filterChain);
|
||||||
@@ -165,13 +165,15 @@ class JwtAuthenticationFilterTest {
|
|||||||
verify(adminMetricsService).recordUserOnline(1L, "alice");
|
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 user = new User();
|
||||||
user.setId(1L);
|
user.setId(1L);
|
||||||
user.setUsername(username);
|
user.setUsername(username);
|
||||||
user.setEmail(username + "@example.com");
|
user.setEmail(username + "@example.com");
|
||||||
user.setPasswordHash("hashed");
|
user.setPasswordHash("hashed");
|
||||||
user.setActiveSessionId(sessionId);
|
user.setActiveSessionId(desktopSessionId);
|
||||||
|
user.setDesktopActiveSessionId(desktopSessionId);
|
||||||
|
user.setMobileActiveSessionId(mobileSessionId);
|
||||||
user.setCreatedAt(LocalDateTime.now());
|
user.setCreatedAt(LocalDateTime.now());
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ class FileServiceTest {
|
|||||||
MockMultipartFile multipartFile = new MockMultipartFile(
|
MockMultipartFile multipartFile = new MockMultipartFile(
|
||||||
"file", "notes.txt", "text/plain", "hello".getBytes());
|
"file", "notes.txt", "text/plain", "hello".getBytes());
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
|
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));
|
when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class));
|
doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class));
|
||||||
|
|
||||||
@@ -174,7 +175,8 @@ class FileServiceTest {
|
|||||||
void shouldDeleteCompletedUploadBlobWhenMetadataSaveFails() {
|
void shouldDeleteCompletedUploadBlobWhenMetadataSaveFails() {
|
||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
|
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));
|
when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class));
|
doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class));
|
||||||
|
|
||||||
@@ -190,8 +192,8 @@ class FileServiceTest {
|
|||||||
void shouldCreateMissingDirectoriesBeforeCompletingNestedUpload() {
|
void shouldCreateMissingDirectoriesBeforeCompletingNestedUpload() {
|
||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects/site", "logo.png")).thenReturn(false);
|
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects/site", "logo.png")).thenReturn(false);
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "projects")).thenReturn(false);
|
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "projects")).thenReturn(Optional.empty());
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects", "site")).thenReturn(false);
|
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/projects", "site")).thenReturn(Optional.empty());
|
||||||
when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
@@ -384,52 +386,74 @@ class FileServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldDeleteDirectoryWithNestedFilesViaStorage() {
|
void shouldMoveDeletedDirectoryAndDescendantsIntoRecycleBinGroup() {
|
||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
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");
|
FileBlob blob = createBlob(60L, "blobs/blob-delete", 5L, "text/plain");
|
||||||
StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt", blob);
|
StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt", blob);
|
||||||
|
|
||||||
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory));
|
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory));
|
||||||
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile));
|
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(nestedDirectory, childFile));
|
||||||
when(storedFileRepository.countByBlobId(60L)).thenReturn(1L);
|
|
||||||
|
|
||||||
fileService.delete(user, 10L);
|
fileService.delete(user, 10L);
|
||||||
|
|
||||||
verify(fileContentStorage).deleteBlob("blobs/blob-delete");
|
assertThat(directory.getDeletedAt()).isNotNull();
|
||||||
verify(fileBlobRepository).delete(blob);
|
assertThat(directory.isRecycleRoot()).isTrue();
|
||||||
verify(storedFileRepository).deleteAll(List.of(childFile));
|
assertThat(directory.getRecycleGroupId()).isNotBlank();
|
||||||
verify(storedFileRepository).delete(directory);
|
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
|
@Test
|
||||||
void shouldDeleteSharedBlobOnlyWhenLastReferenceIsRemoved() {
|
void shouldKeepSharedBlobWhenFileMovesIntoRecycleBin() {
|
||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
FileBlob blob = createBlob(70L, "blobs/blob-shared", 5L, "text/plain");
|
FileBlob blob = createBlob(70L, "blobs/blob-shared", 5L, "text/plain");
|
||||||
StoredFile storedFile = createFile(15L, user, "/docs", "shared.txt", blob);
|
StoredFile storedFile = createFile(15L, user, "/docs", "shared.txt", blob);
|
||||||
when(storedFileRepository.findDetailedById(15L)).thenReturn(Optional.of(storedFile));
|
when(storedFileRepository.findDetailedById(15L)).thenReturn(Optional.of(storedFile));
|
||||||
when(storedFileRepository.countByBlobId(70L)).thenReturn(2L);
|
|
||||||
|
|
||||||
fileService.delete(user, 15L);
|
fileService.delete(user, 15L);
|
||||||
|
|
||||||
|
assertThat(storedFile.getDeletedAt()).isNotNull();
|
||||||
|
assertThat(storedFile.isRecycleRoot()).isTrue();
|
||||||
verify(fileContentStorage, never()).deleteBlob(any());
|
verify(fileContentStorage, never()).deleteBlob(any());
|
||||||
verify(fileBlobRepository, never()).delete(any());
|
verify(fileBlobRepository, never()).delete(any());
|
||||||
verify(storedFileRepository).delete(storedFile);
|
verify(storedFileRepository, never()).delete(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldDeleteBlobObjectWhenLastReferenceIsRemoved() {
|
void shouldDeleteExpiredRecycleBinBlobWhenLastReferenceIsRemoved() {
|
||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
FileBlob blob = createBlob(71L, "blobs/blob-last", 5L, "text/plain");
|
FileBlob blob = createBlob(71L, "blobs/blob-last", 5L, "text/plain");
|
||||||
StoredFile storedFile = createFile(16L, user, "/docs", "last.txt", blob);
|
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);
|
when(storedFileRepository.countByBlobId(71L)).thenReturn(1L);
|
||||||
|
|
||||||
fileService.delete(user, 16L);
|
fileService.pruneExpiredRecycleBinItems();
|
||||||
|
|
||||||
verify(fileContentStorage).deleteBlob("blobs/blob-last");
|
verify(fileContentStorage).deleteBlob("blobs/blob-last");
|
||||||
verify(fileBlobRepository).delete(blob);
|
verify(fileBlobRepository).delete(blob);
|
||||||
verify(storedFileRepository).delete(storedFile);
|
verify(storedFileRepository).deleteAll(List.of(storedFile));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -582,7 +606,8 @@ class FileServiceTest {
|
|||||||
User recipient = createUser(8L);
|
User recipient = createUser(8L);
|
||||||
byte[] content = "hello".getBytes(StandardCharsets.UTF_8);
|
byte[] content = "hello".getBytes(StandardCharsets.UTF_8);
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(8L, "/下载", "notes.txt")).thenReturn(false);
|
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));
|
when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class));
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,8 +33,10 @@
|
|||||||
|
|
||||||
- 采用 `Authorization: Bearer <accessToken>`
|
- 采用 `Authorization: Bearer <accessToken>`
|
||||||
- `refreshToken` 通过 `/api/auth/refresh` 换取新的登录态
|
- `refreshToken` 通过 `/api/auth/refresh` 换取新的登录态
|
||||||
- 当前实现为“单账号单设备在线”
|
- 当前实现为“按客户端类型拆分会话”
|
||||||
- 新设备登录后,旧设备的 access token 会失效
|
- 桌面端与移动端可以同时在线
|
||||||
|
- 同一端类型再次登录后,该端旧 access token 会失效
|
||||||
|
- `/api/auth/register`、`/api/auth/login`、`/api/auth/refresh` 与开发环境 `/api/auth/dev-login` 支持可选请求头 `X-Yoyuzh-Client: desktop|mobile`
|
||||||
|
|
||||||
### 权限分层
|
### 权限分层
|
||||||
|
|
||||||
@@ -64,6 +66,7 @@
|
|||||||
- 使用邀请码注册
|
- 使用邀请码注册
|
||||||
- 注册成功后直接返回登录态
|
- 注册成功后直接返回登录态
|
||||||
- 邀请码成功使用后会自动刷新
|
- 邀请码成功使用后会自动刷新
|
||||||
|
- 若请求未显式带 `X-Yoyuzh-Client`,后端默认按 `desktop` 处理
|
||||||
|
|
||||||
请求重点字段:
|
请求重点字段:
|
||||||
|
|
||||||
@@ -90,6 +93,11 @@
|
|||||||
- `refreshToken`
|
- `refreshToken`
|
||||||
- `user`
|
- `user`
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- 可选请求头 `X-Yoyuzh-Client` 用于声明当前登录来自桌面端还是移动端
|
||||||
|
- 同账号桌面端与移动端可同时保持登录,但同类型端再次登录会顶掉旧会话
|
||||||
|
|
||||||
### 2.3 刷新登录态
|
### 2.3 刷新登录态
|
||||||
|
|
||||||
`POST /api/auth/refresh`
|
`POST /api/auth/refresh`
|
||||||
@@ -102,6 +110,7 @@
|
|||||||
|
|
||||||
- 刷新后会返回新的 access token 与 refresh token
|
- 刷新后会返回新的 access token 与 refresh token
|
||||||
- 当前系统会让旧 refresh token 失效
|
- 当前系统会让旧 refresh token 失效
|
||||||
|
- 刷新会沿用该 refresh token 原本所属的客户端类型;请求头缺省时仍按 `desktop` 兜底
|
||||||
|
|
||||||
### 2.4 开发环境登录
|
### 2.4 开发环境登录
|
||||||
|
|
||||||
@@ -111,6 +120,7 @@
|
|||||||
|
|
||||||
- 仅用于开发联调
|
- 仅用于开发联调
|
||||||
- 是否可用取决于当前环境配置
|
- 是否可用取决于当前环境配置
|
||||||
|
- 同样支持可选请求头 `X-Yoyuzh-Client: desktop|mobile`
|
||||||
|
|
||||||
### 2.5 获取用户资料
|
### 2.5 获取用户资料
|
||||||
|
|
||||||
@@ -188,6 +198,8 @@
|
|||||||
- `PATCH /api/files/{fileId}/move`
|
- `PATCH /api/files/{fileId}/move`
|
||||||
- `POST /api/files/{fileId}/copy`
|
- `POST /api/files/{fileId}/copy`
|
||||||
- `DELETE /api/files/{fileId}`
|
- `DELETE /api/files/{fileId}`
|
||||||
|
- `GET /api/files/recycle-bin`
|
||||||
|
- `POST /api/files/recycle-bin/{fileId}/restore`
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
@@ -195,6 +207,9 @@
|
|||||||
- `copy` 用于复制到目标路径
|
- `copy` 用于复制到目标路径
|
||||||
- 文件和文件夹都支持移动 / 复制
|
- 文件和文件夹都支持移动 / 复制
|
||||||
- 普通文件的 `move` / `rename` / `copy` 只改逻辑元数据;`copy` 会复用原有 `FileBlob`,不会复制底层对象
|
- 普通文件的 `move` / `rename` / `copy` 只改逻辑元数据;`copy` 会复用原有 `FileBlob`,不会复制底层对象
|
||||||
|
- `DELETE /api/files/{fileId}` 现在语义是“移入回收站”,不会立刻物理删除;删除的文件或整个目录树会保留 10 天
|
||||||
|
- `GET /api/files/recycle-bin` 返回当前用户回收站根条目分页列表,包含删除时间和预计清理时间
|
||||||
|
- `POST /api/files/recycle-bin/{fileId}/restore` 用于把某个回收站根条目恢复到原目录;若原位置已有同名文件,或当前剩余空间不足,则恢复失败
|
||||||
|
|
||||||
### 3.5 分享链接
|
### 3.5 分享链接
|
||||||
|
|
||||||
|
|||||||
@@ -107,15 +107,16 @@
|
|||||||
- 用户资料查询和修改
|
- 用户资料查询和修改
|
||||||
- 用户自行修改密码
|
- 用户自行修改密码
|
||||||
- 头像上传
|
- 头像上传
|
||||||
- 单设备登录控制
|
- 按客户端类型拆分的登录会话控制
|
||||||
- 邀请码消费与轮换
|
- 邀请码消费与轮换
|
||||||
|
|
||||||
关键实现说明:
|
关键实现说明:
|
||||||
|
|
||||||
- access token 使用 JWT
|
- access token 使用 JWT
|
||||||
- refresh token 持久化到数据库
|
- refresh token 持久化到数据库
|
||||||
- 当前会话通过 `activeSessionId + JWT sid claim` 绑定
|
- 当前会话通过“客户端类型 + 会话 ID”绑定:JWT 同时携带 `sid` 和 `client` claim
|
||||||
- 新登录会挤掉旧设备
|
- 用户表分别记录桌面端与移动端活跃会话;桌面端仍同步回写旧的 `activeSessionId` 以兼容存量逻辑
|
||||||
|
- 同账号现在允许桌面端与移动端同时在线,但同一端类型再次登录仍会挤掉旧会话
|
||||||
- 当前密码策略统一为“至少 8 位且包含大写字母”
|
- 当前密码策略统一为“至少 8 位且包含大写字母”
|
||||||
|
|
||||||
### 3.2 网盘模块
|
### 3.2 网盘模块
|
||||||
@@ -132,6 +133,7 @@
|
|||||||
- 文件/文件夹上传、下载、删除、重命名
|
- 文件/文件夹上传、下载、删除、重命名
|
||||||
- 目录创建与分页列表
|
- 目录创建与分页列表
|
||||||
- 移动、复制
|
- 移动、复制
|
||||||
|
- 回收站列表、恢复与过期清理
|
||||||
- 分享链接与导入
|
- 分享链接与导入
|
||||||
- 前端树状目录导航
|
- 前端树状目录导航
|
||||||
|
|
||||||
@@ -143,10 +145,13 @@
|
|||||||
- 支持本地磁盘和 S3 兼容对象存储
|
- 支持本地磁盘和 S3 兼容对象存储
|
||||||
- 分享导入与网盘复制会直接复用源文件的 `FileBlob`,不会再次写入字节内容
|
- 分享导入与网盘复制会直接复用源文件的 `FileBlob`,不会再次写入字节内容
|
||||||
- 文件重命名、移动只更新 `StoredFile` 元数据,不会移动底层对象
|
- 文件重命名、移动只更新 `StoredFile` 元数据,不会移动底层对象
|
||||||
- 删除文件时会先删除 `StoredFile` 引用;只有最后一个引用消失时,才真正删除 `FileBlob` 对应的底层对象
|
- 删除文件时不会立刻物理删除,而是把 `StoredFile` 及其目录树标记为回收站条目;根条目会记录 `deletedAt`、原始父路径和回收分组 ID,回收站保留期固定为 10 天
|
||||||
|
- 回收站恢复会把整组条目恢复到原路径,并在恢复前检查同名冲突和用户剩余配额
|
||||||
|
- 定时清理任务会删除超过 10 天的回收站条目;只有当某个 `FileBlob` 的最后一个逻辑引用随之消失时,才真正删除底层对象
|
||||||
- 应用启动时会把旧 `portal_file.storage_name` 行自动回填到新的 `blob_id` 引用,保证存量数据能继续读取
|
- 应用启动时会把旧 `portal_file.storage_name` 行自动回填到新的 `blob_id` 引用,保证存量数据能继续读取
|
||||||
- 当前线上网盘文件存储已切到多吉云对象存储,后端先通过多吉云临时密钥 API 换取短期 S3 会话,再访问底层 COS 兼容桶
|
- 当前线上网盘文件存储已切到多吉云对象存储,后端先通过多吉云临时密钥 API 换取短期 S3 会话,再访问底层 COS 兼容桶
|
||||||
- 前端会缓存目录列表和最后访问路径
|
- 前端会缓存目录列表和最后访问路径
|
||||||
|
- 桌面网盘页在左侧树状目录栏底部固定展示回收站入口;移动端在网盘页顶部提供回收站入口;两端共用独立 `RecycleBin` 页面调用 `/api/files/recycle-bin` 与恢复接口
|
||||||
|
|
||||||
Android 壳补充说明:
|
Android 壳补充说明:
|
||||||
|
|
||||||
@@ -156,6 +161,9 @@ Android 壳补充说明:
|
|||||||
- 后端 CORS 默认放行 `http://localhost`、`https://localhost`、`http://127.0.0.1`、`https://127.0.0.1` 与 `capacitor://localhost`,以兼容 Web 开发环境和 Android WebView 壳
|
- 后端 CORS 默认放行 `http://localhost`、`https://localhost`、`http://127.0.0.1`、`https://127.0.0.1` 与 `capacitor://localhost`,以兼容 Web 开发环境和 Android WebView 壳
|
||||||
- Web 端构建完成后,通过 `npx cap sync android` 把静态资源复制到 `front/android/app/src/main/assets/public`
|
- Web 端构建完成后,通过 `npx cap sync android` 把静态资源复制到 `front/android/app/src/main/assets/public`
|
||||||
- Android 调试包当前通过 `cd front/android && ./gradlew assembleDebug` 生成,输出路径是 `front/android/app/build/outputs/apk/debug/app-debug.apk`
|
- Android 调试包当前通过 `cd front/android && ./gradlew assembleDebug` 生成,输出路径是 `front/android/app/build/outputs/apk/debug/app-debug.apk`
|
||||||
|
- 前端总览页会在 Web 环境展示稳定 APK 下载入口 `/downloads/yoyuzh-portal.apk`
|
||||||
|
- Capacitor 原生壳内的移动端总览页会改为“检查更新”入口;前端会先对 OSS 上的 APK 做 `HEAD` 探测并读取最新修改时间,再直接打开下载链接完成更新
|
||||||
|
- 前端 OSS 发布脚本会在上传 `front/dist` 后,额外把 `front/android/app/build/outputs/apk/debug/app-debug.apk` 上传到同一个静态站桶里的 `downloads/yoyuzh-portal.apk`;这里刻意不把 APK 放进 `front/dist`,以避免后续 `npx cap sync android` 时把旧 APK 再次打进新的 Android 包
|
||||||
- 由于当前开发机直连 `dl.google.com` 与 Google Android Maven 仓库存在 TLS 握手失败,本地 Android 构建仓库源已切到可访问镜像;如果后续重新生成 Capacitor 工程,需要重新确认镜像配置仍存在
|
- 由于当前开发机直连 `dl.google.com` 与 Google Android Maven 仓库存在 TLS 握手失败,本地 Android 构建仓库源已切到可访问镜像;如果后续重新生成 Capacitor 工程,需要重新确认镜像配置仍存在
|
||||||
|
|
||||||
### 3.3 快传模块
|
### 3.3 快传模块
|
||||||
@@ -228,10 +236,11 @@ Android 壳补充说明:
|
|||||||
|
|
||||||
1. 前端登录页调用 `/api/auth/login`
|
1. 前端登录页调用 `/api/auth/login`
|
||||||
2. 后端鉴权成功后签发 access token + refresh token
|
2. 后端鉴权成功后签发 access token + refresh token
|
||||||
3. 后端刷新 `activeSessionId`
|
3. 前端同时上送 `X-Yoyuzh-Client` 标记当前是 `desktop` 还是 `mobile`
|
||||||
4. 前端本地存储 `portal-session`
|
4. 后端按客户端类型刷新对应的活跃会话 ID 与 refresh token 集合
|
||||||
5. 后续请求通过 `Authorization: Bearer <token>` 访问
|
5. 前端本地存储 `portal-session`
|
||||||
6. JWT 过滤器校验 token、用户状态和会话 ID 是否仍匹配
|
6. 后续请求通过 `Authorization: Bearer <token>` 访问,并继续带上 `X-Yoyuzh-Client`
|
||||||
|
7. JWT 过滤器校验 token、用户状态,以及当前客户端类型对应的会话 ID 是否仍匹配
|
||||||
|
|
||||||
补充说明:
|
补充说明:
|
||||||
|
|
||||||
@@ -295,7 +304,7 @@ Android 壳补充说明:
|
|||||||
1. 管理台调用 `PUT /api/admin/users/{userId}/password`
|
1. 管理台调用 `PUT /api/admin/users/{userId}/password`
|
||||||
2. 后端按统一密码规则校验新密码
|
2. 后端按统一密码规则校验新密码
|
||||||
3. 后端重算密码哈希并写回用户表
|
3. 后端重算密码哈希并写回用户表
|
||||||
4. 后端刷新 `activeSessionId` 并撤销该用户全部 refresh token
|
4. 后端刷新桌面端与移动端全部活跃会话,并撤销该用户全部 refresh token
|
||||||
5. 旧密码后续登录应失败,新密码登录成功
|
5. 旧密码后续登录应失败,新密码登录成功
|
||||||
|
|
||||||
## 5. 前端路由架构
|
## 5. 前端路由架构
|
||||||
@@ -330,14 +339,15 @@ Android 壳补充说明:
|
|||||||
- `GET /api/files/share-links/{token}` 公开
|
- `GET /api/files/share-links/{token}` 公开
|
||||||
- `/api/files/**`、`/api/user/**`、`/api/admin/**` 需登录
|
- `/api/files/**`、`/api/user/**`、`/api/admin/**` 需登录
|
||||||
|
|
||||||
### 6.2 单设备登录
|
### 6.2 分端单会话登录
|
||||||
|
|
||||||
当前实现不是只撤销 refresh token,而是同时控制 access token:
|
当前实现不是只撤销 refresh token,而是同时控制 access token,并按客户端类型拆分:
|
||||||
|
|
||||||
- 用户表记录 `activeSessionId`
|
- 前端会在鉴权与上传请求里附带 `X-Yoyuzh-Client: desktop|mobile`
|
||||||
- JWT 里包含 `sid`
|
- 用户表记录 `desktopActiveSessionId` 与 `mobileActiveSessionId`
|
||||||
- 过滤器每次请求都会比对当前用户的 `activeSessionId`
|
- JWT 里同时包含 `sid` 和 `client`
|
||||||
- 新登录成功后,旧设备 token 会失效
|
- 过滤器每次请求都会按 token 里的 `client` 去比对对应端的活跃会话 ID
|
||||||
|
- 桌面端与移动端可以同时在线,但同一端再次登录成功后,该端旧 token 会失效
|
||||||
|
|
||||||
## 7. 存储架构
|
## 7. 存储架构
|
||||||
|
|
||||||
|
|||||||
45
docs/superpowers/plans/2026-04-03-overview-apk-download.md
Normal file
45
docs/superpowers/plans/2026-04-03-overview-apk-download.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Overview APK Download Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 在总览界面增加 Android APK 下载入口,并让前端 OSS 发布流程稳定上传最新调试 APK。
|
||||||
|
|
||||||
|
**Architecture:** 总览页使用固定的前端静态下载路径展示 APK 下载按钮,避免引入额外后端接口。OSS 发布仍沿用现有 `scripts/deploy-front-oss.mjs`,只额外把 `front/android/app/build/outputs/apk/debug/app-debug.apk` 上传到稳定对象 key,避免把 APK 混入 `front/dist` 进而被 Capacitor 再次打包进 Android 壳。
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, TypeScript, Vite 6, Node.js ESM scripts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 总览页下载入口状态与链接
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `front/src/lib/app-shell.ts`
|
||||||
|
- Modify: `front/src/lib/app-shell.test.ts`
|
||||||
|
- Modify: `front/src/pages/overview-state.ts`
|
||||||
|
- Modify: `front/src/pages/overview-state.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
- [ ] **Step 2: Run `cd front && npm run test` to verify the new state tests fail for the missing helpers**
|
||||||
|
- [ ] **Step 3: Add native-shell detection and APK download path helpers**
|
||||||
|
- [ ] **Step 4: Run `cd front && npm run test` to verify the helper tests pass**
|
||||||
|
|
||||||
|
### Task 2: 桌面端与移动端总览入口
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `front/src/pages/Overview.tsx`
|
||||||
|
- Modify: `front/src/mobile-pages/MobileOverview.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Render the APK download entry only in web contexts where it is useful**
|
||||||
|
- [ ] **Step 2: Keep the current overview information architecture intact and reuse the shared helper**
|
||||||
|
- [ ] **Step 3: Run `cd front && npm run lint` to verify the React/TypeScript changes type-check**
|
||||||
|
|
||||||
|
### Task 3: OSS 发布时上传 APK
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/deploy-front-oss.mjs`
|
||||||
|
- Modify: `memory.md`
|
||||||
|
- Modify: `docs/architecture.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Extend the existing OSS deploy script to upload the built APK to a stable key**
|
||||||
|
- [ ] **Step 2: Verify with `node scripts/deploy-front-oss.mjs --dry-run` if credentials are available, otherwise document the gap**
|
||||||
|
- [ ] **Step 3: Update project memory and architecture notes for the new APK distribution path**
|
||||||
59
docs/superpowers/plans/2026-04-03-recycle-bin.md
Normal file
59
docs/superpowers/plans/2026-04-03-recycle-bin.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Recycle Bin Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 为网盘增加回收站,删除后的文件或目录自动进入回收站,保留 10 天,可在保留期内恢复,并在网盘左侧目录侧栏底部提供入口。
|
||||||
|
|
||||||
|
**Architecture:** 后端把删除操作从“物理删除”改成“回收站软删除”,为 `StoredFile` 增加删除时间、原始路径/名称、回收站分组信息,并提供“列出回收站”“恢复”“清空过期项”能力。前端在 `Files` 页目录树侧栏底部增加回收站入口,并新增回收站页面/状态,复用现有文件卡片风格展示删除时间、原始位置和恢复操作。
|
||||||
|
|
||||||
|
**Tech Stack:** Spring Boot 3.3, JPA/Hibernate update schema, React 19, TypeScript, Vite
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 后端回收站模型与服务
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFile.java`
|
||||||
|
- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java`
|
||||||
|
- Modify: `backend/src/main/java/com/yoyuzh/files/FileService.java`
|
||||||
|
- Modify: `backend/src/main/java/com/yoyuzh/files/FileController.java`
|
||||||
|
- Create: `backend/src/main/java/com/yoyuzh/files/RecycleBinItemResponse.java`
|
||||||
|
- Create: `backend/src/main/java/com/yoyuzh/files/RestoreRecycleBinItemRequest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写后端失败测试,覆盖删除进入回收站、回收站列表、恢复、10 天过期清理**
|
||||||
|
- [ ] **Step 2: 运行 `cd backend && mvn test` 确认新增测试先失败**
|
||||||
|
- [ ] **Step 3: 给 `StoredFile` 增加回收站字段,并让普通文件列表/最近文件默认排除已删除项**
|
||||||
|
- [ ] **Step 4: 把删除改成软删除,目录删除时按组放入回收站,恢复时按组恢复**
|
||||||
|
- [ ] **Step 5: 增加回收站列表、恢复接口和定时清理过期项**
|
||||||
|
- [ ] **Step 6: 运行 `cd backend && mvn test` 确认后端通过**
|
||||||
|
|
||||||
|
### Task 2: 前端回收站入口与页面
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `front/src/components/layout/Layout.tsx`
|
||||||
|
- Modify: `front/src/components/layout/Layout.test.ts`
|
||||||
|
- Modify: `front/src/App.tsx`
|
||||||
|
- Modify: `front/src/pages/Files.tsx`
|
||||||
|
- Create: `front/src/pages/RecycleBin.tsx`
|
||||||
|
- Create: `front/src/pages/recycle-bin-state.ts`
|
||||||
|
- Create: `front/src/pages/recycle-bin-state.test.ts`
|
||||||
|
- Modify: `front/src/lib/types.ts`
|
||||||
|
- Modify: `front/src/MobileApp.tsx`
|
||||||
|
- Modify: `front/src/mobile-pages/MobileFiles.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写前端失败测试,锁住侧栏底部入口和回收站状态变换**
|
||||||
|
- [ ] **Step 2: 运行 `cd front && npm run test` 确认前端新增测试先失败**
|
||||||
|
- [ ] **Step 3: 新增回收站页面和状态,接后端列表/恢复接口**
|
||||||
|
- [ ] **Step 4: 在桌面网盘左侧侧栏最下方加回收站入口,并把删除确认文案改为“移入回收站”**
|
||||||
|
- [ ] **Step 5: 给移动端补可访问的回收站入口和路由**
|
||||||
|
- [ ] **Step 6: 运行 `cd front && npm run test`、`cd front && npm run lint`、`cd front && npm run build`**
|
||||||
|
|
||||||
|
### Task 3: 文档与项目记忆
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `memory.md`
|
||||||
|
- Modify: `docs/architecture.md`
|
||||||
|
- Modify: `docs/api-reference.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 记录回收站行为、保留周期、入口位置和恢复约束**
|
||||||
|
- [ ] **Step 2: 在交付前确认验证命令和已知限制写入文档**
|
||||||
@@ -5,6 +5,7 @@ import { useAuth } from './auth/AuthProvider';
|
|||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Overview from './pages/Overview';
|
import Overview from './pages/Overview';
|
||||||
import Files from './pages/Files';
|
import Files from './pages/Files';
|
||||||
|
import RecycleBin from './pages/RecycleBin';
|
||||||
import Transfer from './pages/Transfer';
|
import Transfer from './pages/Transfer';
|
||||||
import FileShare from './pages/FileShare';
|
import FileShare from './pages/FileShare';
|
||||||
import Games from './pages/Games';
|
import Games from './pages/Games';
|
||||||
@@ -58,6 +59,7 @@ function AppRoutes() {
|
|||||||
<Route index element={<Navigate to="/overview" replace />} />
|
<Route index element={<Navigate to="/overview" replace />} />
|
||||||
<Route path="overview" element={<Overview />} />
|
<Route path="overview" element={<Overview />} />
|
||||||
<Route path="files" element={<Files />} />
|
<Route path="files" element={<Files />} />
|
||||||
|
<Route path="recycle-bin" element={<RecycleBin />} />
|
||||||
<Route path="games" element={<Games />} />
|
<Route path="games" element={<Games />} />
|
||||||
<Route path="games/:gameId" element={<GamePlayer />} />
|
<Route path="games/:gameId" element={<GamePlayer />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import MobileOverview from './mobile-pages/MobileOverview';
|
|||||||
import MobileFiles from './mobile-pages/MobileFiles';
|
import MobileFiles from './mobile-pages/MobileFiles';
|
||||||
import MobileTransfer from './mobile-pages/MobileTransfer';
|
import MobileTransfer from './mobile-pages/MobileTransfer';
|
||||||
import MobileFileShare from './mobile-pages/MobileFileShare';
|
import MobileFileShare from './mobile-pages/MobileFileShare';
|
||||||
|
import RecycleBin from './pages/RecycleBin';
|
||||||
|
|
||||||
function LegacyTransferRedirect() {
|
function LegacyTransferRedirect() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -53,6 +54,7 @@ function MobileAppRoutes() {
|
|||||||
<Route index element={<Navigate to="/overview" replace />} />
|
<Route index element={<Navigate to="/overview" replace />} />
|
||||||
<Route path="overview" element={<MobileOverview />} />
|
<Route path="overview" element={<MobileOverview />} />
|
||||||
<Route path="files" element={<MobileFiles />} />
|
<Route path="files" element={<MobileFiles />} />
|
||||||
|
<Route path="recycle-bin" element={<RecycleBin />} />
|
||||||
<Route path="games" element={<Navigate to="/overview" replace />} />
|
<Route path="games" element={<Navigate to="/overview" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
11
front/src/components/ui/card.test.tsx
Normal file
11
front/src/components/ui/card.test.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
|
||||||
|
import { Card } from './card';
|
||||||
|
|
||||||
|
test('Card applies the shared elevated shadow styling', () => {
|
||||||
|
const html = renderToStaticMarkup(<Card>demo</Card>);
|
||||||
|
|
||||||
|
assert.match(html, /shadow-\[0_12px_32px_rgba\(15,23,42,0\.18\)\]/);
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"glass-panel rounded-2xl text-white shadow-sm",
|
"glass-panel rounded-2xl text-white shadow-[0_12px_32px_rgba(15,23,42,0.18)]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@
|
|||||||
--color-text-secondary: #94A3B8; /* slate-400 */
|
--color-text-secondary: #94A3B8; /* slate-400 */
|
||||||
--color-text-tertiary: rgba(255, 255, 255, 0.3);
|
--color-text-tertiary: rgba(255, 255, 255, 0.3);
|
||||||
|
|
||||||
--color-glass-bg: rgba(255, 255, 255, 0.03);
|
--color-glass-bg: rgba(255, 255, 255, 0.045);
|
||||||
--color-glass-border: rgba(255, 255, 255, 0.08);
|
--color-glass-border: rgba(255, 255, 255, 0.1);
|
||||||
--color-glass-hover: rgba(255, 255, 255, 0.06);
|
--color-glass-hover: rgba(255, 255, 255, 0.07);
|
||||||
--color-glass-active: rgba(255, 255, 255, 0.1);
|
--color-glass-active: rgba(255, 255, 255, 0.11);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ test('apiRequest attaches bearer token and unwraps response payload', async () =
|
|||||||
assert.deepEqual(payload, {ok: true});
|
assert.deepEqual(payload, {ok: true});
|
||||||
assert.ok(request instanceof Request);
|
assert.ok(request instanceof Request);
|
||||||
assert.equal(request.headers.get('Authorization'), 'Bearer token-123');
|
assert.equal(request.headers.get('Authorization'), 'Bearer token-123');
|
||||||
|
assert.equal(request.headers.get('X-Yoyuzh-Client'), 'desktop');
|
||||||
assert.equal(request.url, 'http://localhost/api/files/recent');
|
assert.equal(request.url, 'http://localhost/api/files/recent');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AuthResponse } from './types';
|
import type { AuthResponse } from './types';
|
||||||
|
import { PORTAL_CLIENT_HEADER, resolvePortalClientType } from './app-shell';
|
||||||
import { clearStoredSession, createSession, readStoredSession, saveStoredSession } from './session';
|
import { clearStoredSession, createSession, readStoredSession, saveStoredSession } from './session';
|
||||||
|
|
||||||
interface ApiEnvelope<T> {
|
interface ApiEnvelope<T> {
|
||||||
@@ -148,6 +149,10 @@ function normalizePath(path: string) {
|
|||||||
return path.startsWith('/') ? path : `/${path}`;
|
return path.startsWith('/') ? path : `/${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldAttachPortalClientHeader(path: string) {
|
||||||
|
return !/^https?:\/\//.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
function shouldAttemptTokenRefresh(path: string) {
|
function shouldAttemptTokenRefresh(path: string) {
|
||||||
const normalizedPath = normalizePath(path);
|
const normalizedPath = normalizePath(path);
|
||||||
return ![
|
return ![
|
||||||
@@ -189,12 +194,15 @@ async function refreshAccessToken() {
|
|||||||
|
|
||||||
refreshRequestPromise = (async () => {
|
refreshRequestPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
|
const headers = new Headers({
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
});
|
||||||
|
headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType());
|
||||||
|
|
||||||
const response = await fetch(resolveUrl(AUTH_REFRESH_PATH), {
|
const response = await fetch(resolveUrl(AUTH_REFRESH_PATH), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
refreshToken: currentSession.refreshToken,
|
refreshToken: currentSession.refreshToken,
|
||||||
}),
|
}),
|
||||||
@@ -269,6 +277,9 @@ async function performRequest(path: string, init: ApiRequestInit = {}, allowRefr
|
|||||||
if (session?.token) {
|
if (session?.token) {
|
||||||
headers.set('Authorization', `Bearer ${session.token}`);
|
headers.set('Authorization', `Bearer ${session.token}`);
|
||||||
}
|
}
|
||||||
|
if (shouldAttachPortalClientHeader(path) && !headers.has(PORTAL_CLIENT_HEADER)) {
|
||||||
|
headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType());
|
||||||
|
}
|
||||||
if (requestBody && !(requestBody instanceof FormData) && !headers.has('Content-Type')) {
|
if (requestBody && !(requestBody instanceof FormData) && !headers.has('Content-Type')) {
|
||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
}
|
}
|
||||||
@@ -341,6 +352,9 @@ function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, a
|
|||||||
if (session?.token) {
|
if (session?.token) {
|
||||||
headers.set('Authorization', `Bearer ${session.token}`);
|
headers.set('Authorization', `Bearer ${session.token}`);
|
||||||
}
|
}
|
||||||
|
if (shouldAttachPortalClientHeader(path) && !headers.has(PORTAL_CLIENT_HEADER)) {
|
||||||
|
headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType());
|
||||||
|
}
|
||||||
if (!headers.has('Accept')) {
|
if (!headers.has('Accept')) {
|
||||||
headers.set('Accept', 'application/json');
|
headers.set('Accept', 'application/json');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { MOBILE_APP_MAX_WIDTH, shouldUseMobileApp } from './app-shell';
|
import {
|
||||||
|
MOBILE_APP_MAX_WIDTH,
|
||||||
|
isNativeAppShellLocation,
|
||||||
|
resolvePortalClientType,
|
||||||
|
shouldUseMobileApp,
|
||||||
|
} from './app-shell';
|
||||||
|
|
||||||
test('shouldUseMobileApp enables the mobile shell below the width breakpoint', () => {
|
test('shouldUseMobileApp enables the mobile shell below the width breakpoint', () => {
|
||||||
assert.equal(shouldUseMobileApp(MOBILE_APP_MAX_WIDTH - 1), true);
|
assert.equal(shouldUseMobileApp(MOBILE_APP_MAX_WIDTH - 1), true);
|
||||||
assert.equal(shouldUseMobileApp(MOBILE_APP_MAX_WIDTH), false);
|
assert.equal(shouldUseMobileApp(MOBILE_APP_MAX_WIDTH), false);
|
||||||
assert.equal(shouldUseMobileApp(1280), false);
|
assert.equal(shouldUseMobileApp(1280), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('isNativeAppShellLocation matches Capacitor localhost origins', () => {
|
||||||
|
assert.equal(isNativeAppShellLocation(new URL('https://localhost')), true);
|
||||||
|
assert.equal(isNativeAppShellLocation(new URL('http://127.0.0.1')), true);
|
||||||
|
assert.equal(isNativeAppShellLocation(new URL('capacitor://localhost')), true);
|
||||||
|
assert.equal(isNativeAppShellLocation(new URL('http://localhost:3000')), false);
|
||||||
|
assert.equal(isNativeAppShellLocation(new URL('https://yoyuzh.xyz')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolvePortalClientType distinguishes desktop web from mobile shell or narrow screens', () => {
|
||||||
|
assert.equal(resolvePortalClientType({ location: new URL('https://yoyuzh.xyz'), viewportWidth: 1280 }), 'desktop');
|
||||||
|
assert.equal(resolvePortalClientType({ location: new URL('https://yoyuzh.xyz'), viewportWidth: 390 }), 'mobile');
|
||||||
|
assert.equal(resolvePortalClientType({ location: new URL('https://localhost') }), 'mobile');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,69 @@
|
|||||||
export const MOBILE_APP_MAX_WIDTH = 768;
|
export const MOBILE_APP_MAX_WIDTH = 768;
|
||||||
|
export const PORTAL_CLIENT_HEADER = 'X-Yoyuzh-Client';
|
||||||
|
|
||||||
|
export type PortalClientType = 'desktop' | 'mobile';
|
||||||
|
|
||||||
export function shouldUseMobileApp(width: number) {
|
export function shouldUseMobileApp(width: number) {
|
||||||
return width < MOBILE_APP_MAX_WIDTH;
|
return width < MOBILE_APP_MAX_WIDTH;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isNativeAppShellLocation(location: Location | URL | null) {
|
||||||
|
if (!location) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = location.hostname || '';
|
||||||
|
const protocol = location.protocol || '';
|
||||||
|
const port = location.port || '';
|
||||||
|
|
||||||
|
if (protocol === 'capacitor:') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1';
|
||||||
|
const isCapacitorLocalScheme = protocol === 'http:' || protocol === 'https:';
|
||||||
|
|
||||||
|
return isLocalhostHost && isCapacitorLocalScheme && port === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRuntimeViewportWidth() {
|
||||||
|
if (typeof globalThis.innerWidth === 'number' && Number.isFinite(globalThis.innerWidth)) {
|
||||||
|
return globalThis.innerWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && typeof window.innerWidth === 'number') {
|
||||||
|
return window.innerWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRuntimeLocation() {
|
||||||
|
if (typeof globalThis.location !== 'undefined') {
|
||||||
|
return globalThis.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePortalClientType({
|
||||||
|
location = resolveRuntimeLocation(),
|
||||||
|
viewportWidth = resolveRuntimeViewportWidth(),
|
||||||
|
}: {
|
||||||
|
location?: Location | URL | null;
|
||||||
|
viewportWidth?: number | null;
|
||||||
|
} = {}): PortalClientType {
|
||||||
|
if (isNativeAppShellLocation(location)) {
|
||||||
|
return 'mobile';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof viewportWidth === 'number' && shouldUseMobileApp(viewportWidth)) {
|
||||||
|
return 'mobile';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'desktop';
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,6 +106,18 @@ export interface FileMetadata {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecycleBinItem {
|
||||||
|
id: number;
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
contentType: string | null;
|
||||||
|
directory: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
deletedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InitiateUploadResponse {
|
export interface InitiateUploadResponse {
|
||||||
direct: boolean;
|
direct: boolean;
|
||||||
uploadUrl: string;
|
uploadUrl: string;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { AnimatePresence, motion } from 'motion/react';
|
|||||||
|
|
||||||
import { useAuth } from '@/src/auth/AuthProvider';
|
import { useAuth } from '@/src/auth/AuthProvider';
|
||||||
import { apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
|
import { apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
|
||||||
|
import { isNativeAppShellLocation } from '@/src/lib/app-shell';
|
||||||
import { createSession, readStoredSession, saveStoredSession } from '@/src/lib/session';
|
import { createSession, readStoredSession, saveStoredSession } from '@/src/lib/session';
|
||||||
import type { AuthResponse, InitiateUploadResponse, UserProfile } from '@/src/lib/types';
|
import type { AuthResponse, InitiateUploadResponse, UserProfile } from '@/src/lib/types';
|
||||||
import { cn } from '@/src/lib/utils';
|
import { cn } from '@/src/lib/utils';
|
||||||
@@ -33,16 +34,7 @@ const NAV_ITEMS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function isNativeMobileShellLocation(location: Location | URL | null) {
|
export function isNativeMobileShellLocation(location: Location | URL | null) {
|
||||||
if (!location) {
|
return isNativeAppShellLocation(location);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostname = location.hostname || '';
|
|
||||||
const protocol = location.protocol || '';
|
|
||||||
const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1';
|
|
||||||
const isCapacitorScheme = protocol === 'http:' || protocol === 'https:' || protocol === 'capacitor:';
|
|
||||||
|
|
||||||
return isLocalhostHost && isCapacitorScheme;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMobileViewportOffsetClassNames(isNativeShell = false) {
|
export function getMobileViewportOffsetClassNames(isNativeShell = false) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -13,7 +14,8 @@ import {
|
|||||||
Edit2,
|
Edit2,
|
||||||
Trash2,
|
Trash2,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
ChevronLeft
|
ChevronLeft,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
|
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
|
||||||
@@ -66,6 +68,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
toDirectoryPath,
|
toDirectoryPath,
|
||||||
} from '@/src/pages/files-tree';
|
} from '@/src/pages/files-tree';
|
||||||
|
import { RECYCLE_BIN_RETENTION_DAYS, RECYCLE_BIN_ROUTE } from '@/src/pages/recycle-bin-state';
|
||||||
|
|
||||||
function sleep(ms: number) {
|
function sleep(ms: number) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -119,6 +122,7 @@ interface UiFile {
|
|||||||
type NetdiskTargetAction = 'move' | 'copy';
|
type NetdiskTargetAction = 'move' | 'copy';
|
||||||
|
|
||||||
export default function MobileFiles() {
|
export default function MobileFiles() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
|
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
|
||||||
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
|
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
|
||||||
|
|
||||||
@@ -445,19 +449,29 @@ export default function MobileFiles() {
|
|||||||
|
|
||||||
{/* Top Header - Path navigation */}
|
{/* Top Header - Path navigation */}
|
||||||
<div className="flex-none px-4 py-3 bg-[#0f172a]/80 border-b border-white/5 sticky top-0 z-20 shadow-md backdrop-blur-xl">
|
<div className="flex-none px-4 py-3 bg-[#0f172a]/80 border-b border-white/5 sticky top-0 z-20 shadow-md backdrop-blur-xl">
|
||||||
<div className="flex flex-nowrap items-center text-sm overflow-x-auto custom-scrollbar whitespace-nowrap">
|
<div className="flex items-center gap-3">
|
||||||
{currentPath.length > 0 && (
|
<div className="flex min-w-0 flex-1 flex-nowrap items-center text-sm overflow-x-auto custom-scrollbar whitespace-nowrap">
|
||||||
<button className="mr-3 p-1.5 rounded-full bg-white/5 text-slate-300 active:bg-white/10" onClick={handleBackClick}>
|
{currentPath.length > 0 && (
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<button className="mr-3 p-1.5 rounded-full bg-white/5 text-slate-300 active:bg-white/10" onClick={handleBackClick}>
|
||||||
</button>
|
<ChevronLeft className="w-4 h-4" />
|
||||||
)}
|
</button>
|
||||||
<button className="text-slate-400 hover:text-white" onClick={() => handleBreadcrumbClick(-1)}>根目录</button>
|
)}
|
||||||
{currentPath.map((pathItem, index) => (
|
<button className="text-slate-400 hover:text-white" onClick={() => handleBreadcrumbClick(-1)}>根目录</button>
|
||||||
<React.Fragment key={index}>
|
{currentPath.map((pathItem, index) => (
|
||||||
<ChevronRight className="w-3 h-3 mx-1 text-slate-600 shrink-0" />
|
<React.Fragment key={index}>
|
||||||
<button onClick={() => handleBreadcrumbClick(index)} className={cn(index === currentPath.length - 1 ? 'text-white font-medium' : 'text-slate-400', 'shrink-0')}>{pathItem}</button>
|
<ChevronRight className="w-3 h-3 mx-1 text-slate-600 shrink-0" />
|
||||||
</React.Fragment>
|
<button onClick={() => handleBreadcrumbClick(index)} className={cn(index === currentPath.length - 1 ? 'text-white font-medium' : 'text-slate-400', 'shrink-0')}>{pathItem}</button>
|
||||||
))}
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(RECYCLE_BIN_ROUTE)}
|
||||||
|
className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-200"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
回收站
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -584,10 +598,10 @@ export default function MobileFiles() {
|
|||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setDeleteModalOpen(false)} />
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setDeleteModalOpen(false)} />
|
||||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="relative w-full max-w-sm glass-panel bg-[#0f172a] border border-white/10 rounded-2xl p-5 z-10 shadow-2xl">
|
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="relative w-full max-w-sm glass-panel bg-[#0f172a] border border-white/10 rounded-2xl p-5 z-10 shadow-2xl">
|
||||||
<h3 className="text-lg font-bold text-white mb-2 flex items-center gap-2"><Trash2 className="text-red-400 w-5 h-5"/>确认删除</h3>
|
<h3 className="text-lg font-bold text-white mb-2 flex items-center gap-2"><Trash2 className="text-red-400 w-5 h-5"/>确认删除</h3>
|
||||||
<p className="text-sm text-slate-300 mb-6 mt-3">你确实要彻底删除 <span className="text-white font-medium break-all">{fileToDelete?.name}</span> 吗?</p>
|
<p className="text-sm text-slate-300 mb-6 mt-3">确定要将 <span className="text-white font-medium break-all">{fileToDelete?.name}</span> 移入回收站吗?文件会保留 {RECYCLE_BIN_RETENTION_DAYS} 天,期间可以恢复。</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button variant="outline" className="flex-1 bg-white/5 border-white/10 text-white" onClick={() => setDeleteModalOpen(false)}>取消</Button>
|
<Button variant="outline" className="flex-1 bg-white/5 border-white/10 text-white" onClick={() => setDeleteModalOpen(false)}>取消</Button>
|
||||||
<Button className="flex-1 bg-red-500 text-white hover:bg-red-600" onClick={handleDelete}>删除</Button>
|
<Button className="flex-1 bg-red-500 text-white hover:bg-red-600" onClick={handleDelete}>移入回收站</Button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
FolderPlus,
|
FolderPlus,
|
||||||
Mail,
|
Mail,
|
||||||
Send,
|
Send,
|
||||||
|
Smartphone,
|
||||||
Upload,
|
Upload,
|
||||||
User,
|
User,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -25,7 +26,14 @@ import { getOverviewCacheKey } from '@/src/lib/page-cache';
|
|||||||
import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session';
|
import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session';
|
||||||
import type { FileMetadata, PageResponse, UserProfile } from '@/src/lib/types';
|
import type { FileMetadata, PageResponse, UserProfile } from '@/src/lib/types';
|
||||||
|
|
||||||
import { getOverviewLoadErrorMessage } from '@/src/pages/overview-state';
|
import {
|
||||||
|
APK_DOWNLOAD_PUBLIC_URL,
|
||||||
|
APK_DOWNLOAD_PATH,
|
||||||
|
getMobileOverviewApkEntryMode,
|
||||||
|
getOverviewLoadErrorMessage,
|
||||||
|
getOverviewStorageQuotaLabel,
|
||||||
|
shouldShowOverviewApkDownload,
|
||||||
|
} from '@/src/pages/overview-state';
|
||||||
|
|
||||||
function formatFileSize(size: number) {
|
function formatFileSize(size: number) {
|
||||||
if (size <= 0) return '0 B';
|
if (size <= 0) return '0 B';
|
||||||
@@ -56,6 +64,8 @@ export default function MobileOverview() {
|
|||||||
const [loadingError, setLoadingError] = useState('');
|
const [loadingError, setLoadingError] = useState('');
|
||||||
const [retryToken, setRetryToken] = useState(0);
|
const [retryToken, setRetryToken] = useState(0);
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||||
|
const [apkActionMessage, setApkActionMessage] = useState('');
|
||||||
|
const [checkingApkUpdate, setCheckingApkUpdate] = useState(false);
|
||||||
|
|
||||||
const currentHour = new Date().getHours();
|
const currentHour = new Date().getHours();
|
||||||
let greeting = '晚上好';
|
let greeting = '晚上好';
|
||||||
@@ -72,6 +82,46 @@ export default function MobileOverview() {
|
|||||||
const latestFile = recentFiles[0] ?? null;
|
const latestFile = recentFiles[0] ?? null;
|
||||||
const profileDisplayName = profile?.displayName || profile?.username || '未登录';
|
const profileDisplayName = profile?.displayName || profile?.username || '未登录';
|
||||||
const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase();
|
const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase();
|
||||||
|
const showApkDownload = shouldShowOverviewApkDownload();
|
||||||
|
const apkEntryMode = getMobileOverviewApkEntryMode();
|
||||||
|
|
||||||
|
const handleCheckApkUpdate = async () => {
|
||||||
|
setCheckingApkUpdate(true);
|
||||||
|
setApkActionMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(APK_DOWNLOAD_PUBLIC_URL, {
|
||||||
|
method: 'HEAD',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`检查更新失败 (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastModified = response.headers.get('last-modified');
|
||||||
|
setApkActionMessage(
|
||||||
|
lastModified
|
||||||
|
? `发现最新安装包,更新时间 ${new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(lastModified))},正在打开下载链接。`
|
||||||
|
: '发现最新安装包,正在打开下载链接。'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const openedWindow = window.open(APK_DOWNLOAD_PUBLIC_URL, '_blank', 'noopener,noreferrer');
|
||||||
|
if (!openedWindow) {
|
||||||
|
window.location.href = APK_DOWNLOAD_PUBLIC_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setApkActionMessage(error instanceof Error ? error.message : '检查更新失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setCheckingApkUpdate(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -166,7 +216,7 @@ export default function MobileOverview() {
|
|||||||
<MobileMetricCard title="文件总数" value={`${rootFiles.length}`} icon={FileText} delay={0.1} color="text-amber-400" bg="bg-amber-500/20" />
|
<MobileMetricCard title="文件总数" value={`${rootFiles.length}`} icon={FileText} delay={0.1} color="text-amber-400" bg="bg-amber-500/20" />
|
||||||
<MobileMetricCard title="近期上传" value={`${recentWeekUploads}`} icon={Upload} delay={0.15} color="text-emerald-400" bg="bg-emerald-500/20" />
|
<MobileMetricCard title="近期上传" value={`${recentWeekUploads}`} icon={Upload} delay={0.15} color="text-emerald-400" bg="bg-emerald-500/20" />
|
||||||
<MobileMetricCard title="快传就绪" value={latestFile ? '使用中' : '待命'} icon={Send} delay={0.2} color="text-[#336EFF]" bg="bg-[#336EFF]/20" />
|
<MobileMetricCard title="快传就绪" value={latestFile ? '使用中' : '待命'} icon={Send} delay={0.2} color="text-[#336EFF]" bg="bg-[#336EFF]/20" />
|
||||||
<MobileMetricCard title="存储占用" value={`${storagePercent.toFixed(1)}%`} icon={Database} delay={0.25} color="text-purple-400" bg="bg-purple-500/20" subtitle={`${formatFileSize(usedBytes)}`} />
|
<MobileMetricCard title="存储占用" value={`${storagePercent.toFixed(1)}%`} icon={Database} delay={0.25} color="text-purple-400" bg="bg-purple-500/20" subtitle={getOverviewStorageQuotaLabel(storageQuotaBytes)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 快捷操作区 */}
|
{/* 快捷操作区 */}
|
||||||
@@ -182,6 +232,48 @@ export default function MobileOverview() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{showApkDownload || apkEntryMode === 'update' ? (
|
||||||
|
<Card className="glass-panel overflow-hidden border-[#336EFF]/20 relative">
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(51,110,255,0.2),transparent_45%),linear-gradient(180deg,rgba(16,24,40,0.94),rgba(15,23,42,0.88))]" />
|
||||||
|
<CardContent className="relative z-10 p-4 space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-[#336EFF]/15 text-[#7ea4ff]">
|
||||||
|
<Smartphone className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-semibold text-white">Android 客户端</p>
|
||||||
|
<p className="text-[11px] leading-5 text-slate-300">
|
||||||
|
{apkEntryMode === 'update'
|
||||||
|
? '在 App 内检查 OSS 上的最新安装包,并跳转到更新下载链接。'
|
||||||
|
: '总览页可直接下载最新 APK,安装包与前端站点一起托管在 OSS。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{apkEntryMode === 'update' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCheckApkUpdate()}
|
||||||
|
disabled={checkingApkUpdate}
|
||||||
|
className="inline-flex h-10 w-full items-center justify-center rounded-xl bg-[#336EFF] px-4 text-sm font-medium text-white shadow-md shadow-[#336EFF]/20 transition-colors hover:bg-[#2958cc] disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{checkingApkUpdate ? '检查中...' : '检查更新'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={APK_DOWNLOAD_PATH}
|
||||||
|
download="yoyuzh-portal.apk"
|
||||||
|
className="inline-flex h-10 w-full items-center justify-center rounded-xl bg-[#336EFF] px-4 text-sm font-medium text-white shadow-md shadow-[#336EFF]/20 transition-colors hover:bg-[#2958cc]"
|
||||||
|
>
|
||||||
|
下载 APK
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{apkActionMessage ? (
|
||||||
|
<p className="text-[11px] leading-5 text-slate-300">{apkActionMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* 近期文件 (精简版) */}
|
{/* 近期文件 (精简版) */}
|
||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<CardHeader className="flex flex-row items-center justify-between py-3 px-4 pb-2 border-b border-white/5">
|
<CardHeader className="flex flex-row items-center justify-between py-3 px-4 pb-2 border-b border-white/5">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
Edit2,
|
Edit2,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
|
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
|
||||||
@@ -74,6 +76,7 @@ import {
|
|||||||
type DirectoryChildrenMap,
|
type DirectoryChildrenMap,
|
||||||
type DirectoryTreeNode,
|
type DirectoryTreeNode,
|
||||||
} from './files-tree';
|
} from './files-tree';
|
||||||
|
import { getFilesSidebarFooterEntries, RECYCLE_BIN_RETENTION_DAYS, RECYCLE_BIN_ROUTE } from './recycle-bin-state';
|
||||||
|
|
||||||
function sleep(ms: number) {
|
function sleep(ms: number) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -180,6 +183,8 @@ interface UiFile {
|
|||||||
type NetdiskTargetAction = 'move' | 'copy';
|
type NetdiskTargetAction = 'move' | 'copy';
|
||||||
|
|
||||||
export default function Files() {
|
export default function Files() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
|
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
|
||||||
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
|
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
@@ -752,11 +757,11 @@ export default function Files() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]">
|
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]">
|
||||||
{/* Left Sidebar */}
|
{/* Left Sidebar */}
|
||||||
<Card className="w-full lg:w-64 shrink-0 flex flex-col h-full overflow-y-auto">
|
<Card className="w-full lg:w-64 shrink-0 flex flex-col h-full overflow-hidden">
|
||||||
<CardContent className="p-4">
|
<CardContent className="flex h-full flex-col p-4">
|
||||||
<div className="space-y-2">
|
<div className="min-h-0 flex-1 space-y-2">
|
||||||
<p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">网盘目录</p>
|
<p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">网盘目录</p>
|
||||||
<div className="rounded-2xl border border-white/5 bg-black/20 p-2">
|
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-white/5 bg-black/20 p-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSidebarClick([])}
|
onClick={() => handleSidebarClick([])}
|
||||||
@@ -768,7 +773,7 @@ export default function Files() {
|
|||||||
<Folder className={cn('h-4 w-4', currentPath.length === 0 ? 'text-[#336EFF]' : 'text-slate-500')} />
|
<Folder className={cn('h-4 w-4', currentPath.length === 0 ? 'text-[#336EFF]' : 'text-slate-500')} />
|
||||||
<span className="truncate">网盘</span>
|
<span className="truncate">网盘</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="mt-1 space-y-0.5">
|
<div className="mt-1 min-h-0 flex-1 space-y-0.5 overflow-y-auto pr-1">
|
||||||
{directoryTree.map((node) => (
|
{directoryTree.map((node) => (
|
||||||
<DirectoryTreeItem
|
<DirectoryTreeItem
|
||||||
key={node.id}
|
key={node.id}
|
||||||
@@ -780,6 +785,30 @@ export default function Files() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-4 border-t border-white/10 pt-4">
|
||||||
|
{getFilesSidebarFooterEntries().map((entry) => {
|
||||||
|
const isActive = location.pathname === entry.path || location.pathname === RECYCLE_BIN_ROUTE;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.path}
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(entry.path)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left text-sm transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'border-[#336EFF]/30 bg-[#336EFF]/15 text-[#7ea6ff]'
|
||||||
|
: 'border-white/10 bg-white/5 text-slate-300 hover:bg-white/10 hover:text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RotateCcw className={cn('h-4 w-4', isActive ? 'text-[#7ea6ff]' : 'text-slate-400')} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">{entry.label}</p>
|
||||||
|
<p className="truncate text-xs text-slate-500">删除后保留 {RECYCLE_BIN_RETENTION_DAYS} 天</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -1136,7 +1165,7 @@ export default function Files() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-5 p-5">
|
<div className="space-y-5 p-5">
|
||||||
<p className="text-sm leading-relaxed text-slate-300">
|
<p className="text-sm leading-relaxed text-slate-300">
|
||||||
确定要删除 <span className="rounded bg-white/10 px-1 py-0.5 font-medium text-white">{fileToDelete?.name}</span> 吗?此操作无法撤销。
|
确定要将 <span className="rounded bg-white/10 px-1 py-0.5 font-medium text-white">{fileToDelete?.name}</span> 移入回收站吗?文件会保留 {RECYCLE_BIN_RETENTION_DAYS} 天,期间可以恢复。
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -1154,7 +1183,7 @@ export default function Files() {
|
|||||||
className="border-red-500/30 bg-red-500 text-white hover:bg-red-600"
|
className="border-red-500/30 bg-red-500 text-white hover:bg-red-600"
|
||||||
onClick={() => void handleDelete()}
|
onClick={() => void handleDelete()}
|
||||||
>
|
>
|
||||||
删除
|
移入回收站
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
FolderPlus,
|
FolderPlus,
|
||||||
Mail,
|
Mail,
|
||||||
Send,
|
Send,
|
||||||
|
Smartphone,
|
||||||
Upload,
|
Upload,
|
||||||
User,
|
User,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -25,7 +26,14 @@ import { getOverviewCacheKey } from '@/src/lib/page-cache';
|
|||||||
import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session';
|
import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session';
|
||||||
import type { FileMetadata, PageResponse, UserProfile } from '@/src/lib/types';
|
import type { FileMetadata, PageResponse, UserProfile } from '@/src/lib/types';
|
||||||
|
|
||||||
import { getOverviewLoadErrorMessage } from './overview-state';
|
import {
|
||||||
|
APK_DOWNLOAD_PATH,
|
||||||
|
getDesktopOverviewSectionColumns,
|
||||||
|
getDesktopOverviewStretchSection,
|
||||||
|
getOverviewLoadErrorMessage,
|
||||||
|
getOverviewStorageQuotaLabel,
|
||||||
|
shouldShowOverviewApkDownload,
|
||||||
|
} from './overview-state';
|
||||||
|
|
||||||
function formatFileSize(size: number) {
|
function formatFileSize(size: number) {
|
||||||
if (size <= 0) {
|
if (size <= 0) {
|
||||||
@@ -89,6 +97,9 @@ export default function Overview() {
|
|||||||
const latestFile = recentFiles[0] ?? null;
|
const latestFile = recentFiles[0] ?? null;
|
||||||
const profileDisplayName = profile?.displayName || profile?.username || '未登录';
|
const profileDisplayName = profile?.displayName || profile?.username || '未登录';
|
||||||
const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase();
|
const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase();
|
||||||
|
const showApkDownload = shouldShowOverviewApkDownload();
|
||||||
|
const desktopSections = getDesktopOverviewSectionColumns(showApkDownload);
|
||||||
|
const desktopStretchSection = getDesktopOverviewStretchSection(showApkDownload);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -242,8 +253,8 @@ export default function Overview() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 items-stretch lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 flex flex-col gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle>最近文件</CardTitle>
|
<CardTitle>最近文件</CardTitle>
|
||||||
@@ -297,9 +308,9 @@ export default function Overview() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className={desktopStretchSection === 'transfer-workbench' ? 'flex-1 overflow-hidden' : 'overflow-hidden'}>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="relative overflow-hidden rounded-2xl bg-[radial-gradient(circle_at_top_left,rgba(51,110,255,0.22),transparent_45%),linear-gradient(135deg,rgba(15,23,42,0.94),rgba(15,23,42,0.8))] p-6">
|
<div className="relative h-full overflow-hidden rounded-2xl bg-[radial-gradient(circle_at_top_left,rgba(51,110,255,0.22),transparent_45%),linear-gradient(135deg,rgba(15,23,42,0.94),rgba(15,23,42,0.8))] p-6">
|
||||||
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-cyan-400/10 blur-2xl" />
|
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-cyan-400/10 blur-2xl" />
|
||||||
<div className="relative z-10 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
<div className="relative z-10 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -326,9 +337,45 @@ export default function Overview() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{desktopSections.main.includes('apk-download') ? (
|
||||||
|
<Card className={`${desktopStretchSection === 'apk-download' ? 'flex-1' : ''} overflow-hidden border-[#336EFF]/20 bg-[radial-gradient(circle_at_top_right,rgba(51,110,255,0.18),transparent_40%),linear-gradient(180deg,rgba(10,14,28,0.96),rgba(15,23,42,0.92))]`}>
|
||||||
|
<CardContent className="h-full p-0">
|
||||||
|
<div className="relative flex h-full flex-col overflow-hidden rounded-2xl p-6">
|
||||||
|
<div className="absolute -left-12 bottom-0 h-28 w-28 rounded-full bg-[#336EFF]/10 blur-3xl" />
|
||||||
|
<div className="relative z-10 flex h-full flex-col gap-5 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-[#336EFF]/20 bg-[#336EFF]/10 px-3 py-1 text-xs font-medium text-[#b9ccff]">
|
||||||
|
<Smartphone className="h-3.5 w-3.5" />
|
||||||
|
Android 客户端
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-semibold text-white">下载 APK 安装包</h3>
|
||||||
|
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-300">
|
||||||
|
当前 Android 安装包会随前端站点一起发布到 OSS,可直接从这里下载最新版本。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3 text-xs text-slate-400">
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">稳定路径</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">OSS 托管</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">一键下载</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={APK_DOWNLOAD_PATH}
|
||||||
|
download="yoyuzh-portal.apk"
|
||||||
|
className="inline-flex h-11 shrink-0 items-center justify-center rounded-xl bg-[#336EFF] px-6 text-sm font-medium text-white shadow-md shadow-[#336EFF]/20 transition-colors hover:bg-[#2958cc]"
|
||||||
|
>
|
||||||
|
下载 APK
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<CardTitle>快捷操作</CardTitle>
|
<CardTitle>快捷操作</CardTitle>
|
||||||
@@ -353,7 +400,7 @@ export default function Overview() {
|
|||||||
<p className="text-3xl font-bold text-white tracking-tight">
|
<p className="text-3xl font-bold text-white tracking-tight">
|
||||||
{usedGb.toFixed(2)} <span className="text-sm text-slate-400 font-normal">GB</span>
|
{usedGb.toFixed(2)} <span className="text-sm text-slate-400 font-normal">GB</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-slate-500 uppercase tracking-wider">已使用 / 共 50 GB</p>
|
<p className="text-xs text-slate-500 uppercase tracking-wider">{getOverviewStorageQuotaLabel(storageQuotaBytes)}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl font-mono text-[#336EFF] font-medium">{storagePercent.toFixed(1)}%</span>
|
<span className="text-xl font-mono text-[#336EFF] font-medium">{storagePercent.toFixed(1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
165
front/src/pages/RecycleBin.tsx
Normal file
165
front/src/pages/RecycleBin.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Clock3, Folder, RefreshCw, RotateCcw, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/src/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
|
||||||
|
import { apiRequest } from '@/src/lib/api';
|
||||||
|
import type { PageResponse, RecycleBinItem } from '@/src/lib/types';
|
||||||
|
|
||||||
|
import { formatRecycleBinExpiresLabel, RECYCLE_BIN_RETENTION_DAYS } from './recycle-bin-state';
|
||||||
|
|
||||||
|
function formatFileSize(size: number) {
|
||||||
|
if (size <= 0) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
|
||||||
|
const value = size / 1024 ** index;
|
||||||
|
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecycleBin() {
|
||||||
|
const [items, setItems] = useState<RecycleBinItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [restoringId, setRestoringId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const loadRecycleBin = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const response = await apiRequest<PageResponse<RecycleBinItem>>('/files/recycle-bin?page=0&size=100');
|
||||||
|
setItems(response.items);
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError instanceof Error ? requestError.message : '回收站加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRecycleBin();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRestore = async (itemId: number) => {
|
||||||
|
setRestoringId(itemId);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await apiRequest(`/files/recycle-bin/${itemId}/restore`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
setItems((previous) => previous.filter((item) => item.id !== itemId));
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError instanceof Error ? requestError.message : '恢复失败');
|
||||||
|
} finally {
|
||||||
|
setRestoringId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex h-full w-full max-w-6xl flex-col gap-6">
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardHeader className="flex flex-col gap-4 border-b border-white/10 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
回收站保留 {RECYCLE_BIN_RETENTION_DAYS} 天
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl text-white">网盘回收站</CardTitle>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
删除的文件会先进入回收站,{RECYCLE_BIN_RETENTION_DAYS} 天内可恢复,到期后自动清理。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-white/10 bg-white/5 text-slate-200 hover:bg-white/10"
|
||||||
|
onClick={() => void loadRecycleBin()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Link
|
||||||
|
to="/files"
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-200 transition-colors hover:bg-white/10"
|
||||||
|
>
|
||||||
|
返回网盘
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
{error ? (
|
||||||
|
<div className="mb-4 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex min-h-64 items-center justify-center text-sm text-slate-400">
|
||||||
|
正在加载回收站...
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="flex min-h-64 flex-col items-center justify-center gap-4 rounded-3xl border border-dashed border-white/10 bg-black/10 text-center">
|
||||||
|
<div className="rounded-3xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<Trash2 className="h-8 w-8 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-lg font-medium text-white">回收站为空</p>
|
||||||
|
<p className="text-sm text-slate-400">删除后的文件会在这里保留 10 天。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex flex-col gap-4 rounded-3xl border border-white/10 bg-black/10 p-5 lg:flex-row lg:items-center lg:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-3 text-slate-200">
|
||||||
|
<Folder className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-base font-semibold text-white">{item.filename}</p>
|
||||||
|
<p className="truncate text-sm text-slate-400">{item.path}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-400">
|
||||||
|
<span>{item.directory ? '文件夹' : formatFileSize(item.size)}</span>
|
||||||
|
<span>删除于 {formatDateTime(item.deletedAt)}</span>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-amber-200">
|
||||||
|
<Clock3 className="h-3.5 w-3.5" />
|
||||||
|
{formatRecycleBinExpiresLabel(item.expiresAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="min-w-28 self-start bg-[#336EFF] text-white hover:bg-[#2958cc] lg:self-center"
|
||||||
|
onClick={() => void handleRestore(item.id)}
|
||||||
|
disabled={restoringId === item.id}
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
{restoringId === item.id ? '恢复中' : '恢复'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
|
|
||||||
import { getOverviewLoadErrorMessage } from './overview-state';
|
import {
|
||||||
|
APK_DOWNLOAD_PATH,
|
||||||
|
APK_DOWNLOAD_PUBLIC_URL,
|
||||||
|
getDesktopOverviewSectionColumns,
|
||||||
|
getDesktopOverviewStretchSection,
|
||||||
|
getMobileOverviewApkEntryMode,
|
||||||
|
getOverviewLoadErrorMessage,
|
||||||
|
getOverviewStorageQuotaLabel,
|
||||||
|
shouldShowOverviewApkDownload,
|
||||||
|
} from './overview-state';
|
||||||
|
|
||||||
test('post-login failures are presented as overview initialization issues', () => {
|
test('post-login failures are presented as overview initialization issues', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -16,3 +25,42 @@ test('generic overview failures stay generic when not coming right after login',
|
|||||||
'总览数据加载失败,请稍后重试。'
|
'总览数据加载失败,请稍后重试。'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('overview exposes a stable apk download path for oss hosting', () => {
|
||||||
|
assert.equal(APK_DOWNLOAD_PATH, '/downloads/yoyuzh-portal.apk');
|
||||||
|
assert.equal(APK_DOWNLOAD_PUBLIC_URL, 'https://yoyuzh.xyz/downloads/yoyuzh-portal.apk');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overview hides the apk download entry inside the native app shell', () => {
|
||||||
|
assert.equal(shouldShowOverviewApkDownload(new URL('https://yoyuzh.xyz')), true);
|
||||||
|
assert.equal(shouldShowOverviewApkDownload(new URL('https://localhost')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile overview switches from download mode to update mode inside the native shell', () => {
|
||||||
|
assert.equal(getMobileOverviewApkEntryMode(new URL('https://yoyuzh.xyz')), 'download');
|
||||||
|
assert.equal(getMobileOverviewApkEntryMode(new URL('https://localhost')), 'update');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop overview places the apk card in the main column to avoid empty left-side space', () => {
|
||||||
|
assert.deepEqual(getDesktopOverviewSectionColumns(true), {
|
||||||
|
main: ['recent-files', 'transfer-workbench', 'apk-download'],
|
||||||
|
sidebar: ['quick-actions', 'storage', 'account'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop overview omits the apk card entirely when the download entry is hidden', () => {
|
||||||
|
assert.deepEqual(getDesktopOverviewSectionColumns(false), {
|
||||||
|
main: ['recent-files', 'transfer-workbench'],
|
||||||
|
sidebar: ['quick-actions', 'storage', 'account'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop overview stretches the last visible main card to keep column bottoms aligned', () => {
|
||||||
|
assert.equal(getDesktopOverviewStretchSection(true), 'apk-download');
|
||||||
|
assert.equal(getDesktopOverviewStretchSection(false), 'transfer-workbench');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overview storage quota label uses the real quota instead of a fixed 50 GB copy', () => {
|
||||||
|
assert.equal(getOverviewStorageQuotaLabel(50 * 1024 * 1024 * 1024), '已使用 / 共 50 GB');
|
||||||
|
assert.equal(getOverviewStorageQuotaLabel(100 * 1024 * 1024 * 1024), '已使用 / 共 100 GB');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,36 @@
|
|||||||
|
import { isNativeAppShellLocation } from '@/src/lib/app-shell';
|
||||||
|
|
||||||
|
export const APK_DOWNLOAD_PATH = '/downloads/yoyuzh-portal.apk';
|
||||||
|
export const APK_DOWNLOAD_PUBLIC_URL = 'https://yoyuzh.xyz/downloads/yoyuzh-portal.apk';
|
||||||
|
|
||||||
|
function formatOverviewStorageSize(size: number) {
|
||||||
|
if (size <= 0) {
|
||||||
|
return '0 B';
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
|
||||||
|
const value = size / 1024 ** index;
|
||||||
|
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDesktopOverviewSectionColumns(showApkDownload: boolean) {
|
||||||
|
return {
|
||||||
|
main: showApkDownload
|
||||||
|
? ['recent-files', 'transfer-workbench', 'apk-download']
|
||||||
|
: ['recent-files', 'transfer-workbench'],
|
||||||
|
sidebar: ['quick-actions', 'storage', 'account'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDesktopOverviewStretchSection(showApkDownload: boolean) {
|
||||||
|
return showApkDownload ? 'apk-download' : 'transfer-workbench';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOverviewStorageQuotaLabel(storageQuotaBytes: number) {
|
||||||
|
return `已使用 / 共 ${formatOverviewStorageSize(storageQuotaBytes)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function getOverviewLoadErrorMessage(isPostLoginFailure: boolean) {
|
export function getOverviewLoadErrorMessage(isPostLoginFailure: boolean) {
|
||||||
if (isPostLoginFailure) {
|
if (isPostLoginFailure) {
|
||||||
return '登录已成功,但总览数据加载失败,请稍后重试。';
|
return '登录已成功,但总览数据加载失败,请稍后重试。';
|
||||||
@@ -5,3 +38,23 @@ export function getOverviewLoadErrorMessage(isPostLoginFailure: boolean) {
|
|||||||
|
|
||||||
return '总览数据加载失败,请稍后重试。';
|
return '总览数据加载失败,请稍后重试。';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveOverviewLocation() {
|
||||||
|
if (typeof globalThis.location !== 'undefined') {
|
||||||
|
return globalThis.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowOverviewApkDownload(location: Location | URL | null = resolveOverviewLocation()) {
|
||||||
|
return !isNativeAppShellLocation(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMobileOverviewApkEntryMode(location: Location | URL | null = resolveOverviewLocation()) {
|
||||||
|
return isNativeAppShellLocation(location) ? 'update' : 'download';
|
||||||
|
}
|
||||||
|
|||||||
31
front/src/pages/recycle-bin-state.test.ts
Normal file
31
front/src/pages/recycle-bin-state.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
RECYCLE_BIN_ROUTE,
|
||||||
|
RECYCLE_BIN_RETENTION_DAYS,
|
||||||
|
formatRecycleBinExpiresLabel,
|
||||||
|
getFilesSidebarFooterEntries,
|
||||||
|
} from './recycle-bin-state';
|
||||||
|
|
||||||
|
test('files sidebar keeps the recycle bin entry at the bottom footer area', () => {
|
||||||
|
const footerEntries = getFilesSidebarFooterEntries();
|
||||||
|
|
||||||
|
assert.equal(footerEntries.at(-1)?.path, RECYCLE_BIN_ROUTE);
|
||||||
|
assert.equal(footerEntries.at(-1)?.label, '回收站');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recycle bin retention stays fixed at ten days', () => {
|
||||||
|
assert.equal(RECYCLE_BIN_RETENTION_DAYS, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recycle bin expiry labels show the remaining days before purge', () => {
|
||||||
|
assert.equal(
|
||||||
|
formatRecycleBinExpiresLabel('2026-04-13T10:00:00', new Date('2026-04-03T10:00:00')),
|
||||||
|
'10 天后清理'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
formatRecycleBinExpiresLabel('2026-04-04T09:00:00', new Date('2026-04-03T10:00:00')),
|
||||||
|
'1 天后清理'
|
||||||
|
);
|
||||||
|
});
|
||||||
27
front/src/pages/recycle-bin-state.ts
Normal file
27
front/src/pages/recycle-bin-state.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export const RECYCLE_BIN_ROUTE = '/recycle-bin';
|
||||||
|
export const RECYCLE_BIN_RETENTION_DAYS = 10;
|
||||||
|
|
||||||
|
export interface FilesSidebarFooterEntry {
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFilesSidebarFooterEntries(): FilesSidebarFooterEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: '回收站',
|
||||||
|
path: RECYCLE_BIN_ROUTE,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRecycleBinExpiresLabel(expiresAt: string, now = new Date()) {
|
||||||
|
const expiresAtDate = new Date(expiresAt);
|
||||||
|
const diffMs = expiresAtDate.getTime() - now.getTime();
|
||||||
|
if (Number.isNaN(expiresAtDate.getTime()) || diffMs <= 0) {
|
||||||
|
return '今天清理';
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingDays = Math.max(1, Math.ceil(diffMs / (24 * 60 * 60 * 1000)));
|
||||||
|
return `${remainingDays} 天后清理`;
|
||||||
|
}
|
||||||
14
memory.md
14
memory.md
@@ -7,7 +7,7 @@
|
|||||||
- 快传模块已整合进主站,支持取件码、分享链接、P2P 传输、部分文件接收、ZIP 下载、存入网盘
|
- 快传模块已整合进主站,支持取件码、分享链接、P2P 传输、部分文件接收、ZIP 下载、存入网盘
|
||||||
- 网盘已支持上传、下载、重命名、删除、移动、复制、公开分享、接收快传后存入
|
- 网盘已支持上传、下载、重命名、删除、移动、复制、公开分享、接收快传后存入
|
||||||
- 注册改成邀请码机制,邀请码单次使用后自动刷新,并在管理台展示与复制
|
- 注册改成邀请码机制,邀请码单次使用后自动刷新,并在管理台展示与复制
|
||||||
- 同账号仅允许一台设备同时登录,旧设备会在下一次访问受保护接口时失效
|
- 同账号现已允许桌面端与移动端同时在线,但同一端类型仍只保留一个有效会话;同端再次登录会在下一次受保护请求时挤掉旧会话
|
||||||
- 后端已补生产 CORS,默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz`
|
- 后端已补生产 CORS,默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz`
|
||||||
- 线上文件存储与前端静态托管已迁到多吉云对象存储,后端通过临时密钥 API 获取短期 S3 会话访问底层 COS 兼容桶
|
- 线上文件存储与前端静态托管已迁到多吉云对象存储,后端通过临时密钥 API 获取短期 S3 会话访问底层 COS 兼容桶
|
||||||
- 管理台 dashboard 已显示总存储量、下载流量、今日请求次数、快传使用量、离线快传占用和请求折线图,并支持调整离线快传总上限
|
- 管理台 dashboard 已显示总存储量、下载流量、今日请求次数、快传使用量、离线快传占用和请求折线图,并支持调整离线快传总上限
|
||||||
@@ -30,6 +30,10 @@
|
|||||||
- 2026-04-03 Android 打包已确认走“Vite 产物 -> `npx cap sync android` -> Gradle `assembleDebug`”链路;当前应用包名为 `xyz.yoyuzh.portal`
|
- 2026-04-03 Android 打包已确认走“Vite 产物 -> `npx cap sync android` -> Gradle `assembleDebug`”链路;当前应用包名为 `xyz.yoyuzh.portal`
|
||||||
- 2026-04-03 Android WebView 壳内的前端 API 基址已改成运行时判断:Web 站点继续走相对 `/api`,Capacitor `localhost` 壳在 `http://localhost` 与 `https://localhost` 下都会默认直连 `https://api.yoyuzh.xyz/api`,避免 APK 把请求误打到应用内本地地址;后端 CORS 也同步放行了 `https://localhost`
|
- 2026-04-03 Android WebView 壳内的前端 API 基址已改成运行时判断:Web 站点继续走相对 `/api`,Capacitor `localhost` 壳在 `http://localhost` 与 `https://localhost` 下都会默认直连 `https://api.yoyuzh.xyz/api`,避免 APK 把请求误打到应用内本地地址;后端 CORS 也同步放行了 `https://localhost`
|
||||||
- 2026-04-03 由于这台机器直连 `dl.google.com` / Android Maven 仓库会 TLS 握手失败,Android 构建已改走阿里云 Google Maven 镜像,并通过 `redirector.gvt1.com` 手动落本机 SDK 包
|
- 2026-04-03 由于这台机器直连 `dl.google.com` / Android Maven 仓库会 TLS 握手失败,Android 构建已改走阿里云 Google Maven 镜像,并通过 `redirector.gvt1.com` 手动落本机 SDK 包
|
||||||
|
- 2026-04-03 总览页已新增 Android APK 下载入口;Web 桌面端和移动端总览都会展示稳定下载链接 `/downloads/yoyuzh-portal.apk`
|
||||||
|
- 2026-04-03 鉴权链路已按客户端类型拆分会话:前端请求会带 `X-Yoyuzh-Client`,后端分别维护桌面和移动的活跃 `sid` 与 refresh token 集合,因此桌面 Web 与移动端 APK 可同时登录;移动端总览页在 Capacitor 原生壳内会显示“检查更新”,通过探测 OSS 上 APK 最新修改时间并直接跳转下载链接完成更新
|
||||||
|
- 2026-04-03 前端 OSS 发布脚本已支持额外上传 `front/android/app/build/outputs/apk/debug/app-debug.apk` 到对象存储稳定 key `downloads/yoyuzh-portal.apk`;这样不会把 APK 混进 `front/dist`,也不会在后续 `npx cap sync android` 时被再次打包进 Android 壳
|
||||||
|
- 2026-04-03 网盘已新增回收站:`DELETE /api/files/{id}` 现在会把文件或整个目录树软删除进回收站,默认保留 10 天;前端桌面网盘页在左侧目录栏最下方新增“回收站”入口,移动端网盘页头也可进入回收站查看并恢复
|
||||||
- 根目录 README 已重写为中文公开版 GitHub 风格
|
- 根目录 README 已重写为中文公开版 GitHub 风格
|
||||||
- VS Code 工作区已补 `.vscode/settings.json`、`.vscode/extensions.json`、`lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor
|
- VS Code 工作区已补 `.vscode/settings.json`、`.vscode/extensions.json`、`lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor
|
||||||
- 进行中:
|
- 进行中:
|
||||||
@@ -47,7 +51,7 @@
|
|||||||
| 快传接收页收口回原 `/transfer` 页面 | 用户不需要单独进入专门的接收页面,入口更统一 | 独立接收页: 路径分散、用户心智更差 |
|
| 快传接收页收口回原 `/transfer` 页面 | 用户不需要单独进入专门的接收页面,入口更统一 | 独立接收页: 路径分散、用户心智更差 |
|
||||||
| 网盘侧边栏改成单一树状目录结构 | 更像真实网盘,层级关系清晰 | 保留“快速访问 + 目录”双区块: 结构割裂 |
|
| 网盘侧边栏改成单一树状目录结构 | 更像真实网盘,层级关系清晰 | 保留“快速访问 + 目录”双区块: 结构割裂 |
|
||||||
| 注册邀请码改成单次使用后自动刷新 | 更适合私域邀请式注册,管理台也能直接查看当前邀请码 | 固定邀请码: 容易扩散且不可控 |
|
| 注册邀请码改成单次使用后自动刷新 | 更适合私域邀请式注册,管理台也能直接查看当前邀请码 | 固定邀请码: 容易扩散且不可控 |
|
||||||
| 单设备登录通过“用户当前会话 ID + JWT sid claim”实现 | 新登录能立即顶掉旧 access token,而不仅仅是旧 refresh token | 只撤销 refresh token: 旧 access token 仍会继续有效一段时间 |
|
| 登录态通过“按客户端类型拆分的会话 ID + JWT sid/client claim”实现 | 桌面 Web 和移动 APK 可以同时在线,但同一端再次登录仍会立即挤掉旧 access token,而不仅仅是旧 refresh token | 只保留全局单会话: 会让桌面/移动互相顶下线;只撤销 refresh token: 旧 access token 仍会继续有效一段时间 |
|
||||||
| 前端发布继续使用 `node scripts/deploy-front-oss.mjs` | 仓库已有正式静态站发布脚本,现已切到多吉云临时密钥 + S3 兼容上传流程 | 手动上传对象存储: 容易出错,也不利于复用 |
|
| 前端发布继续使用 `node scripts/deploy-front-oss.mjs` | 仓库已有正式静态站发布脚本,现已切到多吉云临时密钥 + S3 兼容上传流程 | 手动上传对象存储: 容易出错,也不利于复用 |
|
||||||
| 后端发布继续采用“本地打包 + SSH/ SCP 上传 jar + systemd 重启” | 当前线上就按这个方式运行 | 自创部署脚本: 仓库里没有现成正式脚本,容易和现网偏离 |
|
| 后端发布继续采用“本地打包 + SSH/ SCP 上传 jar + systemd 重启” | 当前线上就按这个方式运行 | 自创部署脚本: 仓库里没有现成正式脚本,容易和现网偏离 |
|
||||||
| 主站 CORS 默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz` | 前端生产环境托管在独立静态站域名下,必须允许主站跨域调用后端 API | 仅保留 localhost: 会导致生产站调用 API 时被浏览器拦截 |
|
| 主站 CORS 默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz` | 前端生产环境托管在独立静态站域名下,必须允许主站跨域调用后端 API | 仅保留 localhost: 会导致生产站调用 API 时被浏览器拦截 |
|
||||||
@@ -58,6 +62,8 @@
|
|||||||
| 前端主入口按宽度自动切换到移动壳 | 不需要单独维护 `/m` 路由,用户在小屏设备上直接进入移动端布局 | 独立 `/m` 路由: 需要额外记忆入口且与主站状态分叉 |
|
| 前端主入口按宽度自动切换到移动壳 | 不需要单独维护 `/m` 路由,用户在小屏设备上直接进入移动端布局 | 独立 `/m` 路由: 需要额外记忆入口且与主站状态分叉 |
|
||||||
| 管理台上线记录按“JWT 鉴权成功的每日去重用户”统计,并只保留 7 天 | 后台需要回答“每天多少人上线、具体是谁”,同时不必引入更重的行为埋点系统 | 只统计登录接口: 无法覆盖 refresh 之后的真实活跃访问;无限保留历史: 超出当前管理需求 |
|
| 管理台上线记录按“JWT 鉴权成功的每日去重用户”统计,并只保留 7 天 | 后台需要回答“每天多少人上线、具体是谁”,同时不必引入更重的行为埋点系统 | 只统计登录接口: 无法覆盖 refresh 之后的真实活跃访问;无限保留历史: 超出当前管理需求 |
|
||||||
| Android 客户端先采用 Capacitor 包裹现有前端站点 | 现有 React/Vite 页面、鉴权和 API 调用可以直接复用,成本最低 | 重新单写原生 Android WebView 壳: 会引入额外原生维护面;改成 React Native / Flutter: 超出当前需求 |
|
| Android 客户端先采用 Capacitor 包裹现有前端站点 | 现有 React/Vite 页面、鉴权和 API 调用可以直接复用,成本最低 | 重新单写原生 Android WebView 壳: 会引入额外原生维护面;改成 React Native / Flutter: 超出当前需求 |
|
||||||
|
| APK 发布通过前端 OSS 脚本额外上传稳定对象 key,而不是进入 `front/dist` | 既能让总览页长期使用固定下载地址,也能避免 `npx cap sync android` 把旧 APK 再次塞进新的 APK 资产里 | 把 APK 直接放进 `front/public` 或 `front/dist`: 会污染前端静态产物,并可能导致 Android 包体递归膨胀 |
|
||||||
|
| 网盘删除采用“回收站软删除 + 10 天过期清理” | 用户删错文件后需要可恢复,同时共享 blob 仍要等最后引用真正过期后才删除底层对象 | 继续立即物理删除: 不可恢复且误删成本高;额外建独立归档表: 当前需求下实现过重 |
|
||||||
|
|
||||||
## 待解决问题
|
## 待解决问题
|
||||||
- [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误
|
- [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误
|
||||||
@@ -92,6 +98,7 @@
|
|||||||
- `cd front && npx cap sync android`
|
- `cd front && npx cap sync android`
|
||||||
- `cd front/android && ./gradlew assembleDebug`
|
- `cd front/android && ./gradlew assembleDebug`
|
||||||
- Android 调试 APK 当前输出路径:`front/android/app/build/outputs/apk/debug/app-debug.apk`
|
- Android 调试 APK 当前输出路径:`front/android/app/build/outputs/apk/debug/app-debug.apk`
|
||||||
|
- 前端 OSS 发布现会额外把调试 APK 上传到稳定对象 key:`downloads/yoyuzh-portal.apk`
|
||||||
- 服务器登录信息保存在本地 `账号密码.txt`,不要把内容写进文档或对外输出
|
- 服务器登录信息保存在本地 `账号密码.txt`,不要把内容写进文档或对外输出
|
||||||
|
|
||||||
## 参考资料
|
## 参考资料
|
||||||
@@ -102,7 +109,7 @@
|
|||||||
- 前端/后端工作区配置: `.vscode/settings.json`、`.vscode/extensions.json`
|
- 前端/后端工作区配置: `.vscode/settings.json`、`.vscode/extensions.json`
|
||||||
- Lombok 配置: `lombok.config`
|
- Lombok 配置: `lombok.config`
|
||||||
- 最近关键实现位置:
|
- 最近关键实现位置:
|
||||||
- 单设备登录: `backend/src/main/java/com/yoyuzh/auth/AuthService.java`
|
- 分端会话登录: `backend/src/main/java/com/yoyuzh/auth/AuthService.java`
|
||||||
- JWT 会话校验: `backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java`
|
- JWT 会话校验: `backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java`
|
||||||
- JWT 过滤器: `backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java`
|
- JWT 过滤器: `backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java`
|
||||||
- CORS 配置: `backend/src/main/java/com/yoyuzh/config/CorsProperties.java`、`backend/src/main/resources/application.yml`
|
- CORS 配置: `backend/src/main/java/com/yoyuzh/config/CorsProperties.java`、`backend/src/main/resources/application.yml`
|
||||||
@@ -116,5 +123,6 @@
|
|||||||
- 管理台统计与 7 天上线记录: `backend/src/main/java/com/yoyuzh/admin/AdminMetricsService.java`、`backend/src/main/java/com/yoyuzh/admin/AdminDailyActiveUserEntity.java`、`backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java`
|
- 管理台统计与 7 天上线记录: `backend/src/main/java/com/yoyuzh/admin/AdminMetricsService.java`、`backend/src/main/java/com/yoyuzh/admin/AdminDailyActiveUserEntity.java`、`backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java`
|
||||||
- 管理台 dashboard 展示与请求折线图: `front/src/admin/dashboard.tsx`、`front/src/admin/dashboard-state.ts`
|
- 管理台 dashboard 展示与请求折线图: `front/src/admin/dashboard.tsx`、`front/src/admin/dashboard-state.ts`
|
||||||
- 网盘 blob 模型与回填: `backend/src/main/java/com/yoyuzh/files/FileService.java`、`backend/src/main/java/com/yoyuzh/files/FileBlob.java`、`backend/src/main/java/com/yoyuzh/files/FileBlobBackfillService.java`
|
- 网盘 blob 模型与回填: `backend/src/main/java/com/yoyuzh/files/FileService.java`、`backend/src/main/java/com/yoyuzh/files/FileBlob.java`、`backend/src/main/java/com/yoyuzh/files/FileBlobBackfillService.java`
|
||||||
|
- 网盘回收站与恢复: `backend/src/main/java/com/yoyuzh/files/FileService.java`、`backend/src/main/java/com/yoyuzh/files/FileController.java`、`backend/src/main/java/com/yoyuzh/files/StoredFile.java`、`front/src/pages/RecycleBin.tsx`、`front/src/pages/recycle-bin-state.ts`
|
||||||
- 前端生产 API 基址: `front/.env.production`
|
- 前端生产 API 基址: `front/.env.production`
|
||||||
- Capacitor Android 入口与配置: `front/capacitor.config.ts`、`front/android/`
|
- Capacitor Android 入口与配置: `front/capacitor.config.ts`、`front/android/`
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ const repoRoot = process.cwd();
|
|||||||
const frontDir = path.join(repoRoot, 'front');
|
const frontDir = path.join(repoRoot, 'front');
|
||||||
const distDir = path.join(frontDir, 'dist');
|
const distDir = path.join(frontDir, 'dist');
|
||||||
const envFilePath = path.join(repoRoot, '.env.oss.local');
|
const envFilePath = path.join(repoRoot, '.env.oss.local');
|
||||||
|
const apkSourcePath = path.join(frontDir, 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk');
|
||||||
|
const apkObjectPath = 'downloads/yoyuzh-portal.apk';
|
||||||
|
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
return {
|
return {
|
||||||
@@ -153,6 +155,48 @@ async function uploadSpaAliases({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadApkIfPresent({
|
||||||
|
bucket,
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
sessionToken,
|
||||||
|
remotePrefix,
|
||||||
|
dryRun,
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await fs.access(apkSourcePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||||
|
console.warn(`skip apk upload: not found at ${apkSourcePath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectKey = buildObjectKey(remotePrefix, apkObjectPath);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(`[dry-run] upload ${apkObjectPath} -> ${objectKey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadFile({
|
||||||
|
bucket,
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
objectKey,
|
||||||
|
filePath: apkSourcePath,
|
||||||
|
contentTypeOverride: 'application/vnd.android.package-archive',
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
sessionToken,
|
||||||
|
});
|
||||||
|
console.log(`uploaded ${objectKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const {dryRun, skipBuild} = parseArgs(process.argv.slice(2));
|
const {dryRun, skipBuild} = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
@@ -221,6 +265,17 @@ async function main() {
|
|||||||
remotePrefix,
|
remotePrefix,
|
||||||
dryRun,
|
dryRun,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await uploadApkIfPresent({
|
||||||
|
bucket,
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
sessionToken,
|
||||||
|
remotePrefix,
|
||||||
|
dryRun,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const FRONTEND_SPA_ALIASES = [
|
|||||||
't',
|
't',
|
||||||
'overview',
|
'overview',
|
||||||
'files',
|
'files',
|
||||||
|
'recycle-bin',
|
||||||
'transfer',
|
'transfer',
|
||||||
'games',
|
'games',
|
||||||
'login',
|
'login',
|
||||||
|
|||||||
Reference in New Issue
Block a user