Compare commits

..

19 Commits

Author SHA1 Message Date
yoyuzh
f59515f5dd feat(admin): add blob share and task admin apis 2026-04-11 14:09:31 +08:00
yoyuzh
12005cc606 feat(front): 覆盖 front 并完善登录快传入口与中文文案 2026-04-10 01:09:06 +08:00
yoyuzh
99e00cd7f7 feat(portal): land files platform and frontend workspace refresh 2026-04-09 18:35:03 +08:00
yoyuzh
67cd0f6e6f docs: rewrite frontend construction spec 2026-04-09 18:34:20 +08:00
yoyuzh
3906a523fd refactor(files): reorganize backend package layout 2026-04-09 16:00:34 +08:00
yoyuzh
da576e0253 docs(front): broaden redesign scope 2026-04-09 01:09:46 +08:00
yoyuzh
7d6ceaf6d8 docs(front): add redesign handoff plans 2026-04-09 00:48:32 +08:00
yoyuzh
977eb60b17 feat(files): add v2 task and metadata workflows 2026-04-09 00:42:41 +08:00
yoyuzh
c5362ebe31 feat(admin): show storage policies 2026-04-08 21:54:22 +08:00
yoyuzh
3e67760712 feat(files): stamp entities with storage policy 2026-04-08 21:44:38 +08:00
yoyuzh
00b268c30f fix(storage): integrate s3 session provider 2026-04-08 21:38:49 +08:00
yoyuzh
19c296a212 添加storage 2026-04-08 21:11:18 +08:00
yoyuzh
6da0d196ee feat(files): add storage policy skeleton 2026-04-08 15:37:43 +08:00
yoyuzh
f582e600aa feat(files): expire stale upload sessions 2026-04-08 15:27:39 +08:00
yoyuzh
06a95bc489 feat(files): track v2 upload session parts 2026-04-08 15:22:52 +08:00
yoyuzh
35b0691188 feat(files): complete v2 upload sessions 2026-04-08 15:18:09 +08:00
yoyuzh
7ddef9bddb feat(files): add v2 upload session skeleton 2026-04-08 15:12:36 +08:00
yoyuzh
5802f396c5 feat(files): add file entity migration 2026-04-08 15:02:42 +08:00
yoyuzh
9d5fdd9ea3 feat(api): add v2 phase one skeleton 2026-04-08 14:28:01 +08:00
347 changed files with 22511 additions and 21793 deletions

View File

@@ -1,5 +1,5 @@
name = "implementer"
description = "Code-writing agent. It makes focused changes in frontend, backend, scripts, or docs after planning/exploration are complete, and leaves broad verification to tester."
description = "Code-writing agent. It owns delegated feature implementation and focused changes in frontend, backend, scripts, or docs after planning/exploration are complete; broad or time-consuming edits should come here instead of being handled by orchestrator. It leaves broad verification to tester."
nickname_candidates = ["implementer", "impl", "builder"]
sandbox_mode = "workspace-write"
include_apply_patch_tool = true

View File

@@ -1,5 +1,5 @@
name = "orchestrator"
description = "Default top-level agent for this repo. It coordinates specialist agents, keeps scope aligned with the user request, and owns the final synthesis."
description = "Default top-level agent for this repo. It coordinates specialist agents, keeps scope aligned with the user request, and owns the final synthesis. It should not directly own feature implementation or broad code edits; only tiny alignment fixes such as imports, field/signature synchronization, or obvious one-line consistency repairs may be handled locally when delegating would be slower than the fix itself."
nickname_candidates = ["orchestrator", "lead", "coord"]
sandbox_mode = "read-only"
include_apply_patch_tool = false

View File

@@ -1,5 +1,5 @@
name = "tester"
description = "Verification-only agent. It runs lint, test, build, package, and type-check commands that already exist in this repo, reports failures, and does not edit source files."
description = "Verification-only agent. It runs only repository-backed verification commands that already exist in this repo, reports exact failures, and does not edit source files. Use the `multi-angle-verification` skill as the default verification workflow so command coverage, browser-flow checks, UI review, and coverage-gap reporting stay consistent. Android emulator or device simulation is out of scope for this agent and is handled manually by the user."
nickname_candidates = ["tester", "qa", "verify"]
sandbox_mode = "workspace-write"
include_apply_patch_tool = false

View File

@@ -22,7 +22,7 @@ config_file = ".codex/agents/implementer.toml"
nickname_candidates = ["implementer", "impl", "builder"]
[agents.tester]
description = "Runs repository-backed verification commands only."
description = "Runs repository-backed verification through the multi-angle-verification workflow, including browser and UI review when the task warrants it."
config_file = ".codex/agents/tester.toml"
nickname_candidates = ["tester", "qa", "verify"]

3
.gitignore vendored
View File

@@ -1,6 +1,7 @@
backend/target/
data/
storage/
/storage/
/backend/storage/
node_modules/
output/
tmp/

View File

@@ -1,10 +1,20 @@
package com.yoyuzh.admin;
import com.yoyuzh.api.v2.tasks.BackgroundTaskResponse;
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;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
@@ -16,6 +26,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
@@ -23,6 +35,7 @@ import org.springframework.web.bind.annotation.RestController;
public class AdminController {
private final AdminService adminService;
private final CustomUserDetailsService userDetailsService;
@GetMapping("/summary")
public ApiResponse<AdminSummaryResponse> summary() {
@@ -52,6 +65,82 @@ 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());
}
@PostMapping("/storage-policies")
public ApiResponse<AdminStoragePolicyResponse> createStoragePolicy(
@Valid @RequestBody AdminStoragePolicyUpsertRequest request) {
return ApiResponse.success(adminService.createStoragePolicy(request));
}
@PutMapping("/storage-policies/{policyId}")
public ApiResponse<AdminStoragePolicyResponse> updateStoragePolicy(
@PathVariable Long policyId,
@Valid @RequestBody AdminStoragePolicyUpsertRequest request) {
return ApiResponse.success(adminService.updateStoragePolicy(policyId, request));
}
@PatchMapping("/storage-policies/{policyId}/status")
public ApiResponse<AdminStoragePolicyResponse> updateStoragePolicyStatus(
@PathVariable Long policyId,
@Valid @RequestBody AdminStoragePolicyStatusUpdateRequest request) {
return ApiResponse.success(adminService.updateStoragePolicyStatus(policyId, request.enabled()));
}
@PostMapping("/storage-policies/migrations")
public ApiResponse<BackgroundTaskResponse> createStoragePolicyMigrationTask(
@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody AdminStoragePolicyMigrationCreateRequest request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiResponse.success(toTaskResponse(adminService.createStoragePolicyMigrationTask(user, request)));
}
@DeleteMapping("/files/{fileId}")
public ApiResponse<Void> deleteFile(@PathVariable Long fileId) {
adminService.deleteFile(fileId);
@@ -92,4 +181,19 @@ public class AdminController {
public ApiResponse<AdminPasswordResetResponse> resetUserPassword(@PathVariable Long userId) {
return ApiResponse.success(adminService.resetUserPassword(userId));
}
private BackgroundTaskResponse toTaskResponse(BackgroundTask task) {
return new BackgroundTaskResponse(
task.getId(),
task.getType(),
task.getStatus(),
task.getUserId(),
task.getPublicStateJson(),
task.getCorrelationId(),
task.getErrorMessage(),
task.getCreatedAt(),
task.getUpdatedAt(),
task.getFinishedAt()
);
}
}

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,31 +1,58 @@
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.FileBlobRepository;
import com.yoyuzh.files.FileService;
import com.yoyuzh.files.StoredFile;
import com.yoyuzh.files.StoredFileRepository;
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;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
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
@@ -37,9 +64,18 @@ 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;
private final StoragePolicyRepository storagePolicyRepository;
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() {
@@ -83,10 +119,177 @@ 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"))
.and(Sort.by(Sort.Direction.ASC, "id")))
.stream()
.map(this::toStoragePolicyResponse)
.toList();
}
@Transactional
@CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true)
public AdminStoragePolicyResponse createStoragePolicy(AdminStoragePolicyUpsertRequest request) {
StoragePolicy policy = new StoragePolicy();
policy.setDefaultPolicy(false);
applyStoragePolicyUpsert(policy, request);
return toStoragePolicyResponse(storagePolicyRepository.save(policy));
}
@Transactional
@CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true)
public AdminStoragePolicyResponse updateStoragePolicy(Long policyId, AdminStoragePolicyUpsertRequest request) {
StoragePolicy policy = getRequiredStoragePolicy(policyId);
applyStoragePolicyUpsert(policy, request);
return toStoragePolicyResponse(storagePolicyRepository.save(policy));
}
@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, "榛樿瀛樺偍绛栫暐涓嶈兘鍋滅敤");
}
policy.setEnabled(enabled);
return toStoragePolicyResponse(storagePolicyRepository.save(policy));
}
@Transactional
public BackgroundTask createStoragePolicyMigrationTask(User user, AdminStoragePolicyMigrationCreateRequest request) {
StoragePolicy sourcePolicy = getRequiredStoragePolicy(request.sourcePolicyId());
StoragePolicy targetPolicy = getRequiredStoragePolicy(request.targetPolicyId());
if (sourcePolicy.getId().equals(targetPolicy.getId())) {
throw new BusinessException(ErrorCode.UNKNOWN, "婧愬瓨鍌ㄧ瓥鐣ュ拰鐩爣瀛樺偍绛栫暐涓嶈兘鐩稿悓");
}
if (!targetPolicy.isEnabled()) {
throw new BusinessException(ErrorCode.UNKNOWN, "target storage policy must be enabled");
}
long candidateEntityCount = fileEntityRepository.countByStoragePolicyIdAndEntityType(
sourcePolicy.getId(),
FileEntityType.VERSION
);
long candidateStoredFileCount = storedFileEntityRepository.countDistinctStoredFilesByStoragePolicyIdAndEntityType(
sourcePolicy.getId(),
FileEntityType.VERSION
);
Map<String, Object> state = new LinkedHashMap<>();
state.put("sourcePolicyId", sourcePolicy.getId());
state.put("sourcePolicyName", sourcePolicy.getName());
state.put("targetPolicyId", targetPolicy.getId());
state.put("targetPolicyName", targetPolicy.getName());
state.put("candidateEntityCount", candidateEntityCount);
state.put("candidateStoredFileCount", candidateStoredFileCount);
state.put("migrationPerformed", false);
state.put("migrationMode", "skeleton");
state.put("entityType", FileEntityType.VERSION.name());
state.put("message", "storage policy migration skeleton queued; worker will validate and recount candidates without moving object data");
Map<String, Object> privateState = new LinkedHashMap<>(state);
privateState.put("taskType", BackgroundTaskType.STORAGE_POLICY_MIGRATION.name());
return backgroundTaskService.createQueuedTask(
user,
BackgroundTaskType.STORAGE_POLICY_MIGRATION,
state,
privateState,
request.correlationId()
);
}
@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);
}
@@ -101,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());
@@ -115,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());
@@ -180,9 +385,136 @@ public class AdminService {
);
}
private AdminStoragePolicyResponse toStoragePolicyResponse(StoragePolicy policy) {
return new AdminStoragePolicyResponse(
policy.getId(),
policy.getName(),
policy.getType(),
policy.getBucketName(),
policy.getEndpoint(),
policy.getRegion(),
policy.isPrivateBucket(),
policy.getPrefix(),
policy.getCredentialMode(),
policy.getMaxSizeBytes(),
storagePolicyService.readCapabilities(policy),
policy.isEnabled(),
policy.isDefaultPolicy(),
policy.getCreatedAt(),
policy.getUpdatedAt()
);
}
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, "榛樿瀛樺偍绛栫暐涓嶈兘鍋滅敤");
}
validateStoragePolicyRequest(request);
policy.setName(request.name().trim());
policy.setType(request.type());
policy.setBucketName(normalizeNullable(request.bucketName()));
policy.setEndpoint(normalizeNullable(request.endpoint()));
policy.setRegion(normalizeNullable(request.region()));
policy.setPrivateBucket(request.privateBucket());
policy.setPrefix(normalizePrefix(request.prefix()));
policy.setCredentialMode(request.credentialMode());
policy.setMaxSizeBytes(request.maxSizeBytes());
policy.setCapabilitiesJson(storagePolicyService.writeCapabilities(request.capabilities()));
policy.setEnabled(request.enabled());
}
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, "storage policy not found"));
}
private String normalizeQuery(String query) {
@@ -192,6 +524,68 @@ public class AdminService {
return query.trim();
}
private String normalizeNullable(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
private String normalizePrefix(String prefix) {
if (!StringUtils.hasText(prefix)) {
return "";
}
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 鍑瘉妯″紡");
}
if (request.type() == com.yoyuzh.files.policy.StoragePolicyType.S3_COMPATIBLE
&& !StringUtils.hasText(request.bucketName())) {
throw new BusinessException(ErrorCode.UNKNOWN, "S3 瀛樺偍绛栫暐蹇呴』鎻愪緵 bucketName");
}
}
private String generateTemporaryPassword() {
String lowers = "abcdefghjkmnpqrstuvwxyz";
String uppers = "ABCDEFGHJKMNPQRSTUVWXYZ";

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,12 @@
package com.yoyuzh.admin;
import jakarta.validation.constraints.NotNull;
public record AdminStoragePolicyMigrationCreateRequest(
@NotNull(message = "sourcePolicyId 不能为空")
Long sourcePolicyId,
@NotNull(message = "targetPolicyId 不能为空")
Long targetPolicyId,
String correlationId
) {
}

View File

@@ -0,0 +1,26 @@
package com.yoyuzh.admin;
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
import com.yoyuzh.files.policy.StoragePolicyCredentialMode;
import com.yoyuzh.files.policy.StoragePolicyType;
import java.time.LocalDateTime;
public record AdminStoragePolicyResponse(
Long id,
String name,
StoragePolicyType type,
String bucketName,
String endpoint,
String region,
boolean privateBucket,
String prefix,
StoragePolicyCredentialMode credentialMode,
long maxSizeBytes,
StoragePolicyCapabilities capabilities,
boolean enabled,
boolean defaultPolicy,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.admin;
import jakarta.validation.constraints.NotNull;
public record AdminStoragePolicyStatusUpdateRequest(
@NotNull(message = "enabled 不能为空")
Boolean enabled
) {
}

View File

@@ -0,0 +1,28 @@
package com.yoyuzh.admin;
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
import com.yoyuzh.files.policy.StoragePolicyCredentialMode;
import com.yoyuzh.files.policy.StoragePolicyType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
public record AdminStoragePolicyUpsertRequest(
@NotBlank(message = "存储策略名称不能为空")
String name,
@NotNull(message = "存储策略类型不能为空")
StoragePolicyType type,
String bucketName,
String endpoint,
String region,
boolean privateBucket,
String prefix,
@NotNull(message = "凭证模式不能为空")
StoragePolicyCredentialMode credentialMode,
@Positive(message = "最大对象大小必须大于 0")
long maxSizeBytes,
@NotNull(message = "能力声明不能为空")
StoragePolicyCapabilities capabilities,
boolean enabled
) {
}

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

@@ -0,0 +1,27 @@
package com.yoyuzh.api.v2;
import org.springframework.http.HttpStatus;
public enum ApiV2ErrorCode {
BAD_REQUEST(2400, HttpStatus.BAD_REQUEST),
NOT_LOGGED_IN(2401, HttpStatus.UNAUTHORIZED),
PERMISSION_DENIED(2403, HttpStatus.FORBIDDEN),
FILE_NOT_FOUND(2404, HttpStatus.NOT_FOUND),
INTERNAL_ERROR(2500, HttpStatus.INTERNAL_SERVER_ERROR);
private final int code;
private final HttpStatus httpStatus;
ApiV2ErrorCode(int code, HttpStatus httpStatus) {
this.code = code;
this.httpStatus = httpStatus;
}
public int getCode() {
return code;
}
public HttpStatus getHttpStatus() {
return httpStatus;
}
}

View File

@@ -0,0 +1,15 @@
package com.yoyuzh.api.v2;
public class ApiV2Exception extends RuntimeException {
private final ApiV2ErrorCode errorCode;
public ApiV2Exception(ApiV2ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public ApiV2ErrorCode getErrorCode() {
return errorCode;
}
}

View File

@@ -0,0 +1,43 @@
package com.yoyuzh.api.v2;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(basePackages = "com.yoyuzh.api.v2")
public class ApiV2ExceptionHandler {
@ExceptionHandler(ApiV2Exception.class)
public ResponseEntity<ApiV2Response<Void>> handleApiV2Exception(ApiV2Exception ex) {
ApiV2ErrorCode errorCode = ex.getErrorCode();
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(ApiV2Response.error(errorCode, ex.getMessage()));
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiV2Response<Void>> handleBusinessException(BusinessException ex) {
ApiV2ErrorCode errorCode = mapBusinessErrorCode(ex.getErrorCode());
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(ApiV2Response.error(errorCode, ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiV2Response<Void>> handleUnknownException(Exception ex) {
return ResponseEntity
.status(ApiV2ErrorCode.INTERNAL_ERROR.getHttpStatus())
.body(ApiV2Response.error(ApiV2ErrorCode.INTERNAL_ERROR, "服务器内部错误"));
}
private ApiV2ErrorCode mapBusinessErrorCode(ErrorCode errorCode) {
return switch (errorCode) {
case NOT_LOGGED_IN -> ApiV2ErrorCode.NOT_LOGGED_IN;
case PERMISSION_DENIED -> ApiV2ErrorCode.PERMISSION_DENIED;
case FILE_NOT_FOUND -> ApiV2ErrorCode.FILE_NOT_FOUND;
case UNKNOWN -> ApiV2ErrorCode.BAD_REQUEST;
};
}
}

View File

@@ -0,0 +1,12 @@
package com.yoyuzh.api.v2;
public record ApiV2Response<T>(int code, String msg, T data) {
public static <T> ApiV2Response<T> success(T data) {
return new ApiV2Response<>(0, "success", data);
}
public static ApiV2Response<Void> error(ApiV2ErrorCode errorCode, String msg) {
return new ApiV2Response<>(errorCode.getCode(), msg, null);
}
}

View File

@@ -0,0 +1,12 @@
package com.yoyuzh.api.v2.files;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public record CreateUploadSessionV2Request(
@NotBlank String path,
@NotBlank String filename,
String contentType,
@Min(0) long size
) {
}

View File

@@ -0,0 +1,31 @@
package com.yoyuzh.api.v2.files;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.files.events.FileEventService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@RestController
@RequestMapping("/api/v2/files")
@RequiredArgsConstructor
public class FileEventsV2Controller {
private final FileEventService fileEventService;
private final CustomUserDetailsService userDetailsService;
@GetMapping(value = "/events", produces = "text/event-stream")
public SseEmitter events(@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(required = false, defaultValue = "/") String path,
@RequestHeader(value = "X-Yoyuzh-Client-Id", required = false) String clientId) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return fileEventService.openStream(user, path, clientId);
}
}

View File

@@ -0,0 +1,71 @@
package com.yoyuzh.api.v2.files;
import com.yoyuzh.api.v2.ApiV2ErrorCode;
import com.yoyuzh.api.v2.ApiV2Exception;
import com.yoyuzh.api.v2.ApiV2Response;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.core.FileMetadataResponse;
import com.yoyuzh.files.search.FileSearchQuery;
import com.yoyuzh.files.search.FileSearchService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.Locale;
@RestController
@RequestMapping("/api/v2/files")
@RequiredArgsConstructor
public class FileSearchV2Controller {
private final FileSearchService fileSearchService;
private final CustomUserDetailsService userDetailsService;
@GetMapping("/search")
public ApiV2Response<PageResponse<FileMetadataResponse>> search(@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(required = false) String name,
@RequestParam(required = false) String type,
@RequestParam(required = false) Long sizeGte,
@RequestParam(required = false) Long sizeLte,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime createdGte,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime createdLte,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime updatedGte,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime updatedLte,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(fileSearchService.search(
user,
new FileSearchQuery(name, parseType(type), sizeGte, sizeLte, createdGte, createdLte, updatedGte, updatedLte, page, size)
));
}
private Boolean parseType(String type) {
if (!StringUtils.hasText(type) || "all".equalsIgnoreCase(type.trim())) {
return null;
}
return switch (type.trim().toLowerCase(Locale.ROOT)) {
case "file" -> false;
case "directory", "folder" -> true;
default -> throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "文件类型筛选只支持 file 或 directory");
};
}
}

View File

@@ -0,0 +1,10 @@
package com.yoyuzh.api.v2.files;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public record MarkUploadSessionPartV2Request(
@NotBlank String etag,
@Min(0) long size
) {
}

View File

@@ -0,0 +1,12 @@
package com.yoyuzh.api.v2.files;
import java.util.Map;
public record PreparedUploadV2Response(
boolean direct,
String uploadUrl,
String method,
Map<String, String> headers,
String storageName
) {
}

View File

@@ -0,0 +1,178 @@
package com.yoyuzh.api.v2.files;
import com.yoyuzh.api.v2.ApiV2Response;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.files.upload.UploadSession;
import com.yoyuzh.files.upload.UploadSessionCreateCommand;
import com.yoyuzh.files.upload.UploadSessionUploadMode;
import com.yoyuzh.files.upload.UploadSessionPartCommand;
import com.yoyuzh.files.upload.UploadSessionService;
import com.yoyuzh.files.storage.PreparedUpload;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/v2/files/upload-sessions")
@RequiredArgsConstructor
public class UploadSessionV2Controller {
private final UploadSessionService uploadSessionService;
private final CustomUserDetailsService userDetailsService;
@PostMapping
public ApiV2Response<UploadSessionV2Response> createSession(@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody CreateUploadSessionV2Request request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
UploadSession session = uploadSessionService.createSession(user, new UploadSessionCreateCommand(
request.path(),
request.filename(),
request.contentType(),
request.size()
));
return ApiV2Response.success(toResponse(session));
}
@GetMapping("/{sessionId}")
public ApiV2Response<UploadSessionV2Response> getSession(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(uploadSessionService.getOwnedSession(user, sessionId)));
}
@GetMapping("/{sessionId}/prepare")
public ApiV2Response<PreparedUploadV2Response> prepareUpload(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
PreparedUpload preparedUpload = uploadSessionService.prepareOwnedUpload(user, sessionId);
return ApiV2Response.success(new PreparedUploadV2Response(
preparedUpload.direct(),
preparedUpload.uploadUrl(),
preparedUpload.method(),
preparedUpload.headers(),
preparedUpload.storageName()
));
}
@DeleteMapping("/{sessionId}")
public ApiV2Response<UploadSessionV2Response> cancelSession(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(uploadSessionService.cancelOwnedSession(user, sessionId)));
}
@PostMapping("/{sessionId}/complete")
public ApiV2Response<UploadSessionV2Response> completeSession(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(uploadSessionService.completeOwnedSession(user, sessionId)));
}
@PutMapping("/{sessionId}/parts/{partIndex}")
public ApiV2Response<UploadSessionV2Response> recordPart(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId,
@PathVariable int partIndex,
@Valid @RequestBody MarkUploadSessionPartV2Request request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
UploadSession session = uploadSessionService.recordUploadedPart(
user,
sessionId,
partIndex,
new UploadSessionPartCommand(request.etag(), request.size())
);
return ApiV2Response.success(toResponse(session));
}
@PostMapping("/{sessionId}/content")
public ApiV2Response<UploadSessionV2Response> uploadContent(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId,
@RequestPart("file") MultipartFile file) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(uploadSessionService.uploadOwnedContent(user, sessionId, file)));
}
@GetMapping("/{sessionId}/parts/{partIndex}/prepare")
public ApiV2Response<PreparedUploadV2Response> preparePartUpload(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId,
@PathVariable int partIndex) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
PreparedUpload preparedUpload = uploadSessionService.prepareOwnedPartUpload(user, sessionId, partIndex);
return ApiV2Response.success(new PreparedUploadV2Response(
preparedUpload.direct(),
preparedUpload.uploadUrl(),
preparedUpload.method(),
preparedUpload.headers(),
preparedUpload.storageName()
));
}
private UploadSessionV2Response toResponse(UploadSession session) {
UploadSessionUploadMode uploadMode = uploadSessionService.resolveUploadMode(session);
if (uploadMode == null) {
uploadMode = session.getMultipartUploadId() != null
? UploadSessionUploadMode.DIRECT_MULTIPART
: UploadSessionUploadMode.PROXY;
}
return new UploadSessionV2Response(
session.getSessionId(),
session.getObjectKey(),
uploadMode != UploadSessionUploadMode.PROXY,
uploadMode == UploadSessionUploadMode.DIRECT_MULTIPART,
uploadMode.name(),
session.getTargetPath(),
session.getFilename(),
session.getContentType(),
session.getSize(),
session.getStoragePolicyId(),
session.getStatus().name(),
session.getChunkSize(),
session.getChunkCount(),
session.getExpiresAt(),
session.getCreatedAt(),
session.getUpdatedAt(),
toStrategyResponse(session.getSessionId(), uploadMode)
);
}
private UploadSessionV2StrategyResponse toStrategyResponse(String sessionId, UploadSessionUploadMode uploadMode) {
String sessionBasePath = "/api/v2/files/upload-sessions/" + sessionId;
return switch (uploadMode) {
case PROXY -> new UploadSessionV2StrategyResponse(
null,
sessionBasePath + "/content",
null,
null,
sessionBasePath + "/complete",
"file"
);
case DIRECT_SINGLE -> new UploadSessionV2StrategyResponse(
sessionBasePath + "/prepare",
null,
null,
null,
sessionBasePath + "/complete",
null
);
case DIRECT_MULTIPART -> new UploadSessionV2StrategyResponse(
null,
null,
sessionBasePath + "/parts/{partIndex}/prepare",
sessionBasePath + "/parts/{partIndex}",
sessionBasePath + "/complete",
null
);
};
}
}

