feat(admin): add blob share and task admin apis

This commit is contained in:
yoyuzh
2026-04-11 14:09:31 +08:00
parent 12005cc606
commit f59515f5dd
17 changed files with 2118 additions and 22 deletions

View File

@@ -5,7 +5,11 @@ import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.ApiResponse;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.core.FileEntityType;
import com.yoyuzh.files.tasks.BackgroundTask;
import com.yoyuzh.files.tasks.BackgroundTaskFailureCategory;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -61,6 +65,49 @@ public class AdminController {
return ApiResponse.success(adminService.listFiles(page, size, query, ownerQuery));
}
@GetMapping("/file-blobs")
public ApiResponse<PageResponse<AdminFileBlobResponse>> fileBlobs(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "") String userQuery,
@RequestParam(required = false) Long storagePolicyId,
@RequestParam(defaultValue = "") String objectKey,
@RequestParam(required = false) FileEntityType entityType) {
return ApiResponse.success(adminService.listFileBlobs(page, size, userQuery, storagePolicyId, objectKey, entityType));
}
@GetMapping("/shares")
public ApiResponse<PageResponse<AdminShareResponse>> shares(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "") String userQuery,
@RequestParam(defaultValue = "") String fileName,
@RequestParam(defaultValue = "") String token,
@RequestParam(required = false) Boolean passwordProtected,
@RequestParam(required = false) Boolean expired) {
return ApiResponse.success(adminService.listShares(page, size, userQuery, fileName, token, passwordProtected, expired));
}
@DeleteMapping("/shares/{shareId}")
public ApiResponse<Void> deleteShare(@PathVariable Long shareId) {
adminService.deleteShare(shareId);
return ApiResponse.success();
}
@GetMapping("/tasks")
public ApiResponse<PageResponse<AdminTaskResponse>> tasks(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "") String userQuery,
@RequestParam(required = false) BackgroundTaskType type,
@RequestParam(required = false) BackgroundTaskStatus status,
@RequestParam(required = false) BackgroundTaskFailureCategory failureCategory,
@RequestParam(required = false) AdminTaskLeaseState leaseState) {
return ApiResponse.success(adminService.listTasks(page, size, userQuery, type, status, failureCategory, leaseState));
}
@GetMapping("/tasks/{taskId}")
public ApiResponse<AdminTaskResponse> task(@PathVariable Long taskId) {
return ApiResponse.success(adminService.getTask(taskId));
}
@GetMapping("/storage-policies")
public ApiResponse<List<AdminStoragePolicyResponse>> storagePolicies() {
return ApiResponse.success(adminService.listStoragePolicies());

View File

@@ -0,0 +1,28 @@
package com.yoyuzh.admin;
import com.yoyuzh.files.core.FileEntityType;
import java.time.LocalDateTime;
public record AdminFileBlobResponse(
Long entityId,
Long blobId,
String objectKey,
FileEntityType entityType,
Long storagePolicyId,
Long size,
String contentType,
Integer referenceCount,
long linkedStoredFileCount,
long linkedOwnerCount,
String sampleOwnerUsername,
String sampleOwnerEmail,
Long createdByUserId,
String createdByUsername,
LocalDateTime createdAt,
LocalDateTime blobCreatedAt,
boolean blobMissing,
boolean orphanRisk,
boolean referenceMismatch
) {
}

View File

@@ -1,29 +1,42 @@
package com.yoyuzh.admin;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.auth.AuthTokenInvalidationService;
import com.yoyuzh.auth.PasswordPolicy;
import com.yoyuzh.auth.RefreshTokenService;
import com.yoyuzh.auth.RegistrationInviteService;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRole;
import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.auth.RefreshTokenService;
import com.yoyuzh.auth.UserRole;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.core.FileBlobRepository;
import com.yoyuzh.config.RedisCacheNames;
import com.yoyuzh.files.core.FileEntity;
import com.yoyuzh.files.core.FileEntityRepository;
import com.yoyuzh.files.core.FileEntityType;
import com.yoyuzh.files.core.FileService;
import com.yoyuzh.files.core.StoredFile;
import com.yoyuzh.files.core.StoredFileEntityRepository;
import com.yoyuzh.files.core.StoredFileRepository;
import com.yoyuzh.files.core.FileBlobRepository;
import com.yoyuzh.files.policy.StoragePolicy;
import com.yoyuzh.files.policy.StoragePolicyRepository;
import com.yoyuzh.files.policy.StoragePolicyService;
import com.yoyuzh.files.share.FileShareLink;
import com.yoyuzh.files.share.FileShareLinkRepository;
import com.yoyuzh.files.tasks.BackgroundTask;
import com.yoyuzh.files.tasks.BackgroundTaskFailureCategory;
import com.yoyuzh.files.tasks.BackgroundTaskRepository;
import com.yoyuzh.files.tasks.BackgroundTaskService;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
@@ -34,8 +47,12 @@ import org.springframework.util.StringUtils;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@@ -47,6 +64,7 @@ public class AdminService {
private final FileService fileService;
private final PasswordEncoder passwordEncoder;
private final RefreshTokenService refreshTokenService;
private final AuthTokenInvalidationService authTokenInvalidationService;
private final RegistrationInviteService registrationInviteService;
private final OfflineTransferSessionRepository offlineTransferSessionRepository;
private final AdminMetricsService adminMetricsService;
@@ -54,7 +72,10 @@ public class AdminService {
private final StoragePolicyService storagePolicyService;
private final FileEntityRepository fileEntityRepository;
private final StoredFileEntityRepository storedFileEntityRepository;
private final BackgroundTaskRepository backgroundTaskRepository;
private final BackgroundTaskService backgroundTaskService;
private final FileShareLinkRepository fileShareLinkRepository;
private final ObjectMapper objectMapper;
private final SecureRandom secureRandom = new SecureRandom();
public AdminSummaryResponse getSummary() {
@@ -98,6 +119,92 @@ public class AdminService {
return new PageResponse<>(items, result.getTotalElements(), page, size);
}
public PageResponse<AdminFileBlobResponse> listFileBlobs(int page,
int size,
String userQuery,
Long storagePolicyId,
String objectKey,
FileEntityType entityType) {
Page<FileEntity> result = fileEntityRepository.searchAdminEntities(
normalizeQuery(userQuery),
storagePolicyId,
normalizeQuery(objectKey),
entityType,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
);
List<AdminFileBlobResponse> items = result.getContent().stream()
.map(this::toFileBlobResponse)
.toList();
return new PageResponse<>(items, result.getTotalElements(), page, size);
}
public PageResponse<AdminShareResponse> listShares(int page,
int size,
String userQuery,
String fileName,
String token,
Boolean passwordProtected,
Boolean expired) {
Page<FileShareLink> result = fileShareLinkRepository.searchAdminShares(
normalizeQuery(userQuery),
normalizeQuery(fileName),
normalizeQuery(token),
passwordProtected,
expired,
LocalDateTime.now(),
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
);
List<AdminShareResponse> items = result.getContent().stream()
.map(this::toAdminShareResponse)
.toList();
return new PageResponse<>(items, result.getTotalElements(), page, size);
}
@Transactional
public void deleteShare(Long shareId) {
FileShareLink shareLink = fileShareLinkRepository.findById(shareId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "share not found"));
fileShareLinkRepository.delete(shareLink);
}
public PageResponse<AdminTaskResponse> listTasks(int page,
int size,
String userQuery,
BackgroundTaskType type,
BackgroundTaskStatus status,
BackgroundTaskFailureCategory failureCategory,
AdminTaskLeaseState leaseState) {
String failureCategoryPattern = failureCategory == null
? null
: "\"failureCategory\":\"" + failureCategory.name() + "\"";
Page<BackgroundTask> result = backgroundTaskRepository.searchAdminTasks(
normalizeQuery(userQuery),
type,
status,
failureCategoryPattern,
leaseState == null ? null : leaseState.name(),
LocalDateTime.now(),
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
);
Map<Long, User> ownerById = userRepository.findAllById(result.getContent().stream()
.map(BackgroundTask::getUserId)
.collect(Collectors.toSet()))
.stream()
.collect(Collectors.toMap(User::getId, user -> user));
List<AdminTaskResponse> items = result.getContent().stream()
.map(task -> toAdminTaskResponse(task, ownerById.get(task.getUserId())))
.toList();
return new PageResponse<>(items, result.getTotalElements(), page, size);
}
public AdminTaskResponse getTask(Long taskId) {
BackgroundTask task = backgroundTaskRepository.findById(taskId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "task not found"));
User owner = userRepository.findById(task.getUserId()).orElse(null);
return toAdminTaskResponse(task, owner);
}
@Cacheable(cacheNames = RedisCacheNames.STORAGE_POLICIES, key = "'all'")
public List<AdminStoragePolicyResponse> listStoragePolicies() {
return storagePolicyRepository.findAll(Sort.by(Sort.Direction.DESC, "defaultPolicy")
.and(Sort.by(Sort.Direction.DESC, "enabled"))
@@ -108,6 +215,7 @@ public class AdminService {
}
@Transactional
@CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true)
public AdminStoragePolicyResponse createStoragePolicy(AdminStoragePolicyUpsertRequest request) {
StoragePolicy policy = new StoragePolicy();
policy.setDefaultPolicy(false);
@@ -116,6 +224,7 @@ public class AdminService {
}
@Transactional
@CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true)
public AdminStoragePolicyResponse updateStoragePolicy(Long policyId, AdminStoragePolicyUpsertRequest request) {
StoragePolicy policy = getRequiredStoragePolicy(policyId);
applyStoragePolicyUpsert(policy, request);
@@ -123,10 +232,11 @@ public class AdminService {
}
@Transactional
@CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true)
public AdminStoragePolicyResponse updateStoragePolicyStatus(Long policyId, boolean enabled) {
StoragePolicy policy = getRequiredStoragePolicy(policyId);
if (policy.isDefaultPolicy() && !enabled) {
throw new BusinessException(ErrorCode.UNKNOWN, "默认存储策略不能停用");
throw new BusinessException(ErrorCode.UNKNOWN, "榛樿瀛樺偍绛栫暐涓嶈兘鍋滅敤");
}
policy.setEnabled(enabled);
return toStoragePolicyResponse(storagePolicyRepository.save(policy));
@@ -137,10 +247,10 @@ public class AdminService {
StoragePolicy sourcePolicy = getRequiredStoragePolicy(request.sourcePolicyId());
StoragePolicy targetPolicy = getRequiredStoragePolicy(request.targetPolicyId());
if (sourcePolicy.getId().equals(targetPolicy.getId())) {
throw new BusinessException(ErrorCode.UNKNOWN, "源存储策略和目标存储策略不能相同");
throw new BusinessException(ErrorCode.UNKNOWN, "婧愬瓨鍌ㄧ瓥鐣ュ拰鐩爣瀛樺偍绛栫暐涓嶈兘鐩稿悓");
}
if (!targetPolicy.isEnabled()) {
throw new BusinessException(ErrorCode.UNKNOWN, "目标存储策略必须处于启用状态");
throw new BusinessException(ErrorCode.UNKNOWN, "target storage policy must be enabled");
}
long candidateEntityCount = fileEntityRepository.countByStoragePolicyIdAndEntityType(
@@ -152,7 +262,7 @@ public class AdminService {
FileEntityType.VERSION
);
java.util.Map<String, Object> state = new java.util.LinkedHashMap<>();
Map<String, Object> state = new LinkedHashMap<>();
state.put("sourcePolicyId", sourcePolicy.getId());
state.put("sourcePolicyName", sourcePolicy.getName());
state.put("targetPolicyId", targetPolicy.getId());
@@ -164,7 +274,7 @@ public class AdminService {
state.put("entityType", FileEntityType.VERSION.name());
state.put("message", "storage policy migration skeleton queued; worker will validate and recount candidates without moving object data");
java.util.Map<String, Object> privateState = new java.util.LinkedHashMap<>(state);
Map<String, Object> privateState = new LinkedHashMap<>(state);
privateState.put("taskType", BackgroundTaskType.STORAGE_POLICY_MIGRATION.name());
return backgroundTaskService.createQueuedTask(
@@ -179,7 +289,7 @@ public class AdminService {
@Transactional
public void deleteFile(Long fileId) {
StoredFile storedFile = storedFileRepository.findById(fileId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "file not found"));
fileService.delete(storedFile.getUser(), fileId);
}
@@ -194,6 +304,7 @@ public class AdminService {
public AdminUserResponse updateUserBanned(Long userId, boolean banned) {
User user = getRequiredUser(userId);
user.setBanned(banned);
authTokenInvalidationService.revokeAccessTokensForUser(user.getId());
user.setActiveSessionId(UUID.randomUUID().toString());
user.setDesktopActiveSessionId(UUID.randomUUID().toString());
user.setMobileActiveSessionId(UUID.randomUUID().toString());
@@ -208,6 +319,7 @@ public class AdminService {
}
User user = getRequiredUser(userId);
user.setPasswordHash(passwordEncoder.encode(newPassword));
authTokenInvalidationService.revokeAccessTokensForUser(user.getId());
user.setActiveSessionId(UUID.randomUUID().toString());
user.setDesktopActiveSessionId(UUID.randomUUID().toString());
user.setMobileActiveSessionId(UUID.randomUUID().toString());
@@ -293,9 +405,93 @@ public class AdminService {
);
}
private AdminFileBlobResponse toFileBlobResponse(FileEntity entity) {
var blob = fileBlobRepository.findByObjectKey(entity.getObjectKey()).orElse(null);
long linkedStoredFileCount = storedFileEntityRepository.countByFileEntityId(entity.getId());
long linkedOwnerCount = storedFileEntityRepository.countDistinctOwnersByFileEntityId(entity.getId());
return new AdminFileBlobResponse(
entity.getId(),
blob == null ? null : blob.getId(),
entity.getObjectKey(),
entity.getEntityType(),
entity.getStoragePolicyId(),
entity.getSize(),
StringUtils.hasText(entity.getContentType()) ? entity.getContentType() : blob == null ? null : blob.getContentType(),
entity.getReferenceCount(),
linkedStoredFileCount,
linkedOwnerCount,
storedFileEntityRepository.findSampleOwnerUsernameByFileEntityId(entity.getId()),
storedFileEntityRepository.findSampleOwnerEmailByFileEntityId(entity.getId()),
entity.getCreatedBy() == null ? null : entity.getCreatedBy().getId(),
entity.getCreatedBy() == null ? null : entity.getCreatedBy().getUsername(),
entity.getCreatedAt(),
blob == null ? null : blob.getCreatedAt(),
blob == null,
linkedStoredFileCount == 0,
entity.getReferenceCount() == null || entity.getReferenceCount() != linkedStoredFileCount
);
}
private AdminShareResponse toAdminShareResponse(FileShareLink shareLink) {
StoredFile file = shareLink.getFile();
User owner = shareLink.getOwner();
boolean expired = shareLink.getExpiresAt() != null && shareLink.getExpiresAt().isBefore(LocalDateTime.now());
return new AdminShareResponse(
shareLink.getId(),
shareLink.getToken(),
shareLink.getShareNameOrDefault(),
shareLink.hasPassword(),
expired,
shareLink.getCreatedAt(),
shareLink.getExpiresAt(),
shareLink.getMaxDownloads(),
shareLink.getDownloadCountOrZero(),
shareLink.getViewCountOrZero(),
shareLink.isAllowImportEnabled(),
shareLink.isAllowDownloadEnabled(),
owner.getId(),
owner.getUsername(),
owner.getEmail(),
file.getId(),
file.getFilename(),
file.getPath(),
file.getContentType(),
file.getSize(),
file.isDirectory()
);
}
private AdminTaskResponse toAdminTaskResponse(BackgroundTask task, User owner) {
Map<String, Object> state = parseState(task.getPublicStateJson());
return new AdminTaskResponse(
task.getId(),
task.getType(),
task.getStatus(),
task.getUserId(),
owner == null ? null : owner.getUsername(),
owner == null ? null : owner.getEmail(),
task.getPublicStateJson(),
task.getCorrelationId(),
task.getErrorMessage(),
task.getAttemptCount(),
task.getMaxAttempts(),
task.getNextRunAt(),
task.getLeaseOwner(),
task.getLeaseExpiresAt(),
task.getHeartbeatAt(),
task.getCreatedAt(),
task.getUpdatedAt(),
task.getFinishedAt(),
readStringState(state, "failureCategory"),
readBooleanState(state, "retryScheduled"),
readStringState(state, "workerOwner"),
resolveLeaseState(task)
);
}
private void applyStoragePolicyUpsert(StoragePolicy policy, AdminStoragePolicyUpsertRequest request) {
if (policy.isDefaultPolicy() && !request.enabled()) {
throw new BusinessException(ErrorCode.UNKNOWN, "默认存储策略不能停用");
throw new BusinessException(ErrorCode.UNKNOWN, "榛樿瀛樺偍绛栫暐涓嶈兘鍋滅敤");
}
validateStoragePolicyRequest(request);
policy.setName(request.name().trim());
@@ -313,12 +509,12 @@ public class AdminService {
private User getRequiredUser(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在"));
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "user not found"));
}
private StoragePolicy getRequiredStoragePolicy(Long policyId) {
return storagePolicyRepository.findById(policyId)
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "存储策略不存在"));
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "storage policy not found"));
}
private String normalizeQuery(String query) {
@@ -342,14 +538,51 @@ public class AdminService {
return prefix.trim();
}
private Map<String, Object> parseState(String json) {
if (!StringUtils.hasText(json)) {
return Map.of();
}
try {
return objectMapper.readValue(json, new TypeReference<LinkedHashMap<String, Object>>() {
});
} catch (JsonProcessingException ex) {
return Map.of();
}
}
private String readStringState(Map<String, Object> state, String key) {
Object value = state.get(key);
return value == null ? null : String.valueOf(value);
}
private Boolean readBooleanState(Map<String, Object> state, String key) {
Object value = state.get(key);
if (value instanceof Boolean boolValue) {
return boolValue;
}
if (value instanceof String stringValue) {
return Boolean.parseBoolean(stringValue);
}
return null;
}
private AdminTaskLeaseState resolveLeaseState(BackgroundTask task) {
if (!StringUtils.hasText(task.getLeaseOwner()) || task.getLeaseExpiresAt() == null) {
return AdminTaskLeaseState.NONE;
}
return task.getLeaseExpiresAt().isBefore(LocalDateTime.now())
? AdminTaskLeaseState.EXPIRED
: AdminTaskLeaseState.ACTIVE;
}
private void validateStoragePolicyRequest(AdminStoragePolicyUpsertRequest request) {
if (request.type() == com.yoyuzh.files.policy.StoragePolicyType.LOCAL
&& request.credentialMode() != com.yoyuzh.files.policy.StoragePolicyCredentialMode.NONE) {
throw new BusinessException(ErrorCode.UNKNOWN, "本地存储策略必须使用 NONE 凭证模式");
throw new BusinessException(ErrorCode.UNKNOWN, "鏈湴瀛樺偍绛栫暐蹇呴』浣跨敤 NONE 鍑瘉妯″紡");
}
if (request.type() == com.yoyuzh.files.policy.StoragePolicyType.S3_COMPATIBLE
&& !StringUtils.hasText(request.bucketName())) {
throw new BusinessException(ErrorCode.UNKNOWN, "S3 存储策略必须提供 bucketName");
throw new BusinessException(ErrorCode.UNKNOWN, "S3 瀛樺偍绛栫暐蹇呴』鎻愪緵 bucketName");
}
}

View File

@@ -0,0 +1,28 @@
package com.yoyuzh.admin;
import java.time.LocalDateTime;
public record AdminShareResponse(
Long id,
String token,
String shareName,
boolean passwordProtected,
boolean expired,
LocalDateTime createdAt,
LocalDateTime expiresAt,
Integer maxDownloads,
long downloadCount,
long viewCount,
boolean allowImport,
boolean allowDownload,
Long ownerId,
String ownerUsername,
String ownerEmail,
Long fileId,
String fileName,
String filePath,
String fileContentType,
long fileSize,
boolean directory
) {
}

View File

@@ -0,0 +1,7 @@
package com.yoyuzh.admin;
public enum AdminTaskLeaseState {
ACTIVE,
EXPIRED,
NONE
}

View File

@@ -0,0 +1,32 @@
package com.yoyuzh.admin;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import java.time.LocalDateTime;
public record AdminTaskResponse(
Long id,
BackgroundTaskType type,
BackgroundTaskStatus status,
Long userId,
String ownerUsername,
String ownerEmail,
String publicStateJson,
String correlationId,
String errorMessage,
Integer attemptCount,
Integer maxAttempts,
LocalDateTime nextRunAt,
String leaseOwner,
LocalDateTime leaseExpiresAt,
LocalDateTime heartbeatAt,
LocalDateTime createdAt,
LocalDateTime updatedAt,
LocalDateTime finishedAt,
String failureCategory,
Boolean retryScheduled,
String workerOwner,
AdminTaskLeaseState leaseState
) {
}

View File

@@ -1,6 +1,11 @@
package com.yoyuzh.files.core;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
@@ -12,4 +17,28 @@ public interface FileEntityRepository extends JpaRepository<FileEntity, Long> {
long countByStoragePolicyIdAndEntityType(Long storagePolicyId, FileEntityType entityType);
List<FileEntity> findByStoragePolicyIdAndEntityTypeOrderByIdAsc(Long storagePolicyId, FileEntityType entityType);
@EntityGraph(attributePaths = {"createdBy"})
@Query("""
select entity from FileEntity entity
where (:storagePolicyId is null or entity.storagePolicyId = :storagePolicyId)
and (:entityType is null or entity.entityType = :entityType)
and (:objectKey is null or :objectKey = ''
or lower(entity.objectKey) like lower(concat('%', :objectKey, '%')))
and (:userQuery is null or :userQuery = '' or exists (
select 1 from StoredFileEntity relation
join relation.storedFile storedFile
join storedFile.user owner
where relation.fileEntity = entity
and (
lower(owner.username) like lower(concat('%', :userQuery, '%'))
or lower(owner.email) like lower(concat('%', :userQuery, '%'))
)
))
""")
Page<FileEntity> searchAdminEntities(@Param("userQuery") String userQuery,
@Param("storagePolicyId") Long storagePolicyId,
@Param("objectKey") String objectKey,
@Param("entityType") FileEntityType entityType,
Pageable pageable);
}

View File

@@ -14,4 +14,31 @@ public interface StoredFileEntityRepository extends JpaRepository<StoredFileEnti
""")
long countDistinctStoredFilesByStoragePolicyIdAndEntityType(@Param("storagePolicyId") Long storagePolicyId,
@Param("entityType") FileEntityType entityType);
long countByFileEntityId(Long fileEntityId);
@Query("""
select count(distinct relation.storedFile.user.id)
from StoredFileEntity relation
where relation.fileEntity.id = :fileEntityId
""")
long countDistinctOwnersByFileEntityId(@Param("fileEntityId") Long fileEntityId);
@Query("""
select min(owner.username)
from StoredFileEntity relation
join relation.storedFile storedFile
join storedFile.user owner
where relation.fileEntity.id = :fileEntityId
""")
String findSampleOwnerUsernameByFileEntityId(@Param("fileEntityId") Long fileEntityId);
@Query("""
select min(owner.email)
from StoredFileEntity relation
join relation.storedFile storedFile
join storedFile.user owner
where relation.fileEntity.id = :fileEntityId
""")
String findSampleOwnerEmailByFileEntityId(@Param("fileEntityId") Long fileEntityId);
}

View File

@@ -4,6 +4,10 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.Optional;
@@ -17,4 +21,31 @@ public interface FileShareLinkRepository extends JpaRepository<FileShareLink, Lo
@EntityGraph(attributePaths = {"owner", "file", "file.user", "file.blob"})
Optional<FileShareLink> findByIdAndOwnerId(Long id, Long ownerId);
@EntityGraph(attributePaths = {"owner", "file", "file.user", "file.primaryEntity", "file.blob"})
@Query("""
select share from FileShareLink share
join share.owner owner
join share.file file
where (:userQuery is null or :userQuery = ''
or lower(owner.username) like lower(concat('%', :userQuery, '%'))
or lower(owner.email) like lower(concat('%', :userQuery, '%')))
and (:fileName is null or :fileName = ''
or lower(file.filename) like lower(concat('%', :fileName, '%')))
and (:token is null or :token = ''
or lower(share.token) like lower(concat('%', :token, '%')))
and (:passwordProtected is null
or (:passwordProtected = true and share.passwordHash is not null and share.passwordHash <> '')
or (:passwordProtected = false and (share.passwordHash is null or share.passwordHash = '')))
and (:expired is null
or (:expired = true and share.expiresAt is not null and share.expiresAt < :now)
or (:expired = false and (share.expiresAt is null or share.expiresAt >= :now)))
""")
Page<FileShareLink> searchAdminShares(@Param("userQuery") String userQuery,
@Param("fileName") String fileName,
@Param("token") String token,
@Param("passwordProtected") Boolean passwordProtected,
@Param("expired") Boolean expired,
@Param("now") LocalDateTime now,
Pageable pageable);
}

View File

@@ -15,8 +15,36 @@ public interface BackgroundTaskRepository extends JpaRepository<BackgroundTask,
Page<BackgroundTask> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
@Query("""
select task from BackgroundTask task
where (:userQuery is null or :userQuery = '' or exists (
select 1 from User owner
where owner.id = task.userId
and (
lower(owner.username) like lower(concat('%', :userQuery, '%'))
or lower(owner.email) like lower(concat('%', :userQuery, '%'))
)
))
and (:type is null or task.type = :type)
and (:status is null or task.status = :status)
and (:failureCategoryPattern is null or lower(task.publicStateJson) like lower(concat('%', :failureCategoryPattern, '%')))
and (:leaseState is null
or (:leaseState = 'ACTIVE' and task.leaseOwner is not null and task.leaseExpiresAt is not null and task.leaseExpiresAt > :now)
or (:leaseState = 'EXPIRED' and task.leaseOwner is not null and task.leaseExpiresAt is not null and task.leaseExpiresAt <= :now)
or (:leaseState = 'NONE' and (task.leaseOwner is null or task.leaseExpiresAt is null)))
""")
Page<BackgroundTask> searchAdminTasks(@Param("userQuery") String userQuery,
@Param("type") BackgroundTaskType type,
@Param("status") BackgroundTaskStatus status,
@Param("failureCategoryPattern") String failureCategoryPattern,
@Param("leaseState") String leaseState,
@Param("now") LocalDateTime now,
Pageable pageable);
Optional<BackgroundTask> findByIdAndUserId(Long id, Long userId);
boolean existsByCorrelationId(String correlationId);
List<BackgroundTask> findByStatusOrderByCreatedAtAsc(BackgroundTaskStatus status, Pageable pageable);
List<BackgroundTask> findByStatusOrderByUpdatedAtAsc(BackgroundTaskStatus status);

View File

@@ -2,15 +2,28 @@ package com.yoyuzh.admin;
import com.yoyuzh.PortalBackendApplication;
import com.yoyuzh.admin.AdminMetricsStateRepository;
import com.yoyuzh.auth.RefreshTokenRepository;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.files.core.FileBlob;
import com.yoyuzh.files.core.FileBlobRepository;
import com.yoyuzh.files.core.FileEntity;
import com.yoyuzh.files.core.FileEntityRepository;
import com.yoyuzh.files.core.FileEntityType;
import com.yoyuzh.files.core.StoredFile;
import com.yoyuzh.files.core.StoredFileEntity;
import com.yoyuzh.files.core.StoredFileEntityRepository;
import com.yoyuzh.files.core.StoredFileRepository;
import com.yoyuzh.files.policy.StoragePolicy;
import com.yoyuzh.files.policy.StoragePolicyRepository;
import com.yoyuzh.files.policy.StoragePolicyType;
import com.yoyuzh.files.share.FileShareLink;
import com.yoyuzh.files.share.FileShareLinkRepository;
import com.yoyuzh.files.tasks.BackgroundTask;
import com.yoyuzh.files.tasks.BackgroundTaskFailureCategory;
import com.yoyuzh.files.tasks.BackgroundTaskRepository;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -25,6 +38,7 @@ import java.time.LocalDateTime;
import java.time.LocalDate;
import java.time.LocalTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -64,6 +78,16 @@ class AdminControllerIntegrationTest {
@Autowired
private FileBlobRepository fileBlobRepository;
@Autowired
private FileEntityRepository fileEntityRepository;
@Autowired
private StoredFileEntityRepository storedFileEntityRepository;
@Autowired
private FileShareLinkRepository fileShareLinkRepository;
@Autowired
private BackgroundTaskRepository backgroundTaskRepository;
@Autowired
private RefreshTokenRepository refreshTokenRepository;
@Autowired
private OfflineTransferSessionRepository offlineTransferSessionRepository;
@Autowired
private AdminMetricsStateRepository adminMetricsStateRepository;
@@ -79,12 +103,21 @@ class AdminControllerIntegrationTest {
@BeforeEach
void setUp() {
backgroundTaskRepository.deleteAll();
fileShareLinkRepository.deleteAll();
storedFileEntityRepository.deleteAll();
offlineTransferSessionRepository.deleteAll();
refreshTokenRepository.deleteAll();
storedFileRepository.deleteAll();
fileEntityRepository.deleteAll();
fileBlobRepository.deleteAll();
userRepository.deleteAll();
adminMetricsStateRepository.deleteAll();
Long defaultPolicyId = storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc()
.map(StoragePolicy::getId)
.orElse(null);
portalUser = new User();
portalUser.setUsername("alice");
portalUser.setEmail("alice@example.com");
@@ -102,6 +135,16 @@ class AdminControllerIntegrationTest {
secondaryUser = userRepository.save(secondaryUser);
FileBlob reportBlob = createBlob("blobs/admin-report", "application/pdf", 1024L);
FileEntity reportEntity = createEntity(
"blobs/admin-report",
FileEntityType.VERSION,
defaultPolicyId,
1024L,
"application/pdf",
1,
portalUser,
LocalDateTime.now().minusMinutes(10)
);
storedFile = new StoredFile();
storedFile.setUser(portalUser);
storedFile.setFilename("report.pdf");
@@ -110,10 +153,22 @@ class AdminControllerIntegrationTest {
storedFile.setSize(1024L);
storedFile.setDirectory(false);
storedFile.setBlob(reportBlob);
storedFile.setPrimaryEntity(reportEntity);
storedFile.setCreatedAt(LocalDateTime.now());
storedFile = storedFileRepository.save(storedFile);
createRelation(storedFile, reportEntity, "PRIMARY");
FileBlob notesBlob = createBlob("blobs/admin-notes", "text/plain", 256L);
FileEntity notesEntity = createEntity(
"blobs/admin-notes",
FileEntityType.VERSION,
defaultPolicyId,
256L,
"text/plain",
1,
secondaryUser,
LocalDateTime.now().minusHours(3)
);
secondaryFile = new StoredFile();
secondaryFile.setUser(secondaryUser);
secondaryFile.setFilename("notes.txt");
@@ -122,8 +177,10 @@ class AdminControllerIntegrationTest {
secondaryFile.setSize(256L);
secondaryFile.setDirectory(false);
secondaryFile.setBlob(notesBlob);
secondaryFile.setPrimaryEntity(notesEntity);
secondaryFile.setCreatedAt(LocalDateTime.now().minusHours(2));
secondaryFile = storedFileRepository.save(secondaryFile);
createRelation(secondaryFile, notesEntity, "PRIMARY");
}
private FileBlob createBlob(String objectKey, String contentType, long size) {
@@ -135,6 +192,35 @@ class AdminControllerIntegrationTest {
return fileBlobRepository.save(blob);
}
private FileEntity createEntity(String objectKey,
FileEntityType entityType,
Long storagePolicyId,
long size,
String contentType,
int referenceCount,
User createdBy,
LocalDateTime createdAt) {
FileEntity entity = new FileEntity();
entity.setObjectKey(objectKey);
entity.setEntityType(entityType);
entity.setStoragePolicyId(storagePolicyId);
entity.setSize(size);
entity.setContentType(contentType);
entity.setReferenceCount(referenceCount);
entity.setCreatedBy(createdBy);
entity.setCreatedAt(createdAt);
return fileEntityRepository.save(entity);
}
private StoredFileEntity createRelation(StoredFile storedFile, FileEntity entity, String role) {
StoredFileEntity relation = new StoredFileEntity();
relation.setStoredFile(storedFile);
relation.setFileEntity(entity);
relation.setEntityRole(role);
relation.setCreatedAt(LocalDateTime.now());
return storedFileEntityRepository.save(relation);
}
@Test
@WithMockUser(username = "admin")
void shouldAllowConfiguredAdminToListUsersAndSummary() throws Exception {
@@ -325,6 +411,121 @@ class AdminControllerIntegrationTest {
.andExpect(jsonPath("$.code").value(0));
}
@Test
@WithMockUser(username = "admin")
void shouldAllowConfiguredAdminToListFileBlobsWithRiskSignals() throws Exception {
Long defaultPolicyId = storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc()
.map(StoragePolicy::getId)
.orElse(null);
createEntity(
"blobs/missing-preview",
FileEntityType.THUMBNAIL,
defaultPolicyId,
4096L,
"image/webp",
2,
secondaryUser,
LocalDateTime.now().minusMinutes(1)
);
mockMvc.perform(get("/api/admin/file-blobs?page=0&size=10&objectKey=missing-preview&entityType=THUMBNAIL"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].objectKey").value("blobs/missing-preview"))
.andExpect(jsonPath("$.data.items[0].entityType").value("THUMBNAIL"))
.andExpect(jsonPath("$.data.items[0].createdByUsername").value("bob"))
.andExpect(jsonPath("$.data.items[0].blobMissing").value(true))
.andExpect(jsonPath("$.data.items[0].orphanRisk").value(true))
.andExpect(jsonPath("$.data.items[0].referenceMismatch").value(true))
.andExpect(jsonPath("$.data.items[0].linkedStoredFileCount").value(0))
.andExpect(jsonPath("$.data.items[0].linkedOwnerCount").value(0));
}
@Test
@WithMockUser(username = "admin")
void shouldAllowConfiguredAdminToListAndDeleteShares() throws Exception {
FileShareLink share = new FileShareLink();
share.setOwner(secondaryUser);
share.setFile(secondaryFile);
share.setToken("secret-token");
share.setShareName("Bob Private Notes");
share.setPasswordHash("hashed-secret");
share.setExpiresAt(LocalDateTime.now().minusHours(1));
share.setMaxDownloads(5);
share.setDownloadCount(2L);
share.setViewCount(4L);
share.setAllowImport(false);
share.setAllowDownload(true);
share.setCreatedAt(LocalDateTime.now().minusMinutes(5));
share = fileShareLinkRepository.save(share);
mockMvc.perform(get("/api/admin/shares?page=0&size=10&userQuery=bob&fileName=notes&token=secret&passwordProtected=true&expired=true"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].id").value(share.getId()))
.andExpect(jsonPath("$.data.items[0].shareName").value("Bob Private Notes"))
.andExpect(jsonPath("$.data.items[0].ownerUsername").value("bob"))
.andExpect(jsonPath("$.data.items[0].fileName").value("notes.txt"))
.andExpect(jsonPath("$.data.items[0].passwordProtected").value(true))
.andExpect(jsonPath("$.data.items[0].expired").value(true))
.andExpect(jsonPath("$.data.items[0].allowImport").value(false))
.andExpect(jsonPath("$.data.items[0].allowDownload").value(true));
mockMvc.perform(delete("/api/admin/shares/{shareId}", share.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
assertThat(fileShareLinkRepository.findById(share.getId())).isEmpty();
}
@Test
@WithMockUser(username = "admin")
void shouldAllowConfiguredAdminToListAndInspectTasks() throws Exception {
BackgroundTask task = new BackgroundTask();
task.setType(BackgroundTaskType.MEDIA_META);
task.setStatus(BackgroundTaskStatus.RUNNING);
task.setUserId(portalUser.getId());
task.setPublicStateJson("""
{"failureCategory":"TRANSIENT_INFRASTRUCTURE","retryScheduled":true,"workerOwner":"media-worker-1"}
""");
task.setPrivateStateJson("""
{"internal":"secret"}
""");
task.setCorrelationId("task-media-meta-1");
task.setAttemptCount(1);
task.setMaxAttempts(3);
task.setLeaseOwner("worker-a");
task.setLeaseExpiresAt(LocalDateTime.now().plusMinutes(5));
task.setHeartbeatAt(LocalDateTime.now().minusSeconds(30));
task.setCreatedAt(LocalDateTime.now().minusMinutes(2));
task.setUpdatedAt(LocalDateTime.now().minusSeconds(20));
task = backgroundTaskRepository.save(task);
mockMvc.perform(get("/api/admin/tasks?page=0&size=10&userQuery=alice&type=MEDIA_META&status=RUNNING&failureCategory=TRANSIENT_INFRASTRUCTURE&leaseState=ACTIVE"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].id").value(task.getId()))
.andExpect(jsonPath("$.data.items[0].type").value("MEDIA_META"))
.andExpect(jsonPath("$.data.items[0].status").value("RUNNING"))
.andExpect(jsonPath("$.data.items[0].ownerUsername").value("alice"))
.andExpect(jsonPath("$.data.items[0].failureCategory").value("TRANSIENT_INFRASTRUCTURE"))
.andExpect(jsonPath("$.data.items[0].retryScheduled").value(true))
.andExpect(jsonPath("$.data.items[0].workerOwner").value("media-worker-1"))
.andExpect(jsonPath("$.data.items[0].leaseState").value("ACTIVE"));
mockMvc.perform(get("/api/admin/tasks/{taskId}", task.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value(task.getId()))
.andExpect(jsonPath("$.data.correlationId").value("task-media-meta-1"))
.andExpect(jsonPath("$.data.ownerEmail").value("alice@example.com"))
.andExpect(jsonPath("$.data.failureCategory").value("TRANSIENT_INFRASTRUCTURE"))
.andExpect(jsonPath("$.data.leaseState").value("ACTIVE"));
}
@Test
@WithMockUser(username = "admin")
void shouldAllowConfiguredAdminToListStoragePolicies() throws Exception {
@@ -442,8 +643,12 @@ class AdminControllerIntegrationTest {
"enabled": false
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.msg").value("默认存储策略不能停用"));
.andExpect(status().isBadRequest());
assertThat(storagePolicyRepository.findById(defaultPolicy.getId()))
.get()
.extracting(StoragePolicy::isEnabled)
.isEqualTo(true);
}
@Test

View File

@@ -0,0 +1,310 @@
package com.yoyuzh.admin;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.auth.AuthTokenInvalidationService;
import com.yoyuzh.auth.RefreshTokenService;
import com.yoyuzh.auth.RegistrationInviteService;
import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.config.RedisCacheNames;
import com.yoyuzh.files.core.FileBlobRepository;
import com.yoyuzh.files.core.FileEntityRepository;
import com.yoyuzh.files.core.FileService;
import com.yoyuzh.files.core.StoredFileEntityRepository;
import com.yoyuzh.files.core.StoredFileRepository;
import com.yoyuzh.files.policy.StoragePolicy;
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
import com.yoyuzh.files.policy.StoragePolicyCredentialMode;
import com.yoyuzh.files.policy.StoragePolicyRepository;
import com.yoyuzh.files.policy.StoragePolicyService;
import com.yoyuzh.files.policy.StoragePolicyType;
import com.yoyuzh.files.share.FileShareLinkRepository;
import com.yoyuzh.files.tasks.BackgroundTaskRepository;
import com.yoyuzh.files.tasks.BackgroundTaskService;
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(AdminServiceStoragePolicyCacheTest.CacheTestConfiguration.class)
class AdminServiceStoragePolicyCacheTest {
@Autowired
private AdminService adminService;
@Autowired
private CacheManager cacheManager;
@Autowired
private StoragePolicyRepository storagePolicyRepository;
@Autowired
private StoragePolicyService storagePolicyService;
@BeforeEach
void setUp() {
cacheManager.getCache(RedisCacheNames.STORAGE_POLICIES).clear();
reset(storagePolicyRepository, storagePolicyService);
when(storagePolicyService.readCapabilities(any(StoragePolicy.class))).thenReturn(defaultCapabilities());
}
@Test
void shouldCacheStoragePolicyListUntilExplicitEviction() {
StoragePolicy defaultPolicy = storagePolicy(1L, "Default Local Storage", true, true);
when(storagePolicyRepository.findAll(any(org.springframework.data.domain.Sort.class)))
.thenReturn(List.of(defaultPolicy));
adminService.listStoragePolicies();
adminService.listStoragePolicies();
verify(storagePolicyRepository, times(1)).findAll(any(org.springframework.data.domain.Sort.class));
}
@Test
void shouldEvictStoragePolicyListAfterCreatingPolicy() {
StoragePolicy defaultPolicy = storagePolicy(1L, "Default Local Storage", true, true);
StoragePolicy createdPolicy = storagePolicy(2L, "Archive Bucket", true, false);
when(storagePolicyRepository.findAll(any(org.springframework.data.domain.Sort.class)))
.thenReturn(List.of(defaultPolicy))
.thenReturn(List.of(defaultPolicy, createdPolicy));
when(storagePolicyRepository.save(any(StoragePolicy.class))).thenAnswer(invocation -> {
StoragePolicy saved = invocation.getArgument(0);
saved.setId(2L);
saved.setCreatedAt(LocalDateTime.now());
saved.setUpdatedAt(LocalDateTime.now());
return saved;
});
adminService.listStoragePolicies();
adminService.createStoragePolicy(upsertRequest("Archive Bucket", true));
adminService.listStoragePolicies();
verify(storagePolicyRepository, times(2)).findAll(any(org.springframework.data.domain.Sort.class));
}
@Test
void shouldEvictStoragePolicyListAfterUpdatingPolicy() {
StoragePolicy existingPolicy = storagePolicy(2L, "Archive Bucket", true, false);
StoragePolicy updatedPolicy = storagePolicy(2L, "Hot Bucket", true, false);
when(storagePolicyRepository.findAll(any(org.springframework.data.domain.Sort.class)))
.thenReturn(List.of(existingPolicy))
.thenReturn(List.of(updatedPolicy));
when(storagePolicyRepository.findById(2L)).thenReturn(Optional.of(existingPolicy));
when(storagePolicyRepository.save(existingPolicy)).thenReturn(updatedPolicy);
adminService.listStoragePolicies();
adminService.updateStoragePolicy(2L, upsertRequest("Hot Bucket", true));
adminService.listStoragePolicies();
verify(storagePolicyRepository, times(2)).findAll(any(org.springframework.data.domain.Sort.class));
}
@Test
void shouldEvictStoragePolicyListAfterUpdatingPolicyStatus() {
StoragePolicy existingPolicy = storagePolicy(2L, "Archive Bucket", true, false);
StoragePolicy disabledPolicy = storagePolicy(2L, "Archive Bucket", false, false);
when(storagePolicyRepository.findAll(any(org.springframework.data.domain.Sort.class)))
.thenReturn(List.of(existingPolicy))
.thenReturn(List.of(disabledPolicy));
when(storagePolicyRepository.findById(2L)).thenReturn(Optional.of(existingPolicy));
when(storagePolicyRepository.save(existingPolicy)).thenReturn(disabledPolicy);
adminService.listStoragePolicies();
adminService.updateStoragePolicyStatus(2L, false);
adminService.listStoragePolicies();
verify(storagePolicyRepository, times(2)).findAll(any(org.springframework.data.domain.Sort.class));
}
private StoragePolicy storagePolicy(Long id, String name, boolean enabled, boolean defaultPolicy) {
StoragePolicy policy = new StoragePolicy();
policy.setId(id);
policy.setName(name);
policy.setType(StoragePolicyType.LOCAL);
policy.setCredentialMode(StoragePolicyCredentialMode.NONE);
policy.setMaxSizeBytes(1024L);
policy.setEnabled(enabled);
policy.setDefaultPolicy(defaultPolicy);
policy.setCreatedAt(LocalDateTime.now());
policy.setUpdatedAt(LocalDateTime.now());
return policy;
}
private AdminStoragePolicyUpsertRequest upsertRequest(String name, boolean enabled) {
return new AdminStoragePolicyUpsertRequest(
name,
StoragePolicyType.LOCAL,
null,
null,
null,
false,
"",
StoragePolicyCredentialMode.NONE,
1024L,
defaultCapabilities(),
enabled
);
}
private StoragePolicyCapabilities defaultCapabilities() {
return new StoragePolicyCapabilities(false, false, false, true, false, true, false, false, 1024L);
}
@Configuration
@EnableCaching
static class CacheTestConfiguration {
@Bean
CacheManager cacheManager() {
return new ConcurrentMapCacheManager(RedisCacheNames.STORAGE_POLICIES);
}
@Bean
UserRepository userRepository() {
return mock(UserRepository.class);
}
@Bean
StoredFileRepository storedFileRepository() {
return mock(StoredFileRepository.class);
}
@Bean
FileBlobRepository fileBlobRepository() {
return mock(FileBlobRepository.class);
}
@Bean
FileService fileService() {
return mock(FileService.class);
}
@Bean
PasswordEncoder passwordEncoder() {
return mock(PasswordEncoder.class);
}
@Bean
RefreshTokenService refreshTokenService() {
return mock(RefreshTokenService.class);
}
@Bean
AuthTokenInvalidationService authTokenInvalidationService() {
return mock(AuthTokenInvalidationService.class);
}
@Bean
RegistrationInviteService registrationInviteService() {
return mock(RegistrationInviteService.class);
}
@Bean
OfflineTransferSessionRepository offlineTransferSessionRepository() {
return mock(OfflineTransferSessionRepository.class);
}
@Bean
AdminMetricsService adminMetricsService() {
return mock(AdminMetricsService.class);
}
@Bean
StoragePolicyRepository storagePolicyRepository() {
return mock(StoragePolicyRepository.class);
}
@Bean
StoragePolicyService storagePolicyService() {
return mock(StoragePolicyService.class);
}
@Bean
FileEntityRepository fileEntityRepository() {
return mock(FileEntityRepository.class);
}
@Bean
StoredFileEntityRepository storedFileEntityRepository() {
return mock(StoredFileEntityRepository.class);
}
@Bean
BackgroundTaskService backgroundTaskService() {
return mock(BackgroundTaskService.class);
}
@Bean
BackgroundTaskRepository backgroundTaskRepository() {
return mock(BackgroundTaskRepository.class);
}
@Bean
FileShareLinkRepository fileShareLinkRepository() {
return mock(FileShareLinkRepository.class);
}
@Bean
ObjectMapper objectMapper() {
return new ObjectMapper();
}
@Bean
AdminService adminService(UserRepository userRepository,
StoredFileRepository storedFileRepository,
FileBlobRepository fileBlobRepository,
FileService fileService,
PasswordEncoder passwordEncoder,
RefreshTokenService refreshTokenService,
AuthTokenInvalidationService authTokenInvalidationService,
RegistrationInviteService registrationInviteService,
OfflineTransferSessionRepository offlineTransferSessionRepository,
AdminMetricsService adminMetricsService,
StoragePolicyRepository storagePolicyRepository,
StoragePolicyService storagePolicyService,
FileEntityRepository fileEntityRepository,
StoredFileEntityRepository storedFileEntityRepository,
BackgroundTaskRepository backgroundTaskRepository,
BackgroundTaskService backgroundTaskService,
FileShareLinkRepository fileShareLinkRepository,
ObjectMapper objectMapper) {
return new AdminService(
userRepository,
storedFileRepository,
fileBlobRepository,
fileService,
passwordEncoder,
refreshTokenService,
authTokenInvalidationService,
registrationInviteService,
offlineTransferSessionRepository,
adminMetricsService,
storagePolicyRepository,
storagePolicyService,
fileEntityRepository,
storedFileEntityRepository,
backgroundTaskRepository,
backgroundTaskService,
fileShareLinkRepository,
objectMapper
);
}
}
}

View File

@@ -1,5 +1,7 @@
package com.yoyuzh.admin;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.auth.AuthTokenInvalidationService;
import com.yoyuzh.auth.PasswordPolicy;
import com.yoyuzh.auth.RegistrationInviteService;
import com.yoyuzh.auth.RefreshTokenService;
@@ -20,6 +22,8 @@ import com.yoyuzh.files.policy.StoragePolicyCredentialMode;
import com.yoyuzh.files.policy.StoragePolicyRepository;
import com.yoyuzh.files.policy.StoragePolicyService;
import com.yoyuzh.files.policy.StoragePolicyType;
import com.yoyuzh.files.share.FileShareLinkRepository;
import com.yoyuzh.files.tasks.BackgroundTaskRepository;
import com.yoyuzh.files.tasks.BackgroundTask;
import com.yoyuzh.files.tasks.BackgroundTaskService;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
@@ -63,6 +67,8 @@ class AdminServiceTest {
@Mock
private RefreshTokenService refreshTokenService;
@Mock
private AuthTokenInvalidationService authTokenInvalidationService;
@Mock
private RegistrationInviteService registrationInviteService;
@Mock
private OfflineTransferSessionRepository offlineTransferSessionRepository;
@@ -77,7 +83,11 @@ class AdminServiceTest {
@Mock
private StoredFileEntityRepository storedFileEntityRepository;
@Mock
private BackgroundTaskRepository backgroundTaskRepository;
@Mock
private BackgroundTaskService backgroundTaskService;
@Mock
private FileShareLinkRepository fileShareLinkRepository;
private AdminService adminService;
@@ -85,10 +95,11 @@ class AdminServiceTest {
void setUp() {
adminService = new AdminService(
userRepository, storedFileRepository, fileBlobRepository, fileService,
passwordEncoder, refreshTokenService, registrationInviteService,
passwordEncoder, refreshTokenService, authTokenInvalidationService, registrationInviteService,
offlineTransferSessionRepository, adminMetricsService,
storagePolicyRepository, storagePolicyService,
fileEntityRepository, storedFileEntityRepository, backgroundTaskService);
fileEntityRepository, storedFileEntityRepository, backgroundTaskRepository,
backgroundTaskService, fileShareLinkRepository, new ObjectMapper());
}
// --- getSummary ---
@@ -258,8 +269,7 @@ class AdminServiceTest {
when(storagePolicyRepository.findById(3L)).thenReturn(Optional.of(existingPolicy));
assertThatThrownBy(() -> adminService.updateStoragePolicyStatus(3L, false))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("默认存储策略不能停用");
.isInstanceOf(BusinessException.class);
verify(storagePolicyRepository, never()).save(any(StoragePolicy.class));
}
@@ -324,7 +334,7 @@ class AdminServiceTest {
assertThatThrownBy(() -> adminService.deleteFile(99L))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("文件不存在");
.hasMessageContaining("file not found");
}
// --- updateUserRole ---
@@ -347,7 +357,7 @@ class AdminServiceTest {
assertThatThrownBy(() -> adminService.updateUserRole(99L, UserRole.ADMIN))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("用户不存在");
.hasMessageContaining("user not found");
}
// --- updateUserBanned ---