feat(admin): add blob share and task admin apis
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user