View File

@@ -0,0 +1,24 @@
package com.yoyuzh.api.v2.files;
import java.time.LocalDateTime;
public record UploadSessionV2Response(
String sessionId,
String objectKey,
boolean directUpload,
boolean multipartUpload,
String uploadMode,
String path,
String filename,
String contentType,
long size,
Long storagePolicyId,
String status,
long chunkSize,
int chunkCount,
LocalDateTime expiresAt,
LocalDateTime createdAt,
LocalDateTime updatedAt,
UploadSessionV2StrategyResponse strategy
) {
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.api.v2.files;
public record UploadSessionV2StrategyResponse(
String prepareUrl,
String proxyContentUrl,
String partPrepareUrlTemplate,
String partRecordUrlTemplate,
String completeUrl,
String proxyFormField
) {
}

View File

@@ -0,0 +1,17 @@
package com.yoyuzh.api.v2.shares;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
public record CreateShareV2Request(
@NotNull Long fileId,
String password,
LocalDateTime expiresAt,
@Min(1) Integer maxDownloads,
Boolean allowImport,
Boolean allowDownload,
String shareName
) {
}

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.api.v2.shares;
import jakarta.validation.constraints.NotBlank;
public record ImportShareV2Request(
@NotBlank String path,
String password
) {
}

View File

@@ -0,0 +1,83 @@
package com.yoyuzh.api.v2.shares;
import com.yoyuzh.api.v2.ApiV2Response;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.core.FileMetadataResponse;
import com.yoyuzh.files.share.ShareV2Service;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v2/shares")
@RequiredArgsConstructor
public class ShareV2Controller {
private final ShareV2Service shareV2Service;
private final CustomUserDetailsService userDetailsService;
@PostMapping
public ApiV2Response<ShareV2Response> createShare(@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody CreateShareV2Request request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(shareV2Service.createShare(user, request));
}
@GetMapping("/{token}")
public ApiV2Response<ShareV2Response> getShare(@PathVariable String token) {
return ApiV2Response.success(shareV2Service.getShare(token));
}
@GetMapping(value = "/{token}", params = "download")
public ResponseEntity<?> downloadShare(@PathVariable String token,
@RequestParam(required = false) String password) {
return shareV2Service.downloadSharedFile(token, password);
}
@PostMapping("/{token}/verify-password")
public ApiV2Response<ShareV2Response> verifyPassword(@PathVariable String token,
@Valid @RequestBody VerifySharePasswordV2Request request) {
return ApiV2Response.success(shareV2Service.verifyPassword(token, request));
}
@PostMapping("/{token}/import")
public ApiV2Response<FileMetadataResponse> importSharedFile(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String token,
@Valid @RequestBody ImportShareV2Request request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(shareV2Service.importSharedFile(user, token, request));
}
@GetMapping("/mine")
public ApiV2Response<PageResponse<ShareV2Response>> mine(@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
var result = shareV2Service.listOwnedShares(user, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")));
return ApiV2Response.success(new PageResponse<>(result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()));
}
@DeleteMapping("/{id}")
public ApiV2Response<Void> deleteShare(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long id) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
shareV2Service.deleteOwnedShare(user, id);
return ApiV2Response.success(null);
}
}

View File

@@ -0,0 +1,23 @@
package com.yoyuzh.api.v2.shares;
import com.yoyuzh.files.core.FileMetadataResponse;
import java.time.LocalDateTime;
public record ShareV2Response(
Long id,
String token,
String shareName,
String ownerUsername,
boolean passwordRequired,
boolean passwordVerified,
boolean allowImport,
boolean allowDownload,
Integer maxDownloads,
long downloadCount,
long viewCount,
LocalDateTime expiresAt,
LocalDateTime createdAt,
FileMetadataResponse file
) {
}

View File

@@ -0,0 +1,8 @@
package com.yoyuzh.api.v2.shares;
import jakarta.validation.constraints.NotBlank;
public record VerifySharePasswordV2Request(
@NotBlank String password
) {
}

View File

@@ -0,0 +1,16 @@
package com.yoyuzh.api.v2.site;
import com.yoyuzh.api.v2.ApiV2Response;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v2/site")
public class SiteV2Controller {
@GetMapping("/ping")
public ApiV2Response<SiteV2PingResponse> ping() {
return ApiV2Response.success(new SiteV2PingResponse("ok", "v2"));
}
}

View File

@@ -0,0 +1,4 @@
package com.yoyuzh.api.v2.site;
public record SiteV2PingResponse(String status, String apiVersion) {
}

View File

@@ -0,0 +1,20 @@
package com.yoyuzh.api.v2.tasks;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import java.time.LocalDateTime;
public record BackgroundTaskResponse(
Long id,
BackgroundTaskType type,
BackgroundTaskStatus status,
Long userId,
String publicStateJson,
String correlationId,
String errorMessage,
LocalDateTime createdAt,
LocalDateTime updatedAt,
LocalDateTime finishedAt
) {
}

View File

@@ -0,0 +1,119 @@
package com.yoyuzh.api.v2.tasks;
import com.yoyuzh.api.v2.ApiV2Response;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.tasks.BackgroundTask;
import com.yoyuzh.files.tasks.BackgroundTaskService;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v2/tasks")
@RequiredArgsConstructor
public class BackgroundTaskV2Controller {
private final BackgroundTaskService backgroundTaskService;
private final CustomUserDetailsService userDetailsService;
@GetMapping
public ApiV2Response<PageResponse<BackgroundTaskResponse>> list(@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
var result = backgroundTaskService.listOwnedTasks(
user,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
);
return ApiV2Response.success(new PageResponse<>(
result.getContent().stream().map(this::toResponse).toList(),
result.getTotalElements(),
result.getNumber(),
result.getSize()
));
}
@GetMapping("/{id}")
public ApiV2Response<BackgroundTaskResponse> get(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long id) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(backgroundTaskService.getOwnedTask(user, id)));
}
@DeleteMapping("/{id}")
public ApiV2Response<BackgroundTaskResponse> cancel(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long id) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(backgroundTaskService.cancelOwnedTask(user, id)));
}
@PostMapping("/{id}/retry")
public ApiV2Response<BackgroundTaskResponse> retry(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long id) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(backgroundTaskService.retryOwnedTask(user, id)));
}
@PostMapping("/archive")
public ApiV2Response<BackgroundTaskResponse> createArchiveTask(@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody CreateBackgroundTaskRequest request) {
return ApiV2Response.success(createTask(userDetails, BackgroundTaskType.ARCHIVE, request));
}
@PostMapping("/extract")
public ApiV2Response<BackgroundTaskResponse> createExtractTask(@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody CreateBackgroundTaskRequest request) {
return ApiV2Response.success(createTask(userDetails, BackgroundTaskType.EXTRACT, request));
}
@PostMapping("/media-metadata")
public ApiV2Response<BackgroundTaskResponse> createMediaMetadataTask(@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody CreateBackgroundTaskRequest request) {
return ApiV2Response.success(createTask(userDetails, BackgroundTaskType.MEDIA_META, request));
}
private BackgroundTaskResponse createTask(UserDetails userDetails,
BackgroundTaskType type,
CreateBackgroundTaskRequest request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
BackgroundTask task = backgroundTaskService.createQueuedFileTask(
user,
type,
request.fileId(),
request.path(),
request.correlationId()
);
return toResponse(task);
}
private BackgroundTaskResponse toResponse(BackgroundTask task) {
return new BackgroundTaskResponse(
task.getId(),
task.getType(),
task.getStatus(),
task.getUserId(),
task.getPublicStateJson(),
task.getCorrelationId(),
task.getErrorMessage(),
task.getCreatedAt(),
task.getUpdatedAt(),
task.getFinishedAt()
);
}
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.api.v2.tasks;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record CreateBackgroundTaskRequest(
@NotNull Long fileId,
@NotBlank String path,
String correlationId
) {
}

View File

@@ -9,8 +9,8 @@ import com.yoyuzh.auth.dto.UpdateUserProfileRequest;
import com.yoyuzh.auth.dto.UserProfileResponse;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.files.FileService;
import com.yoyuzh.files.InitiateUploadResponse;
import com.yoyuzh.files.core.FileService;
import com.yoyuzh.files.upload.InitiateUploadResponse;
import com.yoyuzh.files.storage.FileContentStorage;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;

View File

@@ -1,7 +1,7 @@
package com.yoyuzh.auth;
import com.yoyuzh.files.FileService;
import com.yoyuzh.files.StoredFileRepository;
import com.yoyuzh.files.core.FileService;
import com.yoyuzh.files.core.StoredFileRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;

View File

@@ -52,6 +52,24 @@ public class SecurityConfig {
.permitAll()
.requestMatchers("/api/app/android/latest", "/api/app/android/download", "/api/app/android/download/*")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/v2/site/ping")
.permitAll()
.requestMatchers("/api/v2/tasks/**")
.authenticated()
.requestMatchers("/api/v2/files/**")
.authenticated()
.requestMatchers(HttpMethod.GET, "/api/v2/shares/mine")
.authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/v2/shares/*")
.authenticated()
.requestMatchers(HttpMethod.POST, "/api/v2/shares")
.authenticated()
.requestMatchers(HttpMethod.GET, "/api/v2/shares/*")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/v2/shares/*/verify-password")
.permitAll()
.requestMatchers("/api/v2/shares/**")
.authenticated()
.requestMatchers("/api/transfer/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/files/share-links/*")

View File

@@ -1,89 +0,0 @@
package com.yoyuzh.files;
import com.yoyuzh.auth.User;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_file_share_link", indexes = {
@Index(name = "uk_file_share_token", columnList = "token", unique = true),
@Index(name = "idx_file_share_created_at", columnList = "created_at")
})
public class FileShareLink {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "file_id", nullable = false)
private StoredFile file;
@Column(nullable = false, length = 96, unique = true)
private String token;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getOwner() {
return owner;
}
public void setOwner(User owner) {
this.owner = owner;
}
public StoredFile getFile() {
return file;
}
public void setFile(StoredFile file) {
this.file = file;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -1,12 +0,0 @@
package com.yoyuzh.files;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface FileShareLinkRepository extends JpaRepository<FileShareLink, Long> {
@EntityGraph(attributePaths = {"owner", "file", "file.user"})
Optional<FileShareLink> findByToken(String token);
}

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import jakarta.validation.constraints.NotBlank;

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
public record DownloadUrlResponse(String url) {
}

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import com.yoyuzh.files.storage.FileContentStorage;
import lombok.RequiredArgsConstructor;

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

View File

@@ -1,8 +1,14 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.common.ApiResponse;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.share.CreateFileShareLinkResponse;
import com.yoyuzh.files.share.FileShareDetailsResponse;
import com.yoyuzh.files.share.ImportSharedFileRequest;
import com.yoyuzh.files.upload.CompleteUploadRequest;
import com.yoyuzh.files.upload.InitiateUploadRequest;
import com.yoyuzh.files.upload.InitiateUploadResponse;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

View File

@@ -0,0 +1,163 @@
package com.yoyuzh.files.core;
import com.yoyuzh.auth.User;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_file_entity", indexes = {
@Index(name = "uk_file_entity_key_type", columnList = "object_key,entity_type", unique = true),
@Index(name = "idx_file_entity_created_at", columnList = "created_at")
})
public class FileEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "object_key", nullable = false, length = 512)
private String objectKey;
@Column(nullable = false)
private Long size;
@Column(name = "content_type", length = 255)
private String contentType;
@Enumerated(EnumType.STRING)
@Column(name = "entity_type", nullable = false, length = 32)
private FileEntityType entityType;
@Column(name = "reference_count", nullable = false)
private Integer referenceCount;
@Column(name = "storage_policy_id")
private Long storagePolicyId;
@Column(name = "upload_session_id", length = 64)
private String uploadSessionId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
@OnDelete(action = OnDeleteAction.SET_NULL)
private User createdBy;
@Column(name = "props_json", columnDefinition = "TEXT")
private String propsJson;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
if (referenceCount == null) {
referenceCount = 0;
}
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getObjectKey() {
return objectKey;
}
public void setObjectKey(String objectKey) {
this.objectKey = objectKey;
}
public Long getSize() {
return size;
}
public void setSize(Long size) {
this.size = size;
}
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
public FileEntityType getEntityType() {
return entityType;
}
public void setEntityType(FileEntityType entityType) {
this.entityType = entityType;
}
public Integer getReferenceCount() {
return referenceCount;
}
public void setReferenceCount(Integer referenceCount) {
this.referenceCount = referenceCount;
}
public Long getStoragePolicyId() {
return storagePolicyId;
}
public void setStoragePolicyId(Long storagePolicyId) {
this.storagePolicyId = storagePolicyId;
}
public String getUploadSessionId() {
return uploadSessionId;
}
public void setUploadSessionId(String uploadSessionId) {
this.uploadSessionId = uploadSessionId;
}
public User getCreatedBy() {
return createdBy;
}
public void setCreatedBy(User createdBy) {
this.createdBy = createdBy;
}
public String getPropsJson() {
return propsJson;
}
public void setPropsJson(String propsJson) {
this.propsJson = propsJson;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,67 @@
package com.yoyuzh.files.core;
import com.yoyuzh.files.policy.StoragePolicyService;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Component
@Order(1)
@RequiredArgsConstructor
public class FileEntityBackfillService implements CommandLineRunner {
static final String PRIMARY_ENTITY_ROLE = "PRIMARY";
private final StoredFileRepository storedFileRepository;
private final FileEntityRepository fileEntityRepository;
private final StoredFileEntityRepository storedFileEntityRepository;
private final StoragePolicyService storagePolicyService;
@Override
@Transactional
public void run(String... args) {
backfillPrimaryEntities();
}
@Transactional
public void backfillPrimaryEntities() {
for (StoredFile storedFile : storedFileRepository.findAllByDirectoryFalseAndBlobIsNotNullAndPrimaryEntityIsNull()) {
FileBlob blob = storedFile.getBlob();
Optional<FileEntity> existingEntity = fileEntityRepository
.findByObjectKeyAndEntityType(blob.getObjectKey(), FileEntityType.VERSION);
FileEntity fileEntity = existingEntity.orElseGet(() -> createEntity(storedFile, blob));
if (existingEntity.isPresent()) {
fileEntity.setReferenceCount(fileEntity.getReferenceCount() + 1);
fileEntityRepository.save(fileEntity);
}
storedFile.setPrimaryEntity(fileEntity);
storedFileRepository.save(storedFile);
storedFileEntityRepository.save(createRelation(storedFile, fileEntity));
}
}
private FileEntity createEntity(StoredFile storedFile, FileBlob blob) {
FileEntity fileEntity = new FileEntity();
fileEntity.setObjectKey(blob.getObjectKey());
fileEntity.setSize(blob.getSize());
fileEntity.setContentType(blob.getContentType());
fileEntity.setEntityType(FileEntityType.VERSION);
fileEntity.setReferenceCount(1);
fileEntity.setCreatedBy(storedFile.getUser());
fileEntity.setStoragePolicyId(storagePolicyService.ensureDefaultPolicy().getId());
return fileEntityRepository.save(fileEntity);
}
private StoredFileEntity createRelation(StoredFile storedFile, FileEntity fileEntity) {
StoredFileEntity relation = new StoredFileEntity();
relation.setStoredFile(storedFile);
relation.setFileEntity(fileEntity);
relation.setEntityRole(PRIMARY_ENTITY_ROLE);
return relation;
}
}

View File

@@ -0,0 +1,44 @@
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;
public interface FileEntityRepository extends JpaRepository<FileEntity, Long> {
Optional<FileEntity> findByObjectKeyAndEntityType(String objectKey, FileEntityType entityType);
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

@@ -0,0 +1,9 @@
package com.yoyuzh.files.core;
public enum FileEntityType {
VERSION,
THUMBNAIL,
LIVE_PHOTO,
TRANSCODE,
AVATAR
}

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import java.time.LocalDateTime;

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import com.yoyuzh.admin.AdminMetricsService;
import com.yoyuzh.auth.User;
@@ -6,8 +6,20 @@ import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.config.FileStorageProperties;
import com.yoyuzh.files.events.FileEventService;
import com.yoyuzh.files.events.FileEventType;
import com.yoyuzh.files.policy.StoragePolicy;
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
import com.yoyuzh.files.policy.StoragePolicyService;
import com.yoyuzh.files.share.CreateFileShareLinkResponse;
import com.yoyuzh.files.share.FileShareDetailsResponse;
import com.yoyuzh.files.share.FileShareLink;
import com.yoyuzh.files.share.FileShareLinkRepository;
import com.yoyuzh.files.storage.FileContentStorage;
import com.yoyuzh.files.storage.PreparedUpload;
import com.yoyuzh.files.upload.CompleteUploadRequest;
import com.yoyuzh.files.upload.InitiateUploadRequest;
import com.yoyuzh.files.upload.InitiateUploadResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -20,6 +32,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
@@ -41,6 +54,7 @@ import java.util.Set;
import java.util.UUID;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
@Service
@@ -51,37 +65,51 @@ public class FileService {
private final StoredFileRepository storedFileRepository;
private final FileBlobRepository fileBlobRepository;
private final FileEntityRepository fileEntityRepository;
private final StoredFileEntityRepository storedFileEntityRepository;
private final FileContentStorage fileContentStorage;
private final FileShareLinkRepository fileShareLinkRepository;
private final AdminMetricsService adminMetricsService;
private final StoragePolicyService storagePolicyService;
private final long maxFileSize;
private final String packageDownloadBaseUrl;
private final String packageDownloadSecret;
private final long packageDownloadTtlSeconds;
private final Clock clock;
@Autowired(required = false)
private FileEventService fileEventService;
@Autowired
public FileService(StoredFileRepository storedFileRepository,
FileBlobRepository fileBlobRepository,
FileEntityRepository fileEntityRepository,
StoredFileEntityRepository storedFileEntityRepository,
FileContentStorage fileContentStorage,
FileShareLinkRepository fileShareLinkRepository,
AdminMetricsService adminMetricsService,
StoragePolicyService storagePolicyService,
FileStorageProperties properties) {
this(storedFileRepository, fileBlobRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties, Clock.systemUTC());
this(storedFileRepository, fileBlobRepository, fileEntityRepository, storedFileEntityRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, storagePolicyService, properties, Clock.systemUTC());
}
FileService(StoredFileRepository storedFileRepository,
FileBlobRepository fileBlobRepository,
FileEntityRepository fileEntityRepository,
StoredFileEntityRepository storedFileEntityRepository,
FileContentStorage fileContentStorage,
FileShareLinkRepository fileShareLinkRepository,
AdminMetricsService adminMetricsService,
StoragePolicyService storagePolicyService,
FileStorageProperties properties,
Clock clock) {
this.storedFileRepository = storedFileRepository;
this.fileBlobRepository = fileBlobRepository;
this.fileEntityRepository = fileEntityRepository;
this.storedFileEntityRepository = storedFileEntityRepository;
this.fileContentStorage = fileContentStorage;
this.fileShareLinkRepository = fileShareLinkRepository;
this.adminMetricsService = adminMetricsService;
this.storagePolicyService = storagePolicyService;
this.maxFileSize = properties.getMaxFileSize();
this.packageDownloadBaseUrl = StringUtils.hasText(properties.getS3().getPackageDownloadBaseUrl())
? properties.getS3().getPackageDownloadBaseUrl().trim()
@@ -93,6 +121,25 @@ public class FileService {
this.clock = clock;
}
FileService(StoredFileRepository storedFileRepository,
FileBlobRepository fileBlobRepository,
FileContentStorage fileContentStorage,
FileShareLinkRepository fileShareLinkRepository,
AdminMetricsService adminMetricsService,
FileStorageProperties properties) {
this(storedFileRepository, fileBlobRepository, null, null, fileContentStorage, fileShareLinkRepository, adminMetricsService, null, properties, Clock.systemUTC());
}
FileService(StoredFileRepository storedFileRepository,
FileBlobRepository fileBlobRepository,
FileContentStorage fileContentStorage,
FileShareLinkRepository fileShareLinkRepository,
AdminMetricsService adminMetricsService,
FileStorageProperties properties,
Clock clock) {
this(storedFileRepository, fileBlobRepository, null, null, fileContentStorage, fileShareLinkRepository, adminMetricsService, null, properties, clock);
}
@Transactional
public FileMetadataResponse upload(User user, String path, MultipartFile multipartFile) {
String normalizedPath = normalizeDirectoryPath(path);
@@ -114,6 +161,10 @@ public class FileService {
validateUpload(user, normalizedPath, filename, request.size());
String objectKey = createBlobObjectKey();
StoragePolicyCapabilities capabilities = resolveDefaultStoragePolicyCapabilities();
if (capabilities != null && !capabilities.directUpload()) {
return new InitiateUploadResponse(false, "", "POST", Map.of(), objectKey);
}
PreparedUpload preparedUpload = fileContentStorage.prepareBlobUpload(
normalizedPath,
filename,
@@ -215,6 +266,7 @@ public class FileService {
@Transactional
public void delete(User user, Long fileId) {
StoredFile storedFile = getOwnedActiveFile(user, fileId, "删除");
String fromPath = buildLogicalPath(storedFile);
List<StoredFile> filesToRecycle = new ArrayList<>();
filesToRecycle.add(storedFile);
if (storedFile.isDirectory()) {
@@ -223,11 +275,14 @@ public class FileService {
filesToRecycle.addAll(descendants);
}
moveToRecycleBin(filesToRecycle, storedFile.getId());
recordFileEvent(user, FileEventType.DELETED, storedFile, fromPath, buildLogicalPath(storedFile));
}
@Transactional
public FileMetadataResponse restoreFromRecycleBin(User user, Long fileId) {
StoredFile recycleRoot = getOwnedRecycleRootFile(user, fileId);
String fromPath = buildLogicalPath(recycleRoot);
String toPath = buildTargetLogicalPath(requireRecycleOriginalPath(recycleRoot), recycleRoot.getFilename());
List<StoredFile> recycleGroupItems = loadRecycleGroupItems(recycleRoot);
long additionalBytes = recycleGroupItems.stream()
.filter(item -> !item.isDirectory())
@@ -245,6 +300,7 @@ public class FileService {
item.setRecycleRoot(false);
}
storedFileRepository.saveAll(recycleGroupItems);
recordFileEvent(user, FileEventType.RESTORED, recycleRoot, fromPath, toPath);
return toResponse(recycleRoot);
}
@@ -266,6 +322,7 @@ public class FileService {
@Transactional
public FileMetadataResponse rename(User user, Long fileId, String nextFilename) {
StoredFile storedFile = getOwnedActiveFile(user, fileId, "重命名");
String fromPath = buildLogicalPath(storedFile);
String sanitizedFilename = normalizeLeafName(nextFilename);
if (sanitizedFilename.equals(storedFile.getFilename())) {
return toResponse(storedFile);
@@ -295,12 +352,15 @@ public class FileService {
}
storedFile.setFilename(sanitizedFilename);
return toResponse(storedFileRepository.save(storedFile));
FileMetadataResponse response = toResponse(storedFileRepository.save(storedFile));
recordFileEvent(user, FileEventType.RENAMED, storedFile, fromPath, buildLogicalPath(storedFile));
return response;
}
@Transactional
public FileMetadataResponse move(User user, Long fileId, String nextPath) {
StoredFile storedFile = getOwnedActiveFile(user, fileId, "移动");
String fromPath = buildLogicalPath(storedFile);
String normalizedTargetPath = normalizeDirectoryPath(nextPath);
if (normalizedTargetPath.equals(storedFile.getPath())) {
return toResponse(storedFile);
@@ -335,7 +395,9 @@ public class FileService {
}
storedFile.setPath(normalizedTargetPath);
return toResponse(storedFileRepository.save(storedFile));
FileMetadataResponse response = toResponse(storedFileRepository.save(storedFile));
recordFileEvent(user, FileEventType.MOVED, storedFile, fromPath, buildLogicalPath(storedFile));
return response;
}
@Transactional
@@ -349,7 +411,7 @@ public class FileService {
if (!storedFile.isDirectory()) {
ensureWithinStorageQuota(user, storedFile.getSize());
return toResponse(storedFileRepository.save(copyStoredFile(storedFile, user, normalizedTargetPath)));
return toResponse(saveCopiedStoredFile(copyStoredFile(storedFile, user, normalizedTargetPath), user));
}
String oldLogicalPath = buildLogicalPath(storedFile);
@@ -385,7 +447,7 @@ public class FileService {
StoredFile savedRoot = null;
for (StoredFile copiedEntry : copiedEntries) {
StoredFile savedEntry = storedFileRepository.save(copiedEntry);
StoredFile savedEntry = saveCopiedStoredFile(copiedEntry, user);
if (savedRoot == null) {
savedRoot = savedEntry;
}
@@ -522,36 +584,49 @@ public class FileService {
});
}
@Transactional
public void importExternalFilesAtomically(User recipient,
List<String> directories,
List<ExternalFileImport> files) {
importExternalFilesAtomically(recipient, directories, files, null);
}
@Transactional
public void importExternalFilesAtomically(User recipient,
List<String> directories,
List<ExternalFileImport> files,
ExternalImportProgressListener progressListener) {
List<String> normalizedDirectories = normalizeExternalImportDirectories(directories);
List<ExternalFileImport> normalizedFiles = normalizeExternalImportFiles(files);
validateExternalImportBatch(recipient, normalizedDirectories, normalizedFiles);
List<String> writtenBlobObjectKeys = new ArrayList<>();
int totalDirectoryCount = normalizedDirectories.size();
int totalFileCount = normalizedFiles.size();
int processedDirectoryCount = 0;
int processedFileCount = 0;
try {
for (String directory : normalizedDirectories) {
mkdir(recipient, directory);
processedDirectoryCount += 1;
reportExternalImportProgress(progressListener, processedFileCount, totalFileCount,
processedDirectoryCount, totalDirectoryCount);
}
for (ExternalFileImport file : normalizedFiles) {
storeExternalImportFile(recipient, file, writtenBlobObjectKeys);
processedFileCount += 1;
reportExternalImportProgress(progressListener, processedFileCount, totalFileCount,
processedDirectoryCount, totalDirectoryCount);
}
} catch (RuntimeException ex) {
cleanupWrittenBlobs(writtenBlobObjectKeys, ex);
throw ex;
}
}
private ResponseEntity<byte[]> downloadDirectory(User user, StoredFile directory) {
String logicalPath = buildLogicalPath(directory);
String archiveName = directory.getFilename() + ".zip";
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), logicalPath)
.stream()
.sorted(Comparator.comparing(StoredFile::getPath).thenComparing(StoredFile::getFilename))
.toList();
byte[] archiveBytes;
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8)) {
Set<String> createdEntries = new LinkedHashSet<>();
writeDirectoryEntry(zipOutputStream, createdEntries, directory.getFilename() + "/");
for (StoredFile descendant : descendants) {
String entryName = buildZipEntryName(directory.getFilename(), logicalPath, descendant);
if (descendant.isDirectory()) {
writeDirectoryEntry(zipOutputStream, createdEntries, entryName + "/");
continue;
}
ensureParentDirectoryEntries(zipOutputStream, createdEntries, entryName);
writeFileEntry(zipOutputStream, createdEntries, entryName,
fileContentStorage.readBlob(getRequiredBlob(descendant).getObjectKey()));
}
zipOutputStream.finish();
archiveBytes = outputStream.toByteArray();
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "目录压缩失败");
}
byte[] archiveBytes = buildArchiveBytes(directory);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
@@ -560,6 +635,63 @@ public class FileService {
.body(archiveBytes);
}
public byte[] buildArchiveBytes(StoredFile source) {
return buildArchiveBytes(source, null);
}
public byte[] buildArchiveBytes(StoredFile source, ArchiveBuildProgressListener progressListener) {
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8)) {
Set<String> createdEntries = new LinkedHashSet<>();
ArchiveBuildProgressState progressState = createArchiveBuildProgressState(source, progressListener);
reportArchiveProgress(progressState);
if (source.isDirectory()) {
writeDirectoryArchiveEntries(zipOutputStream, createdEntries, source, progressState);
} else {
writeFileArchiveEntry(zipOutputStream, createdEntries, source.getFilename(), source, progressState);
}
zipOutputStream.finish();
return outputStream.toByteArray();
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "目录压缩失败");
}
}
public ZipCompatibleArchive readZipCompatibleArchive(StoredFile source) {
byte[] archiveBytes = fileContentStorage.readBlob(getRequiredBlob(source).getObjectKey());
try (ZipInputStream zipInputStream = new ZipInputStream(
new ByteArrayInputStream(archiveBytes),
StandardCharsets.UTF_8)) {
List<ZipCompatibleArchiveEntry> entries = new ArrayList<>();
Map<String, Boolean> seenEntries = new HashMap<>();
ZipEntry entry = zipInputStream.getNextEntry();
while (entry != null) {
String relativePath = normalizeZipCompatibleEntryPath(entry.getName());
if (StringUtils.hasText(relativePath)) {
boolean directory = entry.isDirectory() || entry.getName().endsWith("/");
Boolean existingType = seenEntries.putIfAbsent(relativePath, directory);
if (existingType != null) {
throw new BusinessException(ErrorCode.UNKNOWN, "压缩包内容不合法");
}
entries.add(new ZipCompatibleArchiveEntry(
relativePath,
directory,
directory ? new byte[0] : zipInputStream.readAllBytes()
));
}
entry = zipInputStream.getNextEntry();
}
if (entries.isEmpty() && !hasZipCompatibleSignature(archiveBytes)) {
throw new BusinessException(ErrorCode.UNKNOWN, "压缩包读取失败");
}
return new ZipCompatibleArchive(entries, detectCommonRootDirectoryName(entries));
} catch (BusinessException ex) {
throw ex;
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "压缩包读取失败");
}
}
private boolean shouldUsePublicPackageDownload(StoredFile storedFile) {
return fileContentStorage.supportsDirectDownload()
&& StringUtils.hasText(packageDownloadBaseUrl)
@@ -685,7 +817,68 @@ public class FileService {
storedFile.setSize(size);
storedFile.setDirectory(false);
storedFile.setBlob(blob);
return toResponse(storedFileRepository.save(storedFile));
FileEntity primaryEntity = createOrReferencePrimaryEntity(user, blob);
storedFile.setPrimaryEntity(primaryEntity);
StoredFile savedFile = storedFileRepository.save(storedFile);
savePrimaryEntityRelation(savedFile, primaryEntity);
recordFileEvent(user, FileEventType.CREATED, savedFile, null, buildLogicalPath(savedFile));
return toResponse(savedFile);
}
private FileEntity createOrReferencePrimaryEntity(User user, FileBlob blob) {
if (fileEntityRepository == null) {
return createTransientPrimaryEntity(user, blob);
}
Optional<FileEntity> existingEntity = fileEntityRepository.findByObjectKeyAndEntityType(
blob.getObjectKey(),
FileEntityType.VERSION
);
if (existingEntity.isPresent()) {
FileEntity entity = existingEntity.get();
entity.setReferenceCount(entity.getReferenceCount() + 1);
return fileEntityRepository.save(entity);
}
return fileEntityRepository.save(createTransientPrimaryEntity(user, blob));
}
private FileEntity createTransientPrimaryEntity(User user, FileBlob blob) {
FileEntity entity = new FileEntity();
entity.setObjectKey(blob.getObjectKey());
entity.setContentType(blob.getContentType());
entity.setSize(blob.getSize());
entity.setEntityType(FileEntityType.VERSION);
entity.setReferenceCount(1);
entity.setCreatedBy(user);
entity.setStoragePolicyId(resolveDefaultStoragePolicyId());
return entity;
}
private Long resolveDefaultStoragePolicyId() {
if (storagePolicyService == null) {
return null;
}
return storagePolicyService.ensureDefaultPolicy().getId();
}
private StoragePolicyCapabilities resolveDefaultStoragePolicyCapabilities() {
if (storagePolicyService == null) {
return null;
}
return storagePolicyService.readCapabilities(storagePolicyService.ensureDefaultPolicy());
}
private void savePrimaryEntityRelation(StoredFile storedFile, FileEntity primaryEntity) {
if (storedFileEntityRepository == null) {
return;
}
StoredFileEntity relation = new StoredFileEntity();
relation.setStoredFile(storedFile);
relation.setFileEntity(primaryEntity);
relation.setEntityRole("PRIMARY");
storedFileEntityRepository.save(relation);
}
private FileShareLink getShareLink(String token) {
@@ -747,6 +940,14 @@ public class FileService {
private void validateUpload(User user, String normalizedPath, String filename, long size) {
long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes());
StoragePolicy defaultPolicy = storagePolicyService == null ? null : storagePolicyService.ensureDefaultPolicy();
StoragePolicyCapabilities capabilities = defaultPolicy == null ? null : storagePolicyService.readCapabilities(defaultPolicy);
if (defaultPolicy != null && defaultPolicy.getMaxSizeBytes() > 0) {
effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, defaultPolicy.getMaxSizeBytes());
}
if (capabilities != null && capabilities.maxObjectSize() > 0) {
effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, capabilities.maxObjectSize());
}
if (size > effectiveMaxUploadSize) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制");
}
@@ -756,6 +957,62 @@ public class FileService {
ensureWithinStorageQuota(user, size);
}
private List<String> normalizeExternalImportDirectories(List<String> directories) {
if (directories == null || directories.isEmpty()) {
return List.of();
}
return directories.stream()
.map(this::normalizeDirectoryPath)
.distinct()
.sorted(Comparator.comparingInt(String::length).thenComparing(String::compareTo))
.toList();
}
private List<ExternalFileImport> normalizeExternalImportFiles(List<ExternalFileImport> files) {
if (files == null || files.isEmpty()) {
return List.of();
}
return files.stream()
.map(file -> new ExternalFileImport(
normalizeDirectoryPath(file.path()),
normalizeLeafName(file.filename()),
StringUtils.hasText(file.contentType()) ? file.contentType().trim() : "application/octet-stream",
file.content() == null ? new byte[0] : file.content()
))
.toList();
}
private void validateExternalImportBatch(User recipient,
List<String> directories,
List<ExternalFileImport> files) {
ensureWithinStorageQuota(recipient, files.stream().mapToLong(ExternalFileImport::size).sum());
Set<String> plannedTargets = new LinkedHashSet<>();
for (String directory : directories) {
if ("/".equals(directory)) {
continue;
}
if (!plannedTargets.add(directory)) {
continue;
}
String parentPath = extractParentPath(directory);
String directoryName = extractLeafName(directory);
if (storedFileRepository.existsByUserIdAndPathAndFilename(recipient.getId(), parentPath, directoryName)) {
throw new BusinessException(ErrorCode.UNKNOWN, "解压目标已存在");
}
}
for (ExternalFileImport file : files) {
String logicalPath = buildTargetLogicalPath(file.path(), file.filename());
if (plannedTargets.contains(logicalPath) || !plannedTargets.add(logicalPath)) {
throw new BusinessException(ErrorCode.UNKNOWN, "解压目标已存在");
}
if (storedFileRepository.existsByUserIdAndPathAndFilename(recipient.getId(), file.path(), file.filename())) {
throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在");
}
}
}
private void ensureWithinStorageQuota(User user, long additionalBytes) {
if (additionalBytes <= 0) {
return;
@@ -802,6 +1059,25 @@ public class FileService {
}
}
private void storeExternalImportFile(User recipient,
ExternalFileImport file,
List<String> writtenBlobObjectKeys) {
validateUpload(recipient, file.path(), file.filename(), file.size());
ensureDirectoryHierarchy(recipient, file.path());
String objectKey = createBlobObjectKey();
writtenBlobObjectKeys.add(objectKey);
fileContentStorage.storeBlob(objectKey, file.contentType(), file.content());
FileBlob blob = createAndSaveBlob(objectKey, file.contentType(), file.size());
saveFileMetadata(
recipient,
file.path(),
file.filename(),
file.contentType(),
file.size(),
blob
);
}
private void moveToRecycleBin(List<StoredFile> filesToRecycle, Long recycleRootId) {
if (filesToRecycle.isEmpty()) {
return;
@@ -961,6 +1237,49 @@ public class FileService {
return copiedFile;
}
private StoredFile saveCopiedStoredFile(StoredFile copiedFile, User owner) {
if (!copiedFile.isDirectory() && copiedFile.getBlob() != null && copiedFile.getPrimaryEntity() == null) {
copiedFile.setPrimaryEntity(createOrReferencePrimaryEntity(owner, copiedFile.getBlob()));
}
StoredFile savedFile = storedFileRepository.save(copiedFile);
if (!savedFile.isDirectory() && savedFile.getPrimaryEntity() != null) {
savePrimaryEntityRelation(savedFile, savedFile.getPrimaryEntity());
}
recordFileEvent(owner, FileEventType.CREATED, savedFile, null, buildLogicalPath(savedFile));
return savedFile;
}
private void writeDirectoryArchiveEntries(ZipOutputStream zipOutputStream,
Set<String> createdEntries,
StoredFile directory,
ArchiveBuildProgressState progressState) throws IOException {
String logicalPath = buildLogicalPath(directory);
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(directory.getUser().getId(), logicalPath)
.stream()
.sorted(Comparator.comparing(StoredFile::getPath).thenComparing(StoredFile::getFilename))
.toList();
writeDirectoryEntry(zipOutputStream, createdEntries, directory.getFilename() + "/", progressState);
for (StoredFile descendant : descendants) {
String entryName = buildZipEntryName(directory.getFilename(), logicalPath, descendant);
if (descendant.isDirectory()) {
writeDirectoryEntry(zipOutputStream, createdEntries, entryName + "/", progressState);
continue;
}
writeFileArchiveEntry(zipOutputStream, createdEntries, entryName, descendant, progressState);
}
}
private void writeFileArchiveEntry(ZipOutputStream zipOutputStream,
Set<String> createdEntries,
String entryName,
StoredFile file,
ArchiveBuildProgressState progressState) throws IOException {
ensureParentDirectoryEntries(zipOutputStream, createdEntries, entryName, progressState);
writeFileEntry(zipOutputStream, createdEntries, entryName, progressState,
fileContentStorage.readBlob(getRequiredBlob(file).getObjectKey()));
}
private String buildZipEntryName(String rootDirectoryName, String rootLogicalPath, StoredFile storedFile) {
StringBuilder entryName = new StringBuilder(rootDirectoryName).append('/');
if (!storedFile.getPath().equals(rootLogicalPath)) {
@@ -970,24 +1289,153 @@ public class FileService {
return entryName.toString();
}
private void ensureParentDirectoryEntries(ZipOutputStream zipOutputStream, Set<String> createdEntries, String entryName) throws IOException {
private String normalizeZipCompatibleEntryPath(String entryName) {
String normalized = entryName == null ? "" : entryName.trim().replace("\\", "/");
if (!StringUtils.hasText(normalized)) {
return "";
}
if (normalized.startsWith("/")) {
throw new BusinessException(ErrorCode.UNKNOWN, "压缩包内容不合法");
}
while (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
if (!StringUtils.hasText(normalized)) {
return "";
}
StringBuilder sanitized = new StringBuilder();
for (String segment : normalized.split("/")) {
if (!StringUtils.hasText(segment) || ".".equals(segment) || "..".equals(segment)) {
throw new BusinessException(ErrorCode.UNKNOWN, "压缩包内容不合法");
}
if (sanitized.length() > 0) {
sanitized.append('/');
}
sanitized.append(normalizeLeafName(segment));
}
return sanitized.toString();
}
private String detectCommonRootDirectoryName(List<ZipCompatibleArchiveEntry> entries) {
String candidate = null;
boolean hasNestedEntry = false;
boolean hasDirectoryCandidate = false;
for (ZipCompatibleArchiveEntry entry : entries) {
String relativePath = entry.relativePath();
int slashIndex = relativePath.indexOf('/');
String topSegment = slashIndex >= 0 ? relativePath.substring(0, slashIndex) : relativePath;
if (candidate == null) {
candidate = topSegment;
} else if (!candidate.equals(topSegment)) {
return null;
}
if (slashIndex >= 0) {
hasNestedEntry = true;
}
if (entry.directory() && candidate.equals(relativePath)) {
hasDirectoryCandidate = true;
}
if (!entry.directory() && candidate.equals(relativePath)) {
return null;
}
}
if (!hasNestedEntry && !hasDirectoryCandidate) {
return null;
}
return candidate;
}
private boolean hasZipCompatibleSignature(byte[] archiveBytes) {
if (archiveBytes == null || archiveBytes.length < 4) {
return false;
}
return archiveBytes[0] == 'P'
&& archiveBytes[1] == 'K'
&& (archiveBytes[2] == 3 || archiveBytes[2] == 5 || archiveBytes[2] == 7)
&& (archiveBytes[3] == 4 || archiveBytes[3] == 6 || archiveBytes[3] == 8);
}
public ArchiveSourceSummary summarizeArchiveSource(StoredFile source) {
if (!source.isDirectory()) {
return new ArchiveSourceSummary(1, 0);
}
String logicalPath = buildLogicalPath(source);
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(source.getUser().getId(), logicalPath);
int directoryCount = 1 + (int) descendants.stream().filter(StoredFile::isDirectory).count();
int fileCount = (int) descendants.stream().filter(file -> !file.isDirectory()).count();
return new ArchiveSourceSummary(fileCount, directoryCount);
}
private ArchiveBuildProgressState createArchiveBuildProgressState(StoredFile source,
ArchiveBuildProgressListener progressListener) {
if (progressListener == null) {
return null;
}
ArchiveSourceSummary summary = summarizeArchiveSource(source);
return new ArchiveBuildProgressState(progressListener, summary.fileCount(), summary.directoryCount());
}
private void reportArchiveProgress(ArchiveBuildProgressState progressState) {
if (progressState == null) {
return;
}
progressState.listener.onProgress(new ArchiveBuildProgress(
progressState.processedFileCount,
progressState.totalFileCount,
progressState.processedDirectoryCount,
progressState.totalDirectoryCount
));
}
private void reportExternalImportProgress(ExternalImportProgressListener progressListener,
int processedFileCount,
int totalFileCount,
int processedDirectoryCount,
int totalDirectoryCount) {
if (progressListener == null) {
return;
}
progressListener.onProgress(new ExternalImportProgress(
processedFileCount,
totalFileCount,
processedDirectoryCount,
totalDirectoryCount
));
}
private void ensureParentDirectoryEntries(ZipOutputStream zipOutputStream,
Set<String> createdEntries,
String entryName,
ArchiveBuildProgressState progressState) throws IOException {
int slashIndex = entryName.indexOf('/');
while (slashIndex >= 0) {
writeDirectoryEntry(zipOutputStream, createdEntries, entryName.substring(0, slashIndex + 1));
writeDirectoryEntry(zipOutputStream, createdEntries, entryName.substring(0, slashIndex + 1), progressState);
slashIndex = entryName.indexOf('/', slashIndex + 1);
}
}
private void writeDirectoryEntry(ZipOutputStream zipOutputStream, Set<String> createdEntries, String entryName) throws IOException {
private void writeDirectoryEntry(ZipOutputStream zipOutputStream,
Set<String> createdEntries,
String entryName,
ArchiveBuildProgressState progressState) throws IOException {
if (!createdEntries.add(entryName)) {
return;
}
zipOutputStream.putNextEntry(new ZipEntry(entryName));
zipOutputStream.closeEntry();
if (progressState != null) {
progressState.processedDirectoryCount += 1;
reportArchiveProgress(progressState);
}
}
private void writeFileEntry(ZipOutputStream zipOutputStream, Set<String> createdEntries, String entryName, byte[] content)
private void writeFileEntry(ZipOutputStream zipOutputStream,
Set<String> createdEntries,
String entryName,
ArchiveBuildProgressState progressState,
byte[] content)
throws IOException {
if (!createdEntries.add(entryName)) {
return;
@@ -996,6 +1444,36 @@ public class FileService {
zipOutputStream.putNextEntry(new ZipEntry(entryName));
zipOutputStream.write(content);
zipOutputStream.closeEntry();
if (progressState != null) {
progressState.processedFileCount += 1;
reportArchiveProgress(progressState);
}
}
private void recordFileEvent(User user,
FileEventType eventType,
StoredFile storedFile,
String fromPath,
String toPath) {
if (fileEventService == null || storedFile == null || storedFile.getId() == null) {
return;
}
Map<String, Object> payload = new HashMap<>();
payload.put("action", eventType.name());
payload.put("fileId", storedFile.getId());
payload.put("filename", storedFile.getFilename());
payload.put("path", storedFile.getPath());
payload.put("directory", storedFile.isDirectory());
payload.put("contentType", storedFile.getContentType());
payload.put("size", storedFile.getSize());
if (fromPath != null) {
payload.put("fromPath", fromPath);
}
if (toPath != null) {
payload.put("toPath", toPath);
}
fileEventService.record(user, eventType, storedFile.getId(), fromPath, toPath, payload);
}
private String normalizeLeafName(String filename) {
@@ -1034,6 +1512,16 @@ public class FileService {
}
}
private void cleanupWrittenBlobs(List<String> writtenBlobObjectKeys, RuntimeException ex) {
for (String objectKey : writtenBlobObjectKeys) {
try {
fileContentStorage.deleteBlob(objectKey);
} catch (RuntimeException cleanupEx) {
ex.addSuppressed(cleanupEx);
}
}
}
private FileBlob createAndSaveBlob(String objectKey, String contentType, long size) {
FileBlob blob = new FileBlob();
blob.setObjectKey(objectKey);
@@ -1112,4 +1600,57 @@ public class FileService {
private interface BlobWriteOperation<T> {
T run();
}
public static record ZipCompatibleArchive(List<ZipCompatibleArchiveEntry> entries, String commonRootDirectoryName) {
}
public static record ZipCompatibleArchiveEntry(String relativePath, boolean directory, byte[] content) {
}
public static record ExternalFileImport(String path, String filename, String contentType, byte[] content) {
public long size() {
return content == null ? 0L : content.length;
}
}
public record ArchiveSourceSummary(int fileCount, int directoryCount) {
}
public record ArchiveBuildProgress(int processedFileCount,
int totalFileCount,
int processedDirectoryCount,
int totalDirectoryCount) {
}
@FunctionalInterface
public interface ArchiveBuildProgressListener {
void onProgress(ArchiveBuildProgress progress);
}
public record ExternalImportProgress(int processedFileCount,
int totalFileCount,
int processedDirectoryCount,
int totalDirectoryCount) {
}
@FunctionalInterface
public interface ExternalImportProgressListener {
void onProgress(ExternalImportProgress progress);
}
private static final class ArchiveBuildProgressState {
private final ArchiveBuildProgressListener listener;
private final int totalFileCount;
private final int totalDirectoryCount;
private int processedFileCount;
private int processedDirectoryCount;
private ArchiveBuildProgressState(ArchiveBuildProgressListener listener,
int totalFileCount,
int totalDirectoryCount) {
this.listener = listener;
this.totalFileCount = totalFileCount;
this.totalDirectoryCount = totalDirectoryCount;
}
}
}

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import jakarta.validation.constraints.NotBlank;

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import jakarta.validation.constraints.NotBlank;

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import java.time.LocalDateTime;

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import jakarta.validation.constraints.NotBlank;

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import com.yoyuzh.auth.User;
import jakarta.persistence.Column;
@@ -11,6 +11,7 @@ import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@@ -42,6 +43,10 @@ public class StoredFile {
@JoinColumn(name = "blob_id")
private FileBlob blob;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "primary_entity_id")
private FileEntity primaryEntity;
@Column(name = "storage_name", length = 255)
private String legacyStorageName;
@@ -57,6 +62,9 @@ public class StoredFile {
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@@ -74,6 +82,14 @@ public class StoredFile {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
if (updatedAt == null) {
updatedAt = createdAt;
}
}
@PreUpdate
public void preUpdate() {
updatedAt = LocalDateTime.now();
}
public Long getId() {
@@ -116,6 +132,14 @@ public class StoredFile {
this.blob = blob;
}
public FileEntity getPrimaryEntity() {
return primaryEntity;
}
public void setPrimaryEntity(FileEntity primaryEntity) {
this.primaryEntity = primaryEntity;
}
public String getLegacyStorageName() {
return legacyStorageName;
}
@@ -156,6 +180,14 @@ public class StoredFile {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public LocalDateTime getDeletedAt() {
return deletedAt;
}

View File

@@ -0,0 +1,92 @@
package com.yoyuzh.files.core;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_stored_file_entity", indexes = {
@Index(name = "uk_stored_file_entity_role", columnList = "stored_file_id,file_entity_id,entity_role", unique = true),
@Index(name = "idx_stored_file_entity_file", columnList = "stored_file_id"),
@Index(name = "idx_stored_file_entity_entity", columnList = "file_entity_id")
})
public class StoredFileEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "stored_file_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private StoredFile storedFile;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "file_entity_id", nullable = false)
private FileEntity fileEntity;
@Column(name = "entity_role", nullable = false, length = 32)
private String entityRole;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public StoredFile getStoredFile() {
return storedFile;
}
public void setStoredFile(StoredFile storedFile) {
this.storedFile = storedFile;
}
public FileEntity getFileEntity() {
return fileEntity;
}
public void setFileEntity(FileEntity fileEntity) {
this.fileEntity = fileEntity;
}
public String getEntityRole() {
return entityRole;
}
public void setEntityRole(String entityRole) {
this.entityRole = entityRole;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,44 @@
package com.yoyuzh.files.core;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface StoredFileEntityRepository extends JpaRepository<StoredFileEntity, Long> {
@Query("""
select count(distinct relation.storedFile.id)
from StoredFileEntity relation
where relation.fileEntity.storagePolicyId = :storagePolicyId
and relation.fileEntity.entityType = :entityType
""")
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

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -7,6 +7,7 @@ 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.List;
import java.util.Optional;
@@ -58,6 +59,35 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
@Param("path") String path,
Pageable pageable);
@EntityGraph(attributePaths = "blob")
@Query("""
select f from StoredFile f
where f.user.id = :userId
and f.deletedAt is null
and (:name is null or :name = '' or lower(f.filename) like lower(concat('%', :name, '%')))
and (:directory is null or f.directory = :directory)
and (:sizeGte is null or f.size >= :sizeGte)
and (:sizeLte is null or f.size <= :sizeLte)
and (:createdGte is null or f.createdAt >= :createdGte)
and (:createdLte is null or f.createdAt <= :createdLte)
and (:updatedGte is null or coalesce(f.updatedAt, f.createdAt) >= :updatedGte)
and (:updatedLte is null or coalesce(f.updatedAt, f.createdAt) <= :updatedLte)
order by f.directory desc, coalesce(f.updatedAt, f.createdAt) desc, f.createdAt desc
""")
Page<StoredFile> searchUserFiles(@Param("userId") Long userId,
@Param("name") String name,
@Param("directory") Boolean directory,
@Param("sizeGte") Long sizeGte,
@Param("sizeLte") Long sizeLte,
@Param("createdGte") LocalDateTime createdGte,
@Param("createdLte") LocalDateTime createdLte,
@Param("updatedGte") LocalDateTime updatedGte,
@Param("updatedLte") LocalDateTime updatedLte,
Pageable pageable);
@EntityGraph(attributePaths = {"user", "blob"})
Optional<StoredFile> findByIdAndUserIdAndDeletedAtIsNull(Long id, Long userId);
@EntityGraph(attributePaths = "blob")
@Query("""
select f from StoredFile f
@@ -123,4 +153,7 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
Optional<StoredFile> findDetailedById(@Param("id") Long id);
List<StoredFile> findAllByDirectoryFalseAndBlobIsNull();
@EntityGraph(attributePaths = {"user", "blob"})
List<StoredFile> findAllByDirectoryFalseAndBlobIsNotNullAndPrimaryEntityIsNull();
}

View File

@@ -0,0 +1,131 @@
package com.yoyuzh.files.events;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_file_event", indexes = {
@Index(name = "idx_file_event_user_created_at", columnList = "user_id,created_at"),
@Index(name = "idx_file_event_file_id", columnList = "file_id"),
@Index(name = "idx_file_event_created_at", columnList = "created_at")
})
public class FileEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Enumerated(EnumType.STRING)
@Column(name = "event_type", nullable = false, length = 32)
private FileEventType eventType;
@Column(name = "file_id")
private Long fileId;
@Column(name = "from_path", length = 512)
private String fromPath;
@Column(name = "to_path", length = 512)
private String toPath;
@Column(name = "client_id", length = 128)
private String clientId;
@Column(name = "payload_json", nullable = false, length = 8192)
private String payloadJson;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public FileEventType getEventType() {
return eventType;
}
public void setEventType(FileEventType eventType) {
this.eventType = eventType;
}
public Long getFileId() {
return fileId;
}
public void setFileId(Long fileId) {
this.fileId = fileId;
}
public String getFromPath() {
return fromPath;
}
public void setFromPath(String fromPath) {
this.fromPath = fromPath;
}
public String getToPath() {
return toPath;
}
public void setToPath(String toPath) {
this.toPath = toPath;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getPayloadJson() {
return payloadJson;
}
public void setPayloadJson(String payloadJson) {
this.payloadJson = payloadJson;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.files.events;
import org.springframework.data.jpa.repository.JpaRepository;
public interface FileEventRepository extends JpaRepository<FileEvent, Long> {
}

View File

@@ -0,0 +1,240 @@
package com.yoyuzh.files.events;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.auth.User;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class FileEventService {
private static final String CLIENT_ID_HEADER = "X-Yoyuzh-Client-Id";
private static final String READY_EVENT_NAME = "READY";
private final FileEventRepository fileEventRepository;
private final ObjectMapper objectMapper;
private final ConcurrentHashMap<Long, Set<Subscription>> subscriptions = new ConcurrentHashMap<>();
public FileEventService(FileEventRepository fileEventRepository, ObjectMapper objectMapper) {
this.fileEventRepository = fileEventRepository;
this.objectMapper = objectMapper;
}
public SseEmitter openStream(User user, String path, String clientId) {
String normalizedPath = normalizePath(path);
SseEmitter emitter = createEmitter();
Subscription subscription = new Subscription(emitter, normalizedPath, normalizeClientId(clientId));
subscriptions.computeIfAbsent(user.getId(), ignored -> ConcurrentHashMap.newKeySet()).add(subscription);
emitter.onCompletion(() -> removeSubscription(user.getId(), subscription));
emitter.onTimeout(() -> removeSubscription(user.getId(), subscription));
emitter.onError(ex -> removeSubscription(user.getId(), subscription));
try {
emitter.send(SseEmitter.event()
.name(READY_EVENT_NAME)
.data(createReadyPayload(normalizedPath, subscription.clientId)));
} catch (IOException ex) {
removeSubscription(user.getId(), subscription);
throw new IllegalStateException("Failed to initialize file event stream", ex);
}
return emitter;
}
public FileEvent record(User user,
FileEventType eventType,
Long fileId,
String fromPath,
String toPath,
String clientId,
Map<String, Object> payload) {
FileEvent event = new FileEvent();
event.setUserId(user.getId());
event.setEventType(eventType);
event.setFileId(fileId);
event.setFromPath(fromPath);
event.setToPath(toPath);
event.setClientId(resolveClientId(clientId));
event.setPayloadJson(toJson(payload));
fileEventRepository.save(event);
broadcast(event);
return event;
}
public FileEvent record(User user,
FileEventType eventType,
Long fileId,
String fromPath,
String toPath,
Map<String, Object> payload) {
return record(user, eventType, fileId, fromPath, toPath, null, payload);
}
protected SseEmitter createEmitter() {
return new SseEmitter();
}
private void broadcast(FileEvent event) {
Runnable broadcastTask = () -> {
Set<Subscription> userSubscriptions = subscriptions.get(event.getUserId());
if (userSubscriptions == null || userSubscriptions.isEmpty()) {
return;
}
for (Subscription subscription : userSubscriptions.toArray(new Subscription[0])) {
if (!subscription.matches(event)) {
continue;
}
try {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("eventType", event.getEventType().name());
payload.put("fileId", event.getFileId());
payload.put("fromPath", event.getFromPath());
payload.put("toPath", event.getToPath());
payload.put("clientId", event.getClientId());
payload.put("createdAt", event.getCreatedAt());
payload.put("payload", event.getPayloadJson());
subscription.emitter.send(SseEmitter.event()
.name(event.getEventType().name())
.data(payload));
} catch (IOException | IllegalStateException ex) {
removeSubscription(event.getUserId(), subscription);
}
}
};
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
broadcastTask.run();
}
});
return;
}
broadcastTask.run();
}
private void removeSubscription(Long userId, Subscription subscription) {
Set<Subscription> userSubscriptions = subscriptions.get(userId);
if (userSubscriptions == null) {
return;
}
userSubscriptions.remove(subscription);
if (userSubscriptions.isEmpty()) {
subscriptions.remove(userId, userSubscriptions);
}
}
private String toJson(Map<String, Object> payload) {
Map<String, Object> safePayload = payload == null ? new LinkedHashMap<>() : new LinkedHashMap<>(payload);
if (!safePayload.containsKey("createdAt")) {
safePayload.put("createdAt", LocalDateTime.now());
}
try {
return objectMapper.writeValueAsString(safePayload);
} catch (JsonProcessingException ex) {
throw new IllegalStateException("Failed to serialize file event payload", ex);
}
}
private String resolveClientId(String explicitClientId) {
if (StringUtils.hasText(explicitClientId)) {
return normalizeClientId(explicitClientId);
}
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return null;
}
HttpServletRequest request = attributes.getRequest();
return normalizeClientId(request.getHeader(CLIENT_ID_HEADER));
}
private String normalizeClientId(String clientId) {
if (!StringUtils.hasText(clientId)) {
return null;
}
String cleaned = clientId.trim();
return cleaned.isEmpty() ? null : cleaned;
}
private String normalizePath(String path) {
if (!StringUtils.hasText(path)) {
return "/";
}
String cleaned = path.trim().replace("\\", "/");
while (cleaned.contains("//")) {
cleaned = cleaned.replace("//", "/");
}
if (!cleaned.startsWith("/")) {
cleaned = "/" + cleaned;
}
if (cleaned.length() > 1 && cleaned.endsWith("/")) {
cleaned = cleaned.substring(0, cleaned.length() - 1);
}
return cleaned;
}
private boolean isPathMatch(String filterPath, String eventPath) {
if (!StringUtils.hasText(filterPath) || "/".equals(filterPath)) {
return true;
}
if (!StringUtils.hasText(eventPath)) {
return false;
}
return Objects.equals(filterPath, eventPath) || eventPath.startsWith(filterPath + "/");
}
private Map<String, Object> createReadyPayload(String path, String clientId) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("eventType", READY_EVENT_NAME);
payload.put("path", path);
payload.put("clientId", clientId);
payload.put("createdAt", LocalDateTime.now());
return payload;
}
private final class Subscription {
private final SseEmitter emitter;
private final String path;
private final String clientId;
private Subscription(SseEmitter emitter, String path, String clientId) {
this.emitter = emitter;
this.path = path;
this.clientId = clientId;
}
private boolean matches(FileEvent event) {
boolean pathMatches;
if (event.getFromPath() != null && event.getToPath() != null) {
pathMatches = FileEventService.this.isPathMatch(path, event.getFromPath())
|| FileEventService.this.isPathMatch(path, event.getToPath());
} else {
String eventPath = event.getToPath() != null ? event.getToPath() : event.getFromPath();
pathMatches = FileEventService.this.isPathMatch(path, eventPath);
}
if (!pathMatches) {
return false;
}
return clientId == null || event.getClientId() == null || !clientId.equals(event.getClientId());
}
}
}

View File

@@ -0,0 +1,10 @@
package com.yoyuzh.files.events;
public enum FileEventType {
CREATED,
UPDATED,
RENAMED,
MOVED,
DELETED,
RESTORED
}

View File

@@ -0,0 +1,207 @@
package com.yoyuzh.files.policy;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_storage_policy", indexes = {
@Index(name = "idx_storage_policy_enabled", columnList = "enabled"),
@Index(name = "idx_storage_policy_default", columnList = "default_policy")
})
public class StoragePolicy {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 128)
private String name;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private StoragePolicyType type;
@Column(name = "bucket_name", length = 255)
private String bucketName;
@Column(length = 512)
private String endpoint;
@Column(length = 64)
private String region;
@Column(name = "private_bucket", nullable = false)
private boolean privateBucket;
@Column(length = 512)
private String prefix;
@Enumerated(EnumType.STRING)
@Column(name = "credential_mode", nullable = false, length = 32)
private StoragePolicyCredentialMode credentialMode;
@Column(name = "max_size_bytes", nullable = false)
private long maxSizeBytes;
@Column(name = "capabilities_json", columnDefinition = "TEXT")
private String capabilitiesJson;
@Column(nullable = false)
private boolean enabled;
@Column(name = "default_policy", nullable = false)
private boolean defaultPolicy;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
}
@PreUpdate
public void preUpdate() {
updatedAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public StoragePolicyType getType() {
return type;
}
public void setType(StoragePolicyType type) {
this.type = type;
}
public String getBucketName() {
return bucketName;
}
public void setBucketName(String bucketName) {
this.bucketName = bucketName;
}
public String getEndpoint() {
return endpoint;
}
public void setEndpoint(String endpoint) {
this.endpoint = endpoint;
}
public String getRegion() {
return region;
}
public void setRegion(String region) {
this.region = region;
}
public boolean isPrivateBucket() {
return privateBucket;
}
public void setPrivateBucket(boolean privateBucket) {
this.privateBucket = privateBucket;
}
public String getPrefix() {
return prefix;
}
public void setPrefix(String prefix) {
this.prefix = prefix;
}
public StoragePolicyCredentialMode getCredentialMode() {
return credentialMode;
}
public void setCredentialMode(StoragePolicyCredentialMode credentialMode) {
this.credentialMode = credentialMode;
}
public long getMaxSizeBytes() {
return maxSizeBytes;
}
public void setMaxSizeBytes(long maxSizeBytes) {
this.maxSizeBytes = maxSizeBytes;
}
public String getCapabilitiesJson() {
return capabilitiesJson;
}
public void setCapabilitiesJson(String capabilitiesJson) {
this.capabilitiesJson = capabilitiesJson;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public boolean isDefaultPolicy() {
return defaultPolicy;
}
public void setDefaultPolicy(boolean defaultPolicy) {
this.defaultPolicy = defaultPolicy;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -0,0 +1,14 @@
package com.yoyuzh.files.policy;
public record StoragePolicyCapabilities(
boolean directUpload,
boolean multipartUpload,
boolean signedDownloadUrl,
boolean serverProxyDownload,
boolean thumbnailNative,
boolean friendlyDownloadName,
boolean requiresCors,
boolean supportsInternalEndpoint,
long maxObjectSize
) {
}

View File

@@ -0,0 +1,7 @@
package com.yoyuzh.files.policy;
public enum StoragePolicyCredentialMode {
NONE,
STATIC,
DOGECLOUD_TEMP
}

View File

@@ -0,0 +1,10 @@
package com.yoyuzh.files.policy;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface StoragePolicyRepository extends JpaRepository<StoragePolicy, Long> {
Optional<StoragePolicy> findFirstByDefaultPolicyTrueOrderByIdAsc();
}

View File

@@ -0,0 +1,128 @@
package com.yoyuzh.files.policy;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.config.FileStorageProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@Service
@Order(-1)
@RequiredArgsConstructor
public class StoragePolicyService implements CommandLineRunner {
private final StoragePolicyRepository storagePolicyRepository;
private final FileStorageProperties properties;
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
@Transactional
public void run(String... args) {
ensureDefaultPolicy();
}
@Transactional
public StoragePolicy ensureDefaultPolicy() {
return storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc()
.orElseGet(() -> storagePolicyRepository.save(createDefaultPolicy()));
}
public StoragePolicyCapabilities readCapabilities(StoragePolicy policy) {
try {
return objectMapper.readValue(policy.getCapabilitiesJson(), StoragePolicyCapabilities.class);
} catch (Exception ex) {
throw new IllegalStateException("Storage policy capabilities are invalid", ex);
}
}
public String writeCapabilities(StoragePolicyCapabilities capabilities) {
try {
return objectMapper.writeValueAsString(capabilities);
} catch (Exception ex) {
throw new IllegalStateException("Storage policy capabilities cannot be serialized", ex);
}
}
public StoragePolicy getRequiredPolicy(Long policyId) {
return storagePolicyRepository.findById(policyId)
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "存储策略不存在"));
}
private StoragePolicy createDefaultPolicy() {
if ("s3".equalsIgnoreCase(properties.getProvider())) {
return createDefaultS3Policy();
}
return createDefaultLocalPolicy();
}
private StoragePolicy createDefaultS3Policy() {
StoragePolicy policy = new StoragePolicy();
policy.setName("Default S3 Compatible Storage");
policy.setType(StoragePolicyType.S3_COMPATIBLE);
policy.setBucketName(extractScopeBucketName(properties.getS3().getScope()));
policy.setRegion(properties.getS3().getRegion());
policy.setPrivateBucket(true);
policy.setPrefix(extractScopePrefix(properties.getS3().getScope()));
policy.setCredentialMode(StoragePolicyCredentialMode.DOGECLOUD_TEMP);
policy.setMaxSizeBytes(properties.getMaxFileSize());
policy.setCapabilitiesJson(writeCapabilities(new StoragePolicyCapabilities(
true,
true,
true,
true,
false,
true,
true,
false,
properties.getMaxFileSize()
)));
policy.setEnabled(true);
policy.setDefaultPolicy(true);
return policy;
}
private StoragePolicy createDefaultLocalPolicy() {
StoragePolicy policy = new StoragePolicy();
policy.setName("Default Local Storage");
policy.setType(StoragePolicyType.LOCAL);
policy.setPrivateBucket(true);
policy.setPrefix(properties.getLocal().getRootDir());
policy.setCredentialMode(StoragePolicyCredentialMode.NONE);
policy.setMaxSizeBytes(properties.getMaxFileSize());
policy.setCapabilitiesJson(writeCapabilities(new StoragePolicyCapabilities(
false,
false,
false,
true,
false,
true,
false,
false,
properties.getMaxFileSize()
)));
policy.setEnabled(true);
policy.setDefaultPolicy(true);
return policy;
}
private String extractScopeBucketName(String scope) {
if (!StringUtils.hasText(scope)) {
return null;
}
int separatorIndex = scope.indexOf(':');
return separatorIndex >= 0 ? scope.substring(0, separatorIndex) : scope;
}
private String extractScopePrefix(String scope) {
if (!StringUtils.hasText(scope)) {
return "";
}
int separatorIndex = scope.indexOf(':');
return separatorIndex >= 0 ? scope.substring(separatorIndex + 1) : "";
}
}

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.files.policy;
public enum StoragePolicyType {
LOCAL,
S3_COMPATIBLE
}

View File

@@ -0,0 +1,130 @@
package com.yoyuzh.files.search;
import com.yoyuzh.files.core.StoredFile;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import java.time.LocalDateTime;
@Entity
@Table(
name = "portal_file_metadata",
indexes = {
@Index(name = "idx_file_metadata_file", columnList = "file_id"),
@Index(name = "idx_file_metadata_name", columnList = "name")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_file_metadata_file_name", columnNames = {"file_id", "name"})
}
)
public class FileMetadata {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "file_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private StoredFile file;
@Column(nullable = false, length = 128)
private String name;
@Column(name = "metadata_value", columnDefinition = "TEXT")
private String value;
@Column(name = "public_visible", nullable = false)
private boolean publicVisible;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
}
@PreUpdate
public void preUpdate() {
updatedAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public StoredFile getFile() {
return file;
}
public void setFile(StoredFile file) {
this.file = file;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
public boolean isPublicVisible() {
return publicVisible;
}
public void setPublicVisible(boolean publicVisible) {
this.publicVisible = publicVisible;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -0,0 +1,10 @@
package com.yoyuzh.files.search;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface FileMetadataRepository extends JpaRepository<FileMetadata, Long> {
Optional<FileMetadata> findByFileIdAndName(Long fileId, String name);
}

View File

@@ -0,0 +1,17 @@
package com.yoyuzh.files.search;
import java.time.LocalDateTime;
public record FileSearchQuery(
String name,
Boolean directory,
Long sizeGte,
Long sizeLte,
LocalDateTime createdGte,
LocalDateTime createdLte,
LocalDateTime updatedGte,
LocalDateTime updatedLte,
int page,
int size
) {
}

View File

@@ -0,0 +1,94 @@
package com.yoyuzh.files.search;
import com.yoyuzh.api.v2.ApiV2ErrorCode;
import com.yoyuzh.api.v2.ApiV2Exception;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.core.FileMetadataResponse;
import com.yoyuzh.files.core.StoredFile;
import com.yoyuzh.files.core.StoredFileRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
@RequiredArgsConstructor
public class FileSearchService {
private static final int MAX_PAGE_SIZE = 100;
private final StoredFileRepository storedFileRepository;
public PageResponse<FileMetadataResponse> search(User user, FileSearchQuery query) {
validateQuery(query);
Page<StoredFile> result = storedFileRepository.searchUserFiles(
user.getId(),
normalizeName(query.name()),
query.directory(),
query.sizeGte(),
query.sizeLte(),
query.createdGte(),
query.createdLte(),
query.updatedGte(),
query.updatedLte(),
PageRequest.of(query.page(), query.size())
);
return new PageResponse<>(
result.getContent().stream().map(this::toResponse).toList(),
result.getTotalElements(),
query.page(),
query.size()
);
}
private void validateQuery(FileSearchQuery query) {
if (query.page() < 0) {
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "分页页码不能小于 0");
}
if (query.size() < 1 || query.size() > MAX_PAGE_SIZE) {
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "分页大小必须在 1 到 100 之间");
}
if (query.sizeGte() != null && query.sizeGte() < 0) {
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "文件大小下限不能小于 0");
}
if (query.sizeLte() != null && query.sizeLte() < 0) {
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "文件大小上限不能小于 0");
}
if (query.sizeGte() != null && query.sizeLte() != null && query.sizeGte() > query.sizeLte()) {
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "文件大小范围不合法");
}
if (query.createdGte() != null && query.createdLte() != null && query.createdGte().isAfter(query.createdLte())) {
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "创建时间范围不合法");
}
if (query.updatedGte() != null && query.updatedLte() != null && query.updatedGte().isAfter(query.updatedLte())) {
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "更新时间范围不合法");
}
}
private String normalizeName(String name) {
return StringUtils.hasText(name) ? name.trim() : null;
}
private FileMetadataResponse toResponse(StoredFile storedFile) {
String logicalPath = storedFile.isDirectory()
? buildLogicalPath(storedFile)
: storedFile.getPath();
return new FileMetadataResponse(
storedFile.getId(),
storedFile.getFilename(),
logicalPath,
storedFile.getSize(),
storedFile.getContentType(),
storedFile.isDirectory(),
storedFile.getCreatedAt()
);
}
private String buildLogicalPath(StoredFile storedFile) {
return "/".equals(storedFile.getPath())
? "/" + storedFile.getFilename()
: storedFile.getPath() + "/" + storedFile.getFilename();
}
}

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.share;
import java.time.LocalDateTime;

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.share;
import java.time.LocalDateTime;

View File

@@ -0,0 +1,222 @@
package com.yoyuzh.files.share;
import com.yoyuzh.auth.User;
import com.yoyuzh.files.core.StoredFile;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_file_share_link", indexes = {
@Index(name = "uk_file_share_token", columnList = "token", unique = true),
@Index(name = "idx_file_share_created_at", columnList = "created_at")
})
public class FileShareLink {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "file_id", nullable = false)
private StoredFile file;
@Column(nullable = false, length = 96, unique = true)
private String token;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "password_hash", length = 255)
private String passwordHash;
@Column(name = "expires_at")
private LocalDateTime expiresAt;
@Column(name = "max_downloads")
private Integer maxDownloads;
@Column(name = "download_count")
private Long downloadCount;
@Column(name = "view_count")
private Long viewCount;
@Column(name = "allow_import")
private Boolean allowImport;
@Column(name = "allow_download")
private Boolean allowDownload;
@Column(name = "share_name", length = 255)
private String shareName;
@PrePersist
@PreUpdate
public void prePersist() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
if (downloadCount == null) {
downloadCount = 0L;
}
if (viewCount == null) {
viewCount = 0L;
}
if (allowImport == null) {
allowImport = true;
}
if (allowDownload == null) {
allowDownload = true;
}
if ((shareName == null || shareName.isBlank()) && file != null) {
shareName = file.getFilename();
}
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getOwner() {
return owner;
}
public void setOwner(User owner) {
this.owner = owner;
}
public StoredFile getFile() {
return file;
}
public void setFile(StoredFile file) {
this.file = file;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public String getPasswordHash() {
return passwordHash;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
public LocalDateTime getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(LocalDateTime expiresAt) {
this.expiresAt = expiresAt;
}
public Integer getMaxDownloads() {
return maxDownloads;
}
public void setMaxDownloads(Integer maxDownloads) {
this.maxDownloads = maxDownloads;
}
public Long getDownloadCount() {
return downloadCount;
}
public void setDownloadCount(Long downloadCount) {
this.downloadCount = downloadCount;
}
public Long getViewCount() {
return viewCount;
}
public void setViewCount(Long viewCount) {
this.viewCount = viewCount;
}
public Boolean getAllowImport() {
return allowImport;
}
public void setAllowImport(Boolean allowImport) {
this.allowImport = allowImport;
}
public Boolean getAllowDownload() {
return allowDownload;
}
public void setAllowDownload(Boolean allowDownload) {
this.allowDownload = allowDownload;
}
public String getShareName() {
return shareName;
}
public void setShareName(String shareName) {
this.shareName = shareName;
}
public boolean hasPassword() {
return passwordHash != null && !passwordHash.isBlank();
}
public boolean isAllowImportEnabled() {
return allowImport == null || allowImport;
}
public boolean isAllowDownloadEnabled() {
return allowDownload == null || allowDownload;
}
public long getDownloadCountOrZero() {
return downloadCount == null ? 0L : downloadCount;
}
public long getViewCountOrZero() {
return viewCount == null ? 0L : viewCount;
}
public String getShareNameOrDefault() {
if (shareName != null && !shareName.isBlank()) {
return shareName;
}
return file == null ? null : file.getFilename();
}
}

View File

@@ -0,0 +1,51 @@
package com.yoyuzh.files.share;
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;
public interface FileShareLinkRepository extends JpaRepository<FileShareLink, Long> {
@EntityGraph(attributePaths = {"owner", "file", "file.user", "file.blob"})
Optional<FileShareLink> findByToken(String token);
@EntityGraph(attributePaths = {"owner", "file", "file.user", "file.blob"})
Page<FileShareLink> findByOwnerIdOrderByCreatedAtDesc(Long ownerId, Pageable pageable);
@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

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.share;
import jakarta.validation.constraints.NotBlank;

View File

@@ -0,0 +1,200 @@
package com.yoyuzh.files.share;
import com.yoyuzh.api.v2.ApiV2ErrorCode;
import com.yoyuzh.api.v2.ApiV2Exception;
import com.yoyuzh.api.v2.shares.CreateShareV2Request;
import com.yoyuzh.api.v2.shares.ImportShareV2Request;
import com.yoyuzh.api.v2.shares.ShareV2Response;
import com.yoyuzh.api.v2.shares.VerifySharePasswordV2Request;
import com.yoyuzh.auth.User;
import com.yoyuzh.files.core.FileMetadataResponse;
import com.yoyuzh.files.core.FileService;
import com.yoyuzh.files.core.StoredFile;
import com.yoyuzh.files.core.StoredFileRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class ShareV2Service {
private final StoredFileRepository storedFileRepository;
private final FileShareLinkRepository fileShareLinkRepository;
private final FileService fileService;
private final PasswordEncoder passwordEncoder;
@Transactional
public ShareV2Response createShare(User user, CreateShareV2Request request) {
StoredFile file = storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(request.fileId(), user.getId())
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "file not found"));
if (file.isDirectory()) {
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "directories are not supported");
}
validateSharePolicy(request.expiresAt(), request.maxDownloads());
FileShareLink shareLink = new FileShareLink();
shareLink.setOwner(user);
shareLink.setFile(file);
shareLink.setToken(UUID.randomUUID().toString().replace("-", ""));
shareLink.setShareName(StringUtils.hasText(request.shareName()) ? request.shareName().trim() : file.getFilename());
shareLink.setPasswordHash(StringUtils.hasText(request.password()) ? passwordEncoder.encode(request.password()) : null);
shareLink.setExpiresAt(request.expiresAt());
shareLink.setMaxDownloads(request.maxDownloads());
shareLink.setAllowImport(request.allowImport() == null ? true : request.allowImport());
shareLink.setAllowDownload(request.allowDownload() == null ? true : request.allowDownload());
FileShareLink saved = fileShareLinkRepository.save(shareLink);
return toResponse(saved, true, true);
}
@Transactional
public ShareV2Response getShare(String token) {
FileShareLink shareLink = getShareLink(token);
ensureShareNotExpired(shareLink);
shareLink.setViewCount(shareLink.getViewCountOrZero() + 1);
boolean passwordRequired = shareLink.hasPassword();
return toResponse(shareLink, !passwordRequired, !passwordRequired);
}
@Transactional
public ShareV2Response verifyPassword(String token, VerifySharePasswordV2Request request) {
FileShareLink shareLink = getShareLink(token);
ensureShareNotExpired(shareLink);
if (shareLink.hasPassword()) {
if (!StringUtils.hasText(request.password()) || !passwordEncoder.matches(request.password(), shareLink.getPasswordHash())) {
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "invalid password");
}
}
shareLink.setViewCount(shareLink.getViewCountOrZero() + 1);
return toResponse(shareLink, true, true);
}
@Transactional
public FileMetadataResponse importSharedFile(User recipient, String token, ImportShareV2Request request) {
FileShareLink shareLink = getShareLink(token);
ensureShareNotExpired(shareLink);
ensureImportAllowed(shareLink);
ensurePasswordAccepted(shareLink, request.password());
FileMetadataResponse importedFile = fileService.importSharedFile(recipient, token, request.path());
shareLink.setDownloadCount(shareLink.getDownloadCountOrZero() + 1);
return importedFile;
}
@Transactional
public ResponseEntity<?> downloadSharedFile(String token, String password) {
FileShareLink shareLink = getShareLink(token);
ensureShareNotExpired(shareLink);
ensureDownloadAllowed(shareLink);
ensurePasswordAccepted(shareLink, password);
shareLink.setDownloadCount(shareLink.getDownloadCountOrZero() + 1);
return fileService.download(shareLink.getOwner(), shareLink.getFile().getId());
}
@Transactional
public Page<ShareV2Response> listOwnedShares(User user, Pageable pageable) {
return fileShareLinkRepository.findByOwnerIdOrderByCreatedAtDesc(user.getId(), pageable)
.map(shareLink -> toResponse(shareLink, true, true));
}
@Transactional
public void deleteOwnedShare(User user, Long id) {
FileShareLink shareLink = fileShareLinkRepository.findByIdAndOwnerId(id, user.getId())
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "share not found"));
fileShareLinkRepository.delete(shareLink);
}
private FileShareLink getShareLink(String token) {
return fileShareLinkRepository.findByToken(token)
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "share not found"));
}
private void ensureShareNotExpired(FileShareLink shareLink) {
if (shareLink.getExpiresAt() != null && !LocalDateTime.now().isBefore(shareLink.getExpiresAt())) {
throw new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "share not found");
}
}
private void ensureImportAllowed(FileShareLink shareLink) {
if (!shareLink.isAllowImportEnabled()) {
throw new ApiV2Exception(ApiV2ErrorCode.PERMISSION_DENIED, "import disabled");
}
ensureQuotaAvailable(shareLink);
}
private void ensureDownloadAllowed(FileShareLink shareLink) {
if (!shareLink.isAllowDownloadEnabled()) {
throw new ApiV2Exception(ApiV2ErrorCode.PERMISSION_DENIED, "download disabled");
}
ensureQuotaAvailable(shareLink);
}
private void ensureQuotaAvailable(FileShareLink shareLink) {
Integer maxDownloads = shareLink.getMaxDownloads();
if (maxDownloads != null && shareLink.getDownloadCountOrZero() >= maxDownloads) {
throw new ApiV2Exception(ApiV2ErrorCode.PERMISSION_DENIED, "share quota exceeded");
}
}
private void ensurePasswordAccepted(FileShareLink shareLink, String password) {
if (!shareLink.hasPassword()) {
return;
}
if (!StringUtils.hasText(password) || !passwordEncoder.matches(password, shareLink.getPasswordHash())) {
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "invalid password");
}
}
private void validateSharePolicy(LocalDateTime expiresAt, Integer maxDownloads) {
if (expiresAt != null && !expiresAt.isAfter(LocalDateTime.now())) {
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "expiresAt must be in the future");
}
if (maxDownloads != null && maxDownloads <= 0) {
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "maxDownloads must be greater than 0");
}
}
private ShareV2Response toResponse(FileShareLink shareLink, boolean passwordVerified, boolean includeFile) {
return new ShareV2Response(
shareLink.getId(),
shareLink.getToken(),
shareLink.getShareNameOrDefault(),
shareLink.getOwner() == null ? null : shareLink.getOwner().getUsername(),
shareLink.hasPassword(),
passwordVerified,
shareLink.isAllowImportEnabled(),
shareLink.isAllowDownloadEnabled(),
shareLink.getMaxDownloads(),
shareLink.getDownloadCountOrZero(),
shareLink.getViewCountOrZero(),
shareLink.getExpiresAt(),
shareLink.getCreatedAt(),
includeFile && shareLink.getFile() != null ? toFileMetadataResponse(shareLink.getFile()) : null
);
}
private FileMetadataResponse toFileMetadataResponse(StoredFile file) {
return new FileMetadataResponse(
file.getId(),
file.getFilename(),
file.getPath(),
file.getSize(),
file.getContentType(),
file.isDirectory(),
file.getCreatedAt()
);
}
}

View File

@@ -0,0 +1,108 @@
package com.yoyuzh.files.storage;
import com.yoyuzh.config.FileStorageProperties;
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import java.net.URI;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.function.Function;
import java.util.function.Supplier;
final class DogeCloudS3SessionProvider implements S3SessionProvider {
private static final Duration REFRESH_WINDOW = Duration.ofMinutes(1);
private final Supplier<DogeCloudTemporaryS3Session> sessionSupplier;
private final Clock clock;
private final Function<DogeCloudTemporaryS3Session, S3FileRuntimeSession> runtimeFactory;
private CachedSession cachedSession;
DogeCloudS3SessionProvider(FileStorageProperties.S3 properties, DogeCloudTmpTokenClient tmpTokenClient) {
this(
properties,
tmpTokenClient::fetchSession,
Clock.systemUTC(),
session -> createRuntimeSession(properties, session)
);
}
DogeCloudS3SessionProvider(
FileStorageProperties.S3 properties,
Supplier<DogeCloudTemporaryS3Session> sessionSupplier,
Clock clock,
Function<DogeCloudTemporaryS3Session, S3FileRuntimeSession> runtimeFactory
) {
this.sessionSupplier = sessionSupplier;
this.clock = clock;
this.runtimeFactory = runtimeFactory;
}
@Override
public synchronized S3FileRuntimeSession currentSession() {
if (cachedSession != null && clock.instant().isBefore(cachedSession.expiresAt().minus(REFRESH_WINDOW))) {
return cachedSession.runtimeSession();
}
closeCachedSession();
DogeCloudTemporaryS3Session nextSession = sessionSupplier.get();
S3FileRuntimeSession runtimeSession = runtimeFactory.apply(nextSession);
cachedSession = new CachedSession(nextSession.expiresAt(), runtimeSession);
return runtimeSession;
}
@Override
public synchronized void close() {
closeCachedSession();
}
private void closeCachedSession() {
if (cachedSession == null) {
return;
}
cachedSession.runtimeSession().s3Presigner().close();
cachedSession.runtimeSession().s3Client().close();
cachedSession = null;
}
private static S3FileRuntimeSession createRuntimeSession(FileStorageProperties.S3 properties, DogeCloudTemporaryS3Session session) {
StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsSessionCredentials.create(
session.accessKeyId(),
session.secretAccessKey(),
session.sessionToken()
));
Region region = Region.of(resolveRegion(properties));
URI endpoint = URI.create(session.endpoint());
return new S3FileRuntimeSession(
session.bucket(),
S3Client.builder()
.credentialsProvider(credentialsProvider)
.region(region)
.endpointOverride(endpoint)
.serviceConfiguration(S3Configuration.builder().build())
.build(),
S3Presigner.builder()
.credentialsProvider(credentialsProvider)
.region(region)
.endpointOverride(endpoint)
.serviceConfiguration(S3Configuration.builder().build())
.build()
);
}
private static String resolveRegion(FileStorageProperties.S3 properties) {
return properties.getRegion() == null || properties.getRegion().isBlank()
? "automatic"
: properties.getRegion();
}
private record CachedSession(Instant expiresAt, S3FileRuntimeSession runtimeSession) {
}
}

View File

@@ -0,0 +1,13 @@
package com.yoyuzh.files.storage;
import java.time.Instant;
record DogeCloudTemporaryS3Session(
String bucket,
String endpoint,
String accessKeyId,
String secretAccessKey,
String sessionToken,
Instant expiresAt
) {
}

View File

@@ -0,0 +1,193 @@
package com.yoyuzh.files.storage;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.config.FileStorageProperties;
import org.springframework.util.StringUtils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
final class DogeCloudTmpTokenClient {
private static final String API_PATH = "/auth/tmp_token.json";
private final FileStorageProperties.S3 properties;
private final ObjectMapper objectMapper;
private final Transport transport;
DogeCloudTmpTokenClient(FileStorageProperties.S3 properties, ObjectMapper objectMapper) {
this(properties, objectMapper, new HttpTransport());
}
DogeCloudTmpTokenClient(FileStorageProperties.S3 properties, ObjectMapper objectMapper, Transport transport) {
this.properties = properties;
this.objectMapper = objectMapper;
this.transport = transport;
}
DogeCloudTemporaryS3Session fetchSession() {
validateConfiguration();
String body = buildRequestBody();
Map<String, String> headers = Map.of(
"Content-Type", "application/json",
"Authorization", buildAuthorization(body)
);
TransportResponse response = post(body, headers);
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IllegalStateException("多吉云临时密钥请求失败: HTTP " + response.statusCode() + " " + response.body());
}
try {
JsonNode root = objectMapper.readTree(response.body());
if (root.path("code").asInt() != 200) {
throw new IllegalStateException("多吉云临时密钥请求失败: " + root.path("msg").asText("unknown"));
}
JsonNode data = root.path("data");
JsonNode credentials = data.path("Credentials");
JsonNode bucketNode = resolveBucketNode(data.path("Buckets"));
return new DogeCloudTemporaryS3Session(
requiredText(bucketNode, "s3Bucket"),
requiredText(bucketNode, "s3Endpoint"),
requiredText(credentials, "accessKeyId"),
requiredText(credentials, "secretAccessKey"),
requiredText(credentials, "sessionToken"),
resolveExpiresAt(data.path("ExpiredAt"))
);
} catch (IOException ex) {
throw new IllegalStateException("解析多吉云临时密钥响应失败", ex);
}
}
private TransportResponse post(String body, Map<String, String> headers) {
try {
return transport.post(resolveBaseUrl(), API_PATH, body, headers);
} catch (IOException ex) {
throw new IllegalStateException("请求多吉云临时密钥失败", ex);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("请求多吉云临时密钥被中断", ex);
}
}
private void validateConfiguration() {
if (!StringUtils.hasText(properties.getApiAccessKey())
|| !StringUtils.hasText(properties.getApiSecretKey())
|| !StringUtils.hasText(properties.getScope())) {
throw new IllegalStateException("多吉云存储配置不完整");
}
}
private String buildRequestBody() {
LinkedHashMap<String, Object> payload = new LinkedHashMap<>();
payload.put("channel", "OSS_FULL");
payload.put("ttl", properties.getTtlSeconds());
payload.put("scopes", List.of(properties.getScope()));
try {
return objectMapper.writeValueAsString(payload);
} catch (IOException ex) {
throw new IllegalStateException("构建多吉云临时密钥请求失败", ex);
}
}
private String buildAuthorization(String body) {
String signTarget = API_PATH + "\n" + body;
return "TOKEN " + properties.getApiAccessKey() + ":" + hmacSha1Hex(properties.getApiSecretKey(), signTarget);
}
private String resolveBaseUrl() {
String configured = properties.getApiBaseUrl();
if (!StringUtils.hasText(configured)) {
return "https://api.dogecloud.com";
}
return configured.replaceAll("/+$", "");
}
private JsonNode resolveBucketNode(JsonNode bucketsNode) {
if (!bucketsNode.isArray() || bucketsNode.isEmpty()) {
throw new IllegalStateException("多吉云临时密钥响应缺少 Buckets");
}
String bucketName = extractBucketName(properties.getScope());
for (JsonNode node : bucketsNode) {
if (bucketName.equals(node.path("name").asText())) {
return node;
}
}
if (bucketsNode.size() == 1) {
return bucketsNode.get(0);
}
throw new IllegalStateException("多吉云临时密钥响应中未找到匹配的存储桶: " + bucketName);
}
static String extractBucketName(String scope) {
int separatorIndex = scope.indexOf(':');
return separatorIndex >= 0 ? scope.substring(0, separatorIndex) : scope;
}
private static Instant resolveExpiresAt(JsonNode node) {
long epochSeconds = node.asLong(0L);
if (epochSeconds <= 0L) {
throw new IllegalStateException("多吉云临时密钥响应缺少 ExpiredAt");
}
return Instant.ofEpochSecond(epochSeconds);
}
private static String requiredText(JsonNode node, String fieldName) {
String value = node.path(fieldName).asText();
if (!StringUtils.hasText(value)) {
throw new IllegalStateException("多吉云临时密钥响应缺少字段: " + fieldName);
}
return value;
}
private static String hmacSha1Hex(String secret, String content) {
try {
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA1"));
byte[] digest = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder(digest.length * 2);
for (byte current : digest) {
builder.append(String.format("%02x", current));
}
return builder.toString();
} catch (Exception ex) {
throw new IllegalStateException("生成多吉云 API 签名失败", ex);
}
}
interface Transport {
TransportResponse post(String baseUrl, String apiPath, String body, Map<String, String> headers) throws IOException, InterruptedException;
}
record TransportResponse(int statusCode, String body) {
}
private static final class HttpTransport implements Transport {
private final HttpClient httpClient = HttpClient.newHttpClient();
@Override
public TransportResponse post(String baseUrl, String apiPath, String body, Map<String, String> headers) throws IOException, InterruptedException {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(URI.create(baseUrl + apiPath))
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
for (Map.Entry<String, String> entry : headers.entrySet()) {
requestBuilder.header(entry.getKey(), entry.getValue());
}
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return new TransportResponse(response.statusCode(), response.body());
}
}
}

View File

@@ -0,0 +1,88 @@
package com.yoyuzh.files.storage;
import org.springframework.web.multipart.MultipartFile;
public interface FileContentStorage {
PreparedUpload prepareUpload(Long userId, String path, String storageName, String contentType, long size);
void upload(Long userId, String path, String storageName, MultipartFile file);
void completeUpload(Long userId, String path, String storageName, String contentType, long size);
byte[] readFile(Long userId, String path, String storageName);
void deleteFile(Long userId, String path, String storageName);
String createDownloadUrl(Long userId, String path, String storageName, String filename);
default void renameFile(Long userId, String path, String oldStorageName, String newStorageName) {
throw new UnsupportedOperationException("File content rename is not supported by this storage");
}
default void renameDirectory(Long userId, String oldPath, String oldStorageName, String newStorageName) {
throw new UnsupportedOperationException("Directory content rename is not supported by this storage");
}
default void moveFile(Long userId, String oldPath, String storageName, String newPath) {
throw new UnsupportedOperationException("File content move is not supported by this storage");
}
default void copyFile(Long userId, String path, String storageName, String targetPath) {
throw new UnsupportedOperationException("File content copy is not supported by this storage");
}
default void storeImportedFile(Long userId, String path, String storageName, String contentType, byte[] content) {
throw new UnsupportedOperationException("Imported file storage is not supported by this storage");
}
PreparedUpload prepareBlobUpload(String path, String filename, String objectKey, String contentType, long size);
void uploadBlob(String objectKey, MultipartFile file);
void completeBlobUpload(String objectKey, String contentType, long size);
void storeBlob(String objectKey, String contentType, byte[] content);
byte[] readBlob(String objectKey);
void deleteBlob(String objectKey);
default String createMultipartUpload(String objectKey, String contentType) {
throw new UnsupportedOperationException("Multipart upload is not supported by this storage");
}
default PreparedUpload prepareMultipartPartUpload(String objectKey,
String uploadId,
int partNumber,
String contentType,
long size) {
throw new UnsupportedOperationException("Multipart upload is not supported by this storage");
}
default void completeMultipartUpload(String objectKey, String uploadId, java.util.List<MultipartCompletedPart> parts) {
throw new UnsupportedOperationException("Multipart upload is not supported by this storage");
}
default void abortMultipartUpload(String objectKey, String uploadId) {
throw new UnsupportedOperationException("Multipart upload is not supported by this storage");
}
String createBlobDownloadUrl(String objectKey, String filename);
void createDirectory(Long userId, String logicalPath);
void ensureDirectory(Long userId, String logicalPath);
void storeTransferFile(String sessionId, String storageName, String contentType, byte[] content);
byte[] readTransferFile(String sessionId, String storageName);
void deleteTransferFile(String sessionId, String storageName);
String createTransferDownloadUrl(String sessionId, String storageName, String filename);
boolean supportsDirectDownload();
String resolveLegacyFileObjectKey(Long userId, String path, String storageName);
}

View File

@@ -0,0 +1,230 @@
package com.yoyuzh.files.storage;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.config.FileStorageProperties;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Map;
public class LocalFileContentStorage implements FileContentStorage {
private final Path rootPath;
public LocalFileContentStorage(FileStorageProperties properties) {
this.rootPath = Path.of(properties.getLocal().getRootDir()).toAbsolutePath().normalize();
try {
Files.createDirectories(rootPath);
} catch (IOException ex) {
throw new IllegalStateException("Failed to initialize local storage root", ex);
}
}
@Override
public PreparedUpload prepareUpload(Long userId, String path, String storageName, String contentType, long size) {
return new PreparedUpload(false, "", "POST", Map.of(), storageName);
}
@Override
public void upload(Long userId, String path, String storageName, MultipartFile file) {
write(resolveLegacyPath(userId, path, storageName), file);
}
@Override
public void completeUpload(Long userId, String path, String storageName, String contentType, long size) {
ensureReadable(resolveLegacyPath(userId, path, storageName));
}
@Override
public byte[] readFile(Long userId, String path, String storageName) {
return read(resolveLegacyPath(userId, path, storageName));
}
@Override
public void deleteFile(Long userId, String path, String storageName) {
delete(resolveLegacyPath(userId, path, storageName));
}
@Override
public String createDownloadUrl(Long userId, String path, String storageName, String filename) {
throw new UnsupportedOperationException("Local storage does not support direct download URLs");
}
@Override
public PreparedUpload prepareBlobUpload(String path, String filename, String objectKey, String contentType, long size) {
return new PreparedUpload(false, "", "POST", Map.of(), objectKey);
}
@Override
public void uploadBlob(String objectKey, MultipartFile file) {
write(resolveObjectKey(objectKey), file);
}
@Override
public void completeBlobUpload(String objectKey, String contentType, long size) {
ensureReadable(resolveObjectKey(objectKey));
}
@Override
public void storeBlob(String objectKey, String contentType, byte[] content) {
write(resolveObjectKey(objectKey), content);
}
@Override
public byte[] readBlob(String objectKey) {
return read(resolveObjectKey(objectKey));
}
@Override
public void deleteBlob(String objectKey) {
delete(resolveObjectKey(objectKey));
}
@Override
public String createBlobDownloadUrl(String objectKey, String filename) {
throw new UnsupportedOperationException("Local storage does not support direct download URLs");
}
@Override
public void createDirectory(Long userId, String logicalPath) {
ensureDirectory(userId, logicalPath);
}
@Override
public void ensureDirectory(Long userId, String logicalPath) {
createDirectories(resolveUserDirectory(userId, logicalPath));
}
@Override
public void storeTransferFile(String sessionId, String storageName, String contentType, byte[] content) {
write(resolveTransferPath(sessionId, storageName), content);
}
@Override
public byte[] readTransferFile(String sessionId, String storageName) {
return read(resolveTransferPath(sessionId, storageName));
}
@Override
public void deleteTransferFile(String sessionId, String storageName) {
delete(resolveTransferPath(sessionId, storageName));
}
@Override
public String createTransferDownloadUrl(String sessionId, String storageName, String filename) {
throw new UnsupportedOperationException("Local storage does not support direct download URLs");
}
@Override
public boolean supportsDirectDownload() {
return false;
}
@Override
public String resolveLegacyFileObjectKey(Long userId, String path, String storageName) {
return "users/" + userId + "/" + normalizeRelativePath(path) + "/" + normalizeName(storageName);
}
private Path resolveLegacyPath(Long userId, String path, String storageName) {
return resolveObjectKey(resolveLegacyFileObjectKey(userId, path, storageName));
}
private Path resolveTransferPath(String sessionId, String storageName) {
return resolveObjectKey("transfers/" + normalizeName(sessionId) + "/" + normalizeName(storageName));
}
private Path resolveUserDirectory(Long userId, String logicalPath) {
return resolveObjectKey("users/" + userId + "/" + normalizeRelativePath(logicalPath));
}
private Path resolveObjectKey(String objectKey) {
Path resolved = rootPath.resolve(normalizeObjectKey(objectKey)).normalize();
if (!resolved.startsWith(rootPath)) {
throw new BusinessException(ErrorCode.UNKNOWN, "Invalid storage path");
}
return resolved;
}
private String normalizeObjectKey(String objectKey) {
String cleaned = StringUtils.cleanPath(objectKey == null ? "" : objectKey).replace("\\", "/");
if (!StringUtils.hasText(cleaned) || cleaned.startsWith("/") || cleaned.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "Invalid storage object key");
}
return cleaned;
}
private String normalizeRelativePath(String path) {
String cleaned = StringUtils.cleanPath(path == null ? "" : path).replace("\\", "/");
if (!StringUtils.hasText(cleaned) || "/".equals(cleaned)) {
return "";
}
if (cleaned.startsWith("/")) {
cleaned = cleaned.substring(1);
}
if (cleaned.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "Invalid storage path");
}
return cleaned;
}
private String normalizeName(String name) {
String cleaned = StringUtils.cleanPath(name == null ? "" : name).replace("\\", "/");
if (!StringUtils.hasText(cleaned) || cleaned.startsWith("/") || cleaned.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "Invalid storage filename");
}
return cleaned;
}
private void write(Path target, MultipartFile file) {
try {
createDirectories(target.getParent());
file.transferTo(target);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File write failed");
}
}
private void write(Path target, byte[] content) {
try {
createDirectories(target.getParent());
Files.write(target, content, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File write failed");
}
}
private byte[] read(Path target) {
try {
return Files.readAllBytes(target);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "File content does not exist");
}
}
private void delete(Path target) {
try {
Files.deleteIfExists(target);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File delete failed");
}
}
private void ensureReadable(Path target) {
if (!Files.isRegularFile(target)) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "File content does not exist");
}
}
private void createDirectories(Path path) {
try {
Files.createDirectories(path);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "Directory create failed");
}
}
}

View File

@@ -0,0 +1,7 @@
package com.yoyuzh.files.storage;
public record MultipartCompletedPart(
int partNumber,
String etag
) {
}

View File

@@ -0,0 +1,12 @@
package com.yoyuzh.files.storage;
import java.util.Map;
public record PreparedUpload(
boolean direct,
String uploadUrl,
String method,
Map<String, String> headers,
String storageName
) {
}

View File

@@ -0,0 +1,518 @@
package com.yoyuzh.files.storage;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.config.FileStorageProperties;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.ResponseBytes;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.http.SdkHttpMethod;
import software.amazon.awssdk.services.s3.model.AbortMultipartUploadRequest;
import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest;
import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload;
import software.amazon.awssdk.services.s3.model.CompletedPart;
import software.amazon.awssdk.services.s3.model.CopyObjectRequest;
import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;
import software.amazon.awssdk.services.s3.model.UploadPartRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedUploadPartRequest;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.UploadPartPresignRequest;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class S3FileContentStorage implements FileContentStorage {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final FileStorageProperties.S3 properties;
private final S3SessionProvider sessionProvider;
public S3FileContentStorage(FileStorageProperties storageProperties) {
this(
storageProperties,
new DogeCloudS3SessionProvider(
storageProperties.getS3(),
new DogeCloudTmpTokenClient(storageProperties.getS3(), OBJECT_MAPPER)
)
);
}
S3FileContentStorage(FileStorageProperties storageProperties,
String bucket,
software.amazon.awssdk.services.s3.S3Client s3Client,
software.amazon.awssdk.services.s3.presigner.S3Presigner s3Presigner) {
this(storageProperties, () -> new S3FileRuntimeSession(bucket, s3Client, s3Presigner));
}
S3FileContentStorage(FileStorageProperties storageProperties, S3SessionProvider sessionProvider) {
this.properties = storageProperties.getS3();
this.sessionProvider = sessionProvider;
}
@Override
public PreparedUpload prepareUpload(Long userId, String path, String storageName, String contentType, long size) {
return prepareBlobUpload(path, storageName, resolveLegacyFileObjectKey(userId, path, storageName), contentType, size);
}
@Override
public void upload(Long userId, String path, String storageName, MultipartFile file) {
uploadBlob(resolveLegacyFileObjectKey(userId, path, storageName), file);
}
@Override
public void completeUpload(Long userId, String path, String storageName, String contentType, long size) {
completeBlobUpload(resolveLegacyFileObjectKey(userId, path, storageName), contentType, size);
}
@Override
public byte[] readFile(Long userId, String path, String storageName) {
S3FileRuntimeSession session = sessionProvider.currentSession();
String objectKey = resolveExistingFileObjectKey(session, userId, path, storageName);
return readObject(session, objectKey);
}
@Override
public void deleteFile(Long userId, String path, String storageName) {
deleteBlob(resolveLegacyFileObjectKey(userId, path, storageName));
}
@Override
public String createDownloadUrl(Long userId, String path, String storageName, String filename) {
S3FileRuntimeSession session = sessionProvider.currentSession();
String objectKey = resolveExistingFileObjectKey(session, userId, path, storageName);
return createDownloadUrl(session, objectKey, filename);
}
@Override
public PreparedUpload prepareBlobUpload(String path, String filename, String objectKey, String contentType, long size) {
S3FileRuntimeSession session = sessionProvider.currentSession();
PutObjectRequest.Builder requestBuilder = PutObjectRequest.builder()
.bucket(session.bucket())
.key(normalizeObjectKey(objectKey));
if (StringUtils.hasText(contentType)) {
requestBuilder.contentType(contentType);
}
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofSeconds(Math.max(1, properties.getTtlSeconds())))
.putObjectRequest(requestBuilder.build())
.build();
PresignedPutObjectRequest presignedRequest = session.s3Presigner().presignPutObject(presignRequest);
return new PreparedUpload(
true,
presignedRequest.url().toString(),
resolveUploadMethod(presignedRequest),
resolveUploadHeaders(presignedRequest, contentType),
objectKey
);
}
@Override
public void uploadBlob(String objectKey, MultipartFile file) {
try {
putObject(objectKey, file.getContentType(), file.getBytes());
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File write failed");
}
}
@Override
public void completeBlobUpload(String objectKey, String contentType, long size) {
S3FileRuntimeSession session = sessionProvider.currentSession();
try {
ensureObjectExists(session, normalizeObjectKey(objectKey));
} catch (NoSuchKeyException ex) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "上传文件不存在");
} catch (S3Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File content verification failed");
}
}
@Override
public void storeBlob(String objectKey, String contentType, byte[] content) {
putObject(objectKey, contentType, content);
}
@Override
public byte[] readBlob(String objectKey) {
return readObject(sessionProvider.currentSession(), normalizeObjectKey(objectKey));
}
@Override
public void deleteBlob(String objectKey) {
S3FileRuntimeSession session = sessionProvider.currentSession();
try {
session.s3Client().deleteObject(DeleteObjectRequest.builder()
.bucket(session.bucket())
.key(normalizeObjectKey(objectKey))
.build());
} catch (S3Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File delete failed");
}
}
@Override
public String createMultipartUpload(String objectKey, String contentType) {
S3FileRuntimeSession session = sessionProvider.currentSession();
CreateMultipartUploadRequest.Builder requestBuilder = CreateMultipartUploadRequest.builder()
.bucket(session.bucket())
.key(normalizeObjectKey(objectKey));
if (StringUtils.hasText(contentType)) {
requestBuilder.contentType(contentType);
}
try {
return session.s3Client().createMultipartUpload(requestBuilder.build()).uploadId();
} catch (S3Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "Multipart upload init failed");
}
}
@Override
public PreparedUpload prepareMultipartPartUpload(String objectKey,
String uploadId,
int partNumber,
String contentType,
long size) {
S3FileRuntimeSession session = sessionProvider.currentSession();
UploadPartRequest uploadPartRequest = UploadPartRequest.builder()
.bucket(session.bucket())
.key(normalizeObjectKey(objectKey))
.uploadId(uploadId)
.partNumber(partNumber)
.contentLength(size)
.build();
UploadPartPresignRequest presignRequest = UploadPartPresignRequest.builder()
.signatureDuration(Duration.ofSeconds(Math.max(1, properties.getTtlSeconds())))
.uploadPartRequest(uploadPartRequest)
.build();
PresignedUploadPartRequest presignedRequest = session.s3Presigner().presignUploadPart(presignRequest);
Map<String, String> headers = flattenSignedHeaders(presignedRequest.signedHeaders());
if (StringUtils.hasText(contentType)) {
headers.put("Content-Type", contentType);
}
return new PreparedUpload(
true,
presignedRequest.url().toString(),
resolveUploadMethod(presignedRequest),
headers,
objectKey
);
}
@Override
public void completeMultipartUpload(String objectKey, String uploadId, List<MultipartCompletedPart> parts) {
S3FileRuntimeSession session = sessionProvider.currentSession();
List<CompletedPart> completedParts = parts.stream()
.sorted(Comparator.comparingInt(MultipartCompletedPart::partNumber))
.map(part -> CompletedPart.builder()
.partNumber(part.partNumber())
.eTag(part.etag())
.build())
.toList();
try {
session.s3Client().completeMultipartUpload(CompleteMultipartUploadRequest.builder()
.bucket(session.bucket())
.key(normalizeObjectKey(objectKey))
.uploadId(uploadId)
.multipartUpload(CompletedMultipartUpload.builder().parts(completedParts).build())
.build());
} catch (S3Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "Multipart upload complete failed");
}
}
@Override
public void abortMultipartUpload(String objectKey, String uploadId) {
S3FileRuntimeSession session = sessionProvider.currentSession();
try {
session.s3Client().abortMultipartUpload(AbortMultipartUploadRequest.builder()
.bucket(session.bucket())
.key(normalizeObjectKey(objectKey))
.uploadId(uploadId)
.build());
} catch (S3Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "Multipart upload abort failed");
}
}
@Override
public String createBlobDownloadUrl(String objectKey, String filename) {
return createDownloadUrl(sessionProvider.currentSession(), normalizeObjectKey(objectKey), filename);
}
@Override
public void createDirectory(Long userId, String logicalPath) {
}
@Override
public void ensureDirectory(Long userId, String logicalPath) {
}
@Override
public void renameFile(Long userId, String path, String oldStorageName, String newStorageName) {
S3FileRuntimeSession session = sessionProvider.currentSession();
String sourceKey = resolveExistingFileObjectKey(session, userId, path, oldStorageName);
String targetKey = resolveLegacyFileObjectKey(userId, path, newStorageName);
copyObject(session, sourceKey, targetKey);
deleteObject(session, sourceKey);
}
@Override
public void moveFile(Long userId, String oldPath, String storageName, String newPath) {
S3FileRuntimeSession session = sessionProvider.currentSession();
String sourceKey = resolveExistingFileObjectKey(session, userId, oldPath, storageName);
String targetKey = resolveLegacyFileObjectKey(userId, newPath, storageName);
copyObject(session, sourceKey, targetKey);
deleteObject(session, sourceKey);
}
@Override
public void copyFile(Long userId, String path, String storageName, String targetPath) {
S3FileRuntimeSession session = sessionProvider.currentSession();
String sourceKey = resolveExistingFileObjectKey(session, userId, path, storageName);
String targetKey = resolveLegacyFileObjectKey(userId, targetPath, storageName);
copyObject(session, sourceKey, targetKey);
}
@Override
public void storeImportedFile(Long userId, String path, String storageName, String contentType, byte[] content) {
storeBlob(resolveLegacyFileObjectKey(userId, path, storageName), contentType, content);
}
@Override
public void storeTransferFile(String sessionId, String storageName, String contentType, byte[] content) {
putObject(resolveTransferObjectKey(sessionId, storageName), contentType, content);
}
@Override
public byte[] readTransferFile(String sessionId, String storageName) {
return readBlob(resolveTransferObjectKey(sessionId, storageName));
}
@Override
public void deleteTransferFile(String sessionId, String storageName) {
deleteBlob(resolveTransferObjectKey(sessionId, storageName));
}
@Override
public String createTransferDownloadUrl(String sessionId, String storageName, String filename) {
return createBlobDownloadUrl(resolveTransferObjectKey(sessionId, storageName), filename);
}
@Override
public boolean supportsDirectDownload() {
return true;
}
@Override
public String resolveLegacyFileObjectKey(Long userId, String path, String storageName) {
return "users/" + userId + "/" + joinObjectKeyParts(normalizeRelativePath(path), normalizeName(storageName));
}
private String resolveExistingFileObjectKey(S3FileRuntimeSession session, Long userId, String path, String storageName) {
String currentKey = resolveLegacyFileObjectKey(userId, path, storageName);
try {
ensureObjectExists(session, currentKey);
return currentKey;
} catch (NoSuchKeyException ex) {
String legacyKey = userId + "/" + joinObjectKeyParts(normalizeRelativePath(path), normalizeName(storageName));
ensureObjectExists(session, legacyKey);
return legacyKey;
}
}
private void putObject(String objectKey, String contentType, byte[] content) {
S3FileRuntimeSession session = sessionProvider.currentSession();
PutObjectRequest.Builder requestBuilder = PutObjectRequest.builder()
.bucket(session.bucket())
.key(normalizeObjectKey(objectKey));
if (StringUtils.hasText(contentType)) {
requestBuilder.contentType(contentType);
}
try {
session.s3Client().putObject(requestBuilder.build(), RequestBody.fromBytes(content));
} catch (S3Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File write failed");
}
}
private byte[] readObject(S3FileRuntimeSession session, String objectKey) {
try {
ResponseBytes<?> response = session.s3Client().getObjectAsBytes(GetObjectRequest.builder()
.bucket(session.bucket())
.key(normalizeObjectKey(objectKey))
.build());
return response.asByteArray();
} catch (NoSuchKeyException ex) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "File content does not exist");
} catch (S3Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File read failed");
}
}
private String createDownloadUrl(S3FileRuntimeSession session, String objectKey, String filename) {
GetObjectRequest.Builder requestBuilder = GetObjectRequest.builder()
.bucket(session.bucket())
.key(normalizeObjectKey(objectKey));
if (StringUtils.hasText(filename)) {
requestBuilder.responseContentDisposition(createContentDisposition(filename));
}
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofSeconds(Math.max(1, properties.getTtlSeconds())))
.getObjectRequest(requestBuilder.build())
.build();
PresignedGetObjectRequest presignedRequest = session.s3Presigner().presignGetObject(presignRequest);
return presignedRequest.url().toString();
}
private void copyObject(S3FileRuntimeSession session, String sourceKey, String targetKey) {
try {
session.s3Client().copyObject(CopyObjectRequest.builder()
.sourceBucket(session.bucket())
.sourceKey(normalizeObjectKey(sourceKey))
.destinationBucket(session.bucket())
.destinationKey(normalizeObjectKey(targetKey))
.build());
} catch (S3Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File copy failed");
}
}
private void deleteObject(S3FileRuntimeSession session, String objectKey) {
try {
session.s3Client().deleteObject(DeleteObjectRequest.builder()
.bucket(session.bucket())
.key(normalizeObjectKey(objectKey))
.build());
} catch (S3Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File delete failed");
}
}
private void ensureObjectExists(S3FileRuntimeSession session, String objectKey) {
session.s3Client().headObject(HeadObjectRequest.builder()
.bucket(session.bucket())
.key(normalizeObjectKey(objectKey))
.build());
}
private String resolveUploadMethod(PresignedPutObjectRequest presignedRequest) {
if (presignedRequest.httpRequest() == null) {
return "PUT";
}
return presignedRequest.httpRequest().method() == SdkHttpMethod.PUT ? "PUT" : "POST";
}
private String resolveUploadMethod(PresignedUploadPartRequest presignedRequest) {
if (presignedRequest.httpRequest() == null) {
return "PUT";
}
return presignedRequest.httpRequest().method() == SdkHttpMethod.PUT ? "PUT" : presignedRequest.httpRequest().method().name();
}
private Map<String, String> resolveUploadHeaders(PresignedPutObjectRequest presignedRequest, String contentType) {
Map<String, String> headers = flattenSignedHeaders(presignedRequest.signedHeaders());
if (StringUtils.hasText(contentType)) {
headers.put("Content-Type", contentType);
}
return headers;
}
private Map<String, String> flattenSignedHeaders(Map<String, List<String>> signedHeaders) {
Map<String, String> flattened = new HashMap<>();
if (signedHeaders == null) {
return flattened;
}
signedHeaders.forEach((key, values) -> {
if (values != null && !values.isEmpty()) {
flattened.put(key, String.join(",", values));
}
});
return flattened;
}
private String createContentDisposition(String filename) {
return "attachment; filename=\"" + createAsciiFallbackFilename(filename)
+ "\"; filename*=UTF-8''" + URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
}
private String createAsciiFallbackFilename(String filename) {
String fallback = "download";
int dotIndex = filename.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < filename.length() - 1) {
String extension = filename.substring(dotIndex);
if (isSafeAsciiToken(extension)) {
fallback += extension;
}
}
return fallback;
}
private boolean isSafeAsciiToken(String value) {
for (int index = 0; index < value.length(); index++) {
char current = value.charAt(index);
if (current < 33 || current > 126 || current == '"' || current == '\\' || current == ';') {
return false;
}
}
return true;
}
private String resolveTransferObjectKey(String sessionId, String storageName) {
return "transfers/" + normalizeName(sessionId) + "/" + normalizeName(storageName);
}
private String joinObjectKeyParts(String path, String storageName) {
return StringUtils.hasText(path) ? path + "/" + storageName : storageName;
}
private String normalizeObjectKey(String objectKey) {
String cleaned = StringUtils.cleanPath(objectKey == null ? "" : objectKey).replace("\\", "/");
if (!StringUtils.hasText(cleaned) || cleaned.startsWith("/") || cleaned.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "Invalid storage object key");
}
return cleaned;
}
private String normalizeRelativePath(String path) {
String cleaned = StringUtils.cleanPath(path == null ? "" : path).replace("\\", "/");
if (!StringUtils.hasText(cleaned) || "/".equals(cleaned)) {
return "";
}
if (cleaned.startsWith("/")) {
cleaned = cleaned.substring(1);
}
if (cleaned.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "Invalid storage path");
}
return cleaned;
}
private String normalizeName(String name) {
String cleaned = StringUtils.cleanPath(name == null ? "" : name).replace("\\", "/");
if (!StringUtils.hasText(cleaned) || cleaned.startsWith("/") || cleaned.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "Invalid storage filename");
}
return cleaned;
}
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.files.storage;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
record S3FileRuntimeSession(
String bucket,
S3Client s3Client,
S3Presigner s3Presigner
) {
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.files.storage;
@FunctionalInterface
interface S3SessionProvider extends AutoCloseable {
S3FileRuntimeSession currentSession();
@Override
default void close() {
}
}

View File

@@ -0,0 +1,164 @@
package com.yoyuzh.files.tasks;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.files.core.FileMetadataResponse;
import com.yoyuzh.files.core.FileService;
import com.yoyuzh.files.core.StoredFile;
import com.yoyuzh.files.core.StoredFileRepository;
import jakarta.transaction.Transactional;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.Map;
@Component
@Transactional
public class ArchiveBackgroundTaskHandler implements BackgroundTaskHandler {
private final StoredFileRepository storedFileRepository;
private final UserRepository userRepository;
private final FileService fileService;
private final ObjectMapper objectMapper;
public ArchiveBackgroundTaskHandler(StoredFileRepository storedFileRepository,
UserRepository userRepository,
FileService fileService,
ObjectMapper objectMapper) {
this.storedFileRepository = storedFileRepository;
this.userRepository = userRepository;
this.fileService = fileService;
this.objectMapper = objectMapper;
}
@Override
public boolean supports(BackgroundTaskType type) {
return type == BackgroundTaskType.ARCHIVE;
}
@Override
public BackgroundTaskHandlerResult handle(BackgroundTask task) {
return handle(task, publicStatePatch -> {
});
}
@Override
public BackgroundTaskHandlerResult handle(BackgroundTask task, BackgroundTaskProgressReporter progressReporter) {
Map<String, Object> state = parseState(task.getPrivateStateJson(), task.getPublicStateJson());
Long fileId = extractLong(state.get("fileId"));
String outputPath = extractText(state.get("outputPath"));
String outputFilename = extractText(state.get("outputFilename"));
if (fileId == null) {
throw new IllegalStateException("archive task missing fileId");
}
if (!StringUtils.hasText(outputPath) || !StringUtils.hasText(outputFilename)) {
throw new IllegalStateException("archive task missing output target");
}
StoredFile source = storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(fileId, task.getUserId())
.orElseThrow(() -> new IllegalStateException("archive task file not found"));
User user = userRepository.findById(task.getUserId())
.orElseThrow(() -> new IllegalStateException("archive task user not found"));
FileService.ArchiveSourceSummary summary = fileService.summarizeArchiveSource(source);
progressReporter.report(progressPatch(0, summary.fileCount(), 0, summary.directoryCount()));
byte[] archiveBytes = fileService.buildArchiveBytes(source, progress ->
progressReporter.report(progressPatch(
progress.processedFileCount(),
progress.totalFileCount(),
progress.processedDirectoryCount(),
progress.totalDirectoryCount()
)));
FileMetadataResponse archivedFile = fileService.importExternalFile(
user,
outputPath,
outputFilename,
"application/zip",
archiveBytes.length,
archiveBytes
);
Map<String, Object> publicStatePatch = new LinkedHashMap<>();
publicStatePatch.put("worker", "archive");
publicStatePatch.put("archivedFileId", archivedFile.id());
publicStatePatch.put("archivedFilename", archivedFile.filename());
publicStatePatch.put("archivedPath", archivedFile.path());
publicStatePatch.put("archiveSize", archiveBytes.length);
publicStatePatch.putAll(progressPatch(
summary.fileCount(),
summary.fileCount(),
summary.directoryCount(),
summary.directoryCount()
));
return new BackgroundTaskHandlerResult(publicStatePatch);
}
private Map<String, Object> progressPatch(int processedFileCount,
int totalFileCount,
int processedDirectoryCount,
int totalDirectoryCount) {
Map<String, Object> patch = new LinkedHashMap<>();
patch.put("processedFileCount", processedFileCount);
patch.put("totalFileCount", totalFileCount);
patch.put("processedDirectoryCount", processedDirectoryCount);
patch.put("totalDirectoryCount", totalDirectoryCount);
patch.put("progressPercent", calculateProgressPercent(
processedFileCount,
totalFileCount,
processedDirectoryCount,
totalDirectoryCount
));
return patch;
}
private int calculateProgressPercent(int processedFileCount,
int totalFileCount,
int processedDirectoryCount,
int totalDirectoryCount) {
int total = Math.max(0, totalFileCount) + Math.max(0, totalDirectoryCount);
int processed = Math.max(0, processedFileCount) + Math.max(0, processedDirectoryCount);
if (total <= 0) {
return 100;
}
return Math.min(100, (int) Math.floor((processed * 100.0d) / total));
}
private Map<String, Object> parseState(String privateStateJson, String publicStateJson) {
Map<String, Object> state = new LinkedHashMap<>(parseJsonObject(publicStateJson));
state.putAll(parseJsonObject(privateStateJson));
return state;
}
private Map<String, Object> parseJsonObject(String json) {
if (!StringUtils.hasText(json)) {
return Map.of();
}
try {
return objectMapper.readValue(json, new TypeReference<LinkedHashMap<String, Object>>() {
});
} catch (JsonProcessingException ex) {
throw new IllegalStateException("archive task state is invalid", ex);
}
}
private Long extractLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text && StringUtils.hasText(text)) {
return Long.parseLong(text.trim());
}
return null;
}
private String extractText(Object value) {
if (value instanceof String text && StringUtils.hasText(text)) {
return text.trim();
}
return null;
}
}

View File

@@ -0,0 +1,252 @@
package com.yoyuzh.files.tasks;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_background_task", indexes = {
@Index(name = "idx_background_task_user_created_at", columnList = "user_id,created_at"),
@Index(name = "idx_background_task_status_created_at", columnList = "status,created_at"),
@Index(name = "idx_background_task_status_lease_expires_at", columnList = "status,lease_expires_at"),
@Index(name = "idx_background_task_correlation_id", columnList = "correlation_id")
})
public class BackgroundTask {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(name = "task_type", nullable = false, length = 32)
private BackgroundTaskType type;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private BackgroundTaskStatus status;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "public_state_json", nullable = false, length = 8192)
private String publicStateJson;
@Column(name = "private_state_json", nullable = false, length = 8192)
private String privateStateJson;
@Column(name = "correlation_id", length = 128)
private String correlationId;
@Column(name = "error_message", length = 512)
private String errorMessage;
@Column(name = "attempt_count", nullable = false)
private Integer attemptCount;
@Column(name = "max_attempts", nullable = false)
private Integer maxAttempts;
@Column(name = "next_run_at")
private LocalDateTime nextRunAt;
@Column(name = "lease_owner", length = 128)
private String leaseOwner;
@Column(name = "lease_expires_at")
private LocalDateTime leaseExpiresAt;
@Column(name = "heartbeat_at")
private LocalDateTime heartbeatAt;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@Column(name = "finished_at")
private LocalDateTime finishedAt;
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
if (status == null) {
status = BackgroundTaskStatus.QUEUED;
}
if (attemptCount == null) {
attemptCount = 0;
}
if (maxAttempts == null) {
maxAttempts = 1;
}
if (publicStateJson == null) {
publicStateJson = "{}";
}
if (privateStateJson == null) {
privateStateJson = "{}";
}
}
@PreUpdate
public void preUpdate() {
updatedAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public BackgroundTaskType getType() {
return type;
}
public void setType(BackgroundTaskType type) {
this.type = type;
}
public BackgroundTaskStatus getStatus() {
return status;
}
public void setStatus(BackgroundTaskStatus status) {
this.status = status;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getPublicStateJson() {
return publicStateJson;
}
public void setPublicStateJson(String publicStateJson) {
this.publicStateJson = publicStateJson;
}
public String getPrivateStateJson() {
return privateStateJson;
}
public void setPrivateStateJson(String privateStateJson) {
this.privateStateJson = privateStateJson;
}
public String getCorrelationId() {
return correlationId;
}
public void setCorrelationId(String correlationId) {
this.correlationId = correlationId;
}
public String getErrorMessage() {
return errorMessage;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
public Integer getAttemptCount() {
return attemptCount;
}
public void setAttemptCount(Integer attemptCount) {
this.attemptCount = attemptCount;
}
public Integer getMaxAttempts() {
return maxAttempts;
}
public void setMaxAttempts(Integer maxAttempts) {
this.maxAttempts = maxAttempts;
}
public LocalDateTime getNextRunAt() {
return nextRunAt;
}
public void setNextRunAt(LocalDateTime nextRunAt) {
this.nextRunAt = nextRunAt;
}
public String getLeaseOwner() {
return leaseOwner;
}
public void setLeaseOwner(String leaseOwner) {
this.leaseOwner = leaseOwner;
}
public LocalDateTime getLeaseExpiresAt() {
return leaseExpiresAt;
}
public void setLeaseExpiresAt(LocalDateTime leaseExpiresAt) {
this.leaseExpiresAt = leaseExpiresAt;
}
public LocalDateTime getHeartbeatAt() {
return heartbeatAt;
}
public void setHeartbeatAt(LocalDateTime heartbeatAt) {
this.heartbeatAt = heartbeatAt;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public LocalDateTime getFinishedAt() {
return finishedAt;
}
public void setFinishedAt(LocalDateTime finishedAt) {
this.finishedAt = finishedAt;
}
public boolean isTerminal() {
return status == BackgroundTaskStatus.FAILED
|| status == BackgroundTaskStatus.CANCELLED
|| status == BackgroundTaskStatus.COMPLETED;
}
}

View File

@@ -0,0 +1,19 @@
package com.yoyuzh.files.tasks;
public enum BackgroundTaskFailureCategory {
UNSUPPORTED_INPUT(false),
DATA_STATE(false),
TRANSIENT_INFRASTRUCTURE(true),
RATE_LIMITED(true),
UNKNOWN(true);
private final boolean retryable;
BackgroundTaskFailureCategory(boolean retryable) {
this.retryable = retryable;
}
public boolean isRetryable() {
return retryable;
}
}

View File

@@ -0,0 +1,12 @@
package com.yoyuzh.files.tasks;
public interface BackgroundTaskHandler {
boolean supports(BackgroundTaskType type);
BackgroundTaskHandlerResult handle(BackgroundTask task);
default BackgroundTaskHandlerResult handle(BackgroundTask task, BackgroundTaskProgressReporter progressReporter) {
return handle(task);
}
}

View File

@@ -0,0 +1,10 @@
package com.yoyuzh.files.tasks;
import java.util.Map;
public record BackgroundTaskHandlerResult(Map<String, Object> publicStatePatch) {
public static BackgroundTaskHandlerResult empty() {
return new BackgroundTaskHandlerResult(Map.of());
}
}

View File

@@ -0,0 +1,8 @@
package com.yoyuzh.files.tasks;
class BackgroundTaskLeaseLostException extends RuntimeException {
BackgroundTaskLeaseLostException(Long taskId, String workerOwner) {
super("background task lease lost: taskId=" + taskId + ", workerOwner=" + workerOwner);
}
}

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.files.tasks;
import java.util.Map;
@FunctionalInterface
public interface BackgroundTaskProgressReporter {
void report(Map<String, Object> publicStatePatch);
}

View File

@@ -0,0 +1,131 @@
package com.yoyuzh.files.tasks;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface BackgroundTaskRepository extends JpaRepository<BackgroundTask, Long> {
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);
@Query("""
select task.id from BackgroundTask task
where task.status = :status
and (task.nextRunAt is null or task.nextRunAt <= :now)
order by coalesce(task.nextRunAt, task.createdAt) asc, task.createdAt asc
""")
List<Long> findReadyTaskIdsByStatusOrder(@Param("status") BackgroundTaskStatus status,
@Param("now") LocalDateTime now,
Pageable pageable);
@Modifying
@Query("""
update BackgroundTask task
set task.status = :runningStatus,
task.errorMessage = null,
task.nextRunAt = null,
task.attemptCount = task.attemptCount + 1,
task.leaseOwner = :leaseOwner,
task.leaseExpiresAt = :leaseExpiresAt,
task.heartbeatAt = :heartbeatAt,
task.updatedAt = :updatedAt
where task.id = :id
and task.status = :queuedStatus
""")
int claimQueuedTask(@Param("id") Long id,
@Param("queuedStatus") BackgroundTaskStatus queuedStatus,
@Param("runningStatus") BackgroundTaskStatus runningStatus,
@Param("leaseOwner") String leaseOwner,
@Param("leaseExpiresAt") LocalDateTime leaseExpiresAt,
@Param("heartbeatAt") LocalDateTime heartbeatAt,
@Param("updatedAt") LocalDateTime updatedAt);
@Query("""
select task.id from BackgroundTask task
where task.status = :status
and (task.leaseExpiresAt is null or task.leaseExpiresAt <= :now)
order by coalesce(task.leaseExpiresAt, task.updatedAt, task.createdAt) asc
""")
List<Long> findExpiredRunningTaskIds(@Param("status") BackgroundTaskStatus status,
@Param("now") LocalDateTime now,
Pageable pageable);
@Modifying
@Query("""
update BackgroundTask task
set task.status = :queuedStatus,
task.errorMessage = null,
task.finishedAt = null,
task.nextRunAt = null,
task.leaseOwner = null,
task.leaseExpiresAt = null,
task.heartbeatAt = null,
task.updatedAt = :updatedAt
where task.id = :id
and task.status = :runningStatus
and (task.leaseExpiresAt is null or task.leaseExpiresAt <= :now)
""")
int requeueExpiredRunningTask(@Param("id") Long id,
@Param("runningStatus") BackgroundTaskStatus runningStatus,
@Param("queuedStatus") BackgroundTaskStatus queuedStatus,
@Param("now") LocalDateTime now,
@Param("updatedAt") LocalDateTime updatedAt);
@Modifying
@Query("""
update BackgroundTask task
set task.leaseExpiresAt = :leaseExpiresAt,
task.heartbeatAt = :heartbeatAt,
task.updatedAt = :updatedAt
where task.id = :id
and task.status = :runningStatus
and task.leaseOwner = :leaseOwner
""")
int refreshRunningTaskLease(@Param("id") Long id,
@Param("runningStatus") BackgroundTaskStatus runningStatus,
@Param("leaseOwner") String leaseOwner,
@Param("leaseExpiresAt") LocalDateTime leaseExpiresAt,
@Param("heartbeatAt") LocalDateTime heartbeatAt,
@Param("updatedAt") LocalDateTime updatedAt);
}

Some files were not shown because too many files have changed in this diff Show More