Compare commits
17 Commits
main
...
99e00cd7f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e00cd7f7 | ||
|
|
67cd0f6e6f | ||
|
|
3906a523fd | ||
|
|
da576e0253 | ||
|
|
7d6ceaf6d8 | ||
|
|
977eb60b17 | ||
|
|
c5362ebe31 | ||
|
|
3e67760712 | ||
|
|
00b268c30f | ||
|
|
19c296a212 | ||
|
|
6da0d196ee | ||
|
|
f582e600aa | ||
|
|
06a95bc489 | ||
|
|
35b0691188 | ||
|
|
7ddef9bddb | ||
|
|
5802f396c5 | ||
|
|
9d5fdd9ea3 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
3
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
backend/target/
|
||||
data/
|
||||
storage/
|
||||
/storage/
|
||||
/backend/storage/
|
||||
node_modules/
|
||||
output/
|
||||
tmp/
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
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.tasks.BackgroundTask;
|
||||
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 +22,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 +31,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 +61,39 @@ public class AdminController {
|
||||
return ApiResponse.success(adminService.listFiles(page, size, query, ownerQuery));
|
||||
}
|
||||
|
||||
@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 +134,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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,19 @@ import com.yoyuzh.auth.RefreshTokenService;
|
||||
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.files.core.FileBlobRepository;
|
||||
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.policy.StoragePolicy;
|
||||
import com.yoyuzh.files.policy.StoragePolicyRepository;
|
||||
import com.yoyuzh.files.policy.StoragePolicyService;
|
||||
import com.yoyuzh.files.tasks.BackgroundTask;
|
||||
import com.yoyuzh.files.tasks.BackgroundTaskService;
|
||||
import com.yoyuzh.files.tasks.BackgroundTaskType;
|
||||
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
@@ -21,6 +30,7 @@ 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;
|
||||
@@ -40,6 +50,11 @@ public class AdminService {
|
||||
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 BackgroundTaskService backgroundTaskService;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public AdminSummaryResponse getSummary() {
|
||||
@@ -83,6 +98,84 @@ public class AdminService {
|
||||
return new PageResponse<>(items, result.getTotalElements(), page, size);
|
||||
}
|
||||
|
||||
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
|
||||
public AdminStoragePolicyResponse createStoragePolicy(AdminStoragePolicyUpsertRequest request) {
|
||||
StoragePolicy policy = new StoragePolicy();
|
||||
policy.setDefaultPolicy(false);
|
||||
applyStoragePolicyUpsert(policy, request);
|
||||
return toStoragePolicyResponse(storagePolicyRepository.save(policy));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminStoragePolicyResponse updateStoragePolicy(Long policyId, AdminStoragePolicyUpsertRequest request) {
|
||||
StoragePolicy policy = getRequiredStoragePolicy(policyId);
|
||||
applyStoragePolicyUpsert(policy, request);
|
||||
return toStoragePolicyResponse(storagePolicyRepository.save(policy));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
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, "目标存储策略必须处于启用状态");
|
||||
}
|
||||
|
||||
long candidateEntityCount = fileEntityRepository.countByStoragePolicyIdAndEntityType(
|
||||
sourcePolicy.getId(),
|
||||
FileEntityType.VERSION
|
||||
);
|
||||
long candidateStoredFileCount = storedFileEntityRepository.countDistinctStoredFilesByStoragePolicyIdAndEntityType(
|
||||
sourcePolicy.getId(),
|
||||
FileEntityType.VERSION
|
||||
);
|
||||
|
||||
java.util.Map<String, Object> state = new java.util.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");
|
||||
|
||||
java.util.Map<String, Object> privateState = new java.util.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)
|
||||
@@ -180,11 +273,54 @@ 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 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, "用户不存在"));
|
||||
}
|
||||
|
||||
private StoragePolicy getRequiredStoragePolicy(Long policyId) {
|
||||
return storagePolicyRepository.findById(policyId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "存储策略不存在"));
|
||||
}
|
||||
|
||||
private String normalizeQuery(String query) {
|
||||
if (query == null) {
|
||||
return "";
|
||||
@@ -192,6 +328,31 @@ 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 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";
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record AdminStoragePolicyStatusUpdateRequest(
|
||||
@NotNull(message = "enabled 不能为空")
|
||||
Boolean enabled
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
27
backend/src/main/java/com/yoyuzh/api/v2/ApiV2ErrorCode.java
Normal file
27
backend/src/main/java/com/yoyuzh/api/v2/ApiV2ErrorCode.java
Normal 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;
|
||||
}
|
||||
}
|
||||
15
backend/src/main/java/com/yoyuzh/api/v2/ApiV2Exception.java
Normal file
15
backend/src/main/java/com/yoyuzh/api/v2/ApiV2Exception.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
12
backend/src/main/java/com/yoyuzh/api/v2/ApiV2Response.java
Normal file
12
backend/src/main/java/com/yoyuzh/api/v2/ApiV2Response.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.api.v2.shares;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record ImportShareV2Request(
|
||||
@NotBlank String path,
|
||||
String password
|
||||
) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.yoyuzh.api.v2.shares;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record VerifySharePasswordV2Request(
|
||||
@NotBlank String password
|
||||
) {
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.yoyuzh.api.v2.site;
|
||||
|
||||
public record SiteV2PingResponse(String status, String apiVersion) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/*")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
public record DownloadUrlResponse(String url) {
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
163
backend/src/main/java/com/yoyuzh/files/core/FileEntity.java
Normal file
163
backend/src/main/java/com/yoyuzh/files/core/FileEntity.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
public enum FileEntityType {
|
||||
VERSION,
|
||||
THUMBNAIL,
|
||||
LIVE_PHOTO,
|
||||
TRANSCODE,
|
||||
AVATAR
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
131
backend/src/main/java/com/yoyuzh/files/events/FileEvent.java
Normal file
131
backend/src/main/java/com/yoyuzh/files/events/FileEvent.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.files.events;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface FileEventRepository extends JpaRepository<FileEvent, Long> {
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.yoyuzh.files.events;
|
||||
|
||||
public enum FileEventType {
|
||||
CREATED,
|
||||
UPDATED,
|
||||
RENAMED,
|
||||
MOVED,
|
||||
DELETED,
|
||||
RESTORED
|
||||
}
|
||||
207
backend/src/main/java/com/yoyuzh/files/policy/StoragePolicy.java
Normal file
207
backend/src/main/java/com/yoyuzh/files/policy/StoragePolicy.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.yoyuzh.files.policy;
|
||||
|
||||
public enum StoragePolicyCredentialMode {
|
||||
NONE,
|
||||
STATIC,
|
||||
DOGECLOUD_TEMP
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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) : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.files.policy;
|
||||
|
||||
public enum StoragePolicyType {
|
||||
LOCAL,
|
||||
S3_COMPATIBLE
|
||||
}
|
||||
130
backend/src/main/java/com/yoyuzh/files/search/FileMetadata.java
Normal file
130
backend/src/main/java/com/yoyuzh/files/search/FileMetadata.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.share;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.share;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
222
backend/src/main/java/com/yoyuzh/files/share/FileShareLink.java
Normal file
222
backend/src/main/java/com/yoyuzh/files/share/FileShareLink.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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 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);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.share;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
200
backend/src/main/java/com/yoyuzh/files/share/ShareV2Service.java
Normal file
200
backend/src/main/java/com/yoyuzh/files/share/ShareV2Service.java
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.yoyuzh.files.storage;
|
||||
|
||||
public record MultipartCompletedPart(
|
||||
int partNumber,
|
||||
String etag
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yoyuzh.files.storage;
|
||||
|
||||
@FunctionalInterface
|
||||
interface S3SessionProvider extends AutoCloseable {
|
||||
|
||||
S3FileRuntimeSession currentSession();
|
||||
|
||||
@Override
|
||||
default void close() {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
252
backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTask.java
Normal file
252
backend/src/main/java/com/yoyuzh/files/tasks/BackgroundTask.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.files.tasks;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface BackgroundTaskProgressReporter {
|
||||
|
||||
void report(Map<String, Object> publicStatePatch);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
|
||||
Optional<BackgroundTask> findByIdAndUserId(Long id, Long userId);
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
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.api.v2.ApiV2ErrorCode;
|
||||
import com.yoyuzh.api.v2.ApiV2Exception;
|
||||
import com.yoyuzh.auth.User;
|
||||
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.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BackgroundTaskService {
|
||||
|
||||
static final String STATE_PHASE_KEY = "phase";
|
||||
static final String STATE_ATTEMPT_COUNT_KEY = "attemptCount";
|
||||
static final String STATE_MAX_ATTEMPTS_KEY = "maxAttempts";
|
||||
static final String STATE_RETRY_SCHEDULED_KEY = "retryScheduled";
|
||||
static final String STATE_NEXT_RETRY_AT_KEY = "nextRetryAt";
|
||||
static final String STATE_RETRY_DELAY_SECONDS_KEY = "retryDelaySeconds";
|
||||
static final String STATE_LAST_FAILURE_MESSAGE_KEY = "lastFailureMessage";
|
||||
static final String STATE_LAST_FAILURE_AT_KEY = "lastFailureAt";
|
||||
static final String STATE_FAILURE_CATEGORY_KEY = "failureCategory";
|
||||
static final String STATE_WORKER_OWNER_KEY = "workerOwner";
|
||||
static final String STATE_HEARTBEAT_AT_KEY = "heartbeatAt";
|
||||
static final String STATE_LEASE_EXPIRES_AT_KEY = "leaseExpiresAt";
|
||||
static final String STATE_STARTED_AT_KEY = "startedAt";
|
||||
|
||||
private static final List<String> ZIP_COMPATIBLE_EXTENSIONS = List.of(".zip", ".jar", ".war");
|
||||
private static final List<String> MEDIA_EXTENSIONS = List.of(
|
||||
".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg",
|
||||
".mp4", ".mov", ".mkv", ".webm", ".avi",
|
||||
".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a"
|
||||
);
|
||||
private static final List<String> RETRY_TRANSIENT_STATE_KEYS = List.of(
|
||||
STATE_RETRY_SCHEDULED_KEY,
|
||||
STATE_NEXT_RETRY_AT_KEY,
|
||||
STATE_RETRY_DELAY_SECONDS_KEY,
|
||||
STATE_LAST_FAILURE_MESSAGE_KEY,
|
||||
STATE_LAST_FAILURE_AT_KEY,
|
||||
STATE_FAILURE_CATEGORY_KEY
|
||||
);
|
||||
private static final List<String> RUNNING_TRANSIENT_STATE_KEYS = List.of(
|
||||
STATE_WORKER_OWNER_KEY,
|
||||
STATE_LEASE_EXPIRES_AT_KEY
|
||||
);
|
||||
private static final int EXPIRED_RUNNING_TASK_BATCH_SIZE = 100;
|
||||
|
||||
private final BackgroundTaskRepository backgroundTaskRepository;
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
public BackgroundTask createQueuedFileTask(User user,
|
||||
BackgroundTaskType type,
|
||||
Long fileId,
|
||||
String requestedPath,
|
||||
String correlationId) {
|
||||
StoredFile file = storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(fileId, user.getId())
|
||||
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "file not found"));
|
||||
String logicalPath = buildLogicalPath(file);
|
||||
if (!logicalPath.equals(normalizeLogicalPath(requestedPath))) {
|
||||
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "task path does not match file path");
|
||||
}
|
||||
validateTaskTarget(type, file);
|
||||
|
||||
Map<String, Object> publicState = fileState(file, logicalPath);
|
||||
Map<String, Object> privateState = new LinkedHashMap<>(publicState);
|
||||
privateState.put("taskType", type.name());
|
||||
if (type == BackgroundTaskType.ARCHIVE) {
|
||||
String outputPath = file.getPath();
|
||||
String outputFilename = file.getFilename() + ".zip";
|
||||
publicState.put("outputPath", outputPath);
|
||||
publicState.put("outputFilename", outputFilename);
|
||||
privateState.put("outputPath", outputPath);
|
||||
privateState.put("outputFilename", outputFilename);
|
||||
} else if (type == BackgroundTaskType.EXTRACT) {
|
||||
String outputPath = file.getPath();
|
||||
String outputDirectoryName = deriveExtractOutputDirectoryName(file.getFilename());
|
||||
publicState.put("outputPath", outputPath);
|
||||
publicState.put("outputDirectoryName", outputDirectoryName);
|
||||
privateState.put("outputPath", outputPath);
|
||||
privateState.put("outputDirectoryName", outputDirectoryName);
|
||||
}
|
||||
return createQueuedTask(user, type, publicState, privateState, correlationId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BackgroundTask createQueuedTask(User user,
|
||||
BackgroundTaskType type,
|
||||
Map<String, Object> publicState,
|
||||
Map<String, Object> privateState,
|
||||
String correlationId) {
|
||||
BackgroundTask task = new BackgroundTask();
|
||||
task.setUserId(user.getId());
|
||||
task.setType(type);
|
||||
task.setStatus(BackgroundTaskStatus.QUEUED);
|
||||
task.setAttemptCount(0);
|
||||
task.setMaxAttempts(resolveMaxAttempts(type));
|
||||
task.setNextRunAt(null);
|
||||
Map<String, Object> nextPublicState = new LinkedHashMap<>(publicState == null ? Map.of() : publicState);
|
||||
nextPublicState.put(STATE_PHASE_KEY, "queued");
|
||||
nextPublicState.putAll(retryStatePatch(task.getAttemptCount(), task.getMaxAttempts()));
|
||||
task.setPublicStateJson(toJson(nextPublicState));
|
||||
task.setPrivateStateJson(toJson(privateState));
|
||||
task.setCorrelationId(normalizeCorrelationId(correlationId));
|
||||
return backgroundTaskRepository.save(task);
|
||||
}
|
||||
|
||||
public Page<BackgroundTask> listOwnedTasks(User user, Pageable pageable) {
|
||||
return backgroundTaskRepository.findByUserIdOrderByCreatedAtDesc(user.getId(), pageable);
|
||||
}
|
||||
|
||||
public BackgroundTask getOwnedTask(User user, Long id) {
|
||||
return backgroundTaskRepository.findByIdAndUserId(id, user.getId())
|
||||
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BackgroundTask cancelOwnedTask(User user, Long id) {
|
||||
BackgroundTask task = getOwnedTask(user, id);
|
||||
if (task.isTerminal()) {
|
||||
return task;
|
||||
}
|
||||
|
||||
if (task.getStatus() == BackgroundTaskStatus.QUEUED || task.getStatus() == BackgroundTaskStatus.RUNNING) {
|
||||
task.setStatus(BackgroundTaskStatus.CANCELLED);
|
||||
task.setNextRunAt(null);
|
||||
clearLease(task);
|
||||
task.setPublicStateJson(mergePublicStateJson(
|
||||
task.getPublicStateJson(),
|
||||
Map.of(
|
||||
STATE_PHASE_KEY, "cancelled",
|
||||
STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(),
|
||||
STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts(),
|
||||
STATE_HEARTBEAT_AT_KEY, LocalDateTime.now().toString()
|
||||
),
|
||||
removableStateKeys(RETRY_TRANSIENT_STATE_KEYS, RUNNING_TRANSIENT_STATE_KEYS)
|
||||
));
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
task.setErrorMessage(null);
|
||||
return backgroundTaskRepository.save(task);
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BackgroundTask retryOwnedTask(User user, Long id) {
|
||||
BackgroundTask task = getOwnedTask(user, id);
|
||||
if (task.getStatus() != BackgroundTaskStatus.FAILED) {
|
||||
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "only failed tasks can be retried");
|
||||
}
|
||||
|
||||
task.setAttemptCount(0);
|
||||
task.setNextRunAt(null);
|
||||
clearLease(task);
|
||||
task.setPublicStateJson(resetPublicStateForRetry(task.getPrivateStateJson(), task.getAttemptCount(), task.getMaxAttempts()));
|
||||
task.setStatus(BackgroundTaskStatus.QUEUED);
|
||||
task.setFinishedAt(null);
|
||||
task.setErrorMessage(null);
|
||||
return backgroundTaskRepository.save(task);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BackgroundTask markRunning(User user, Long id) {
|
||||
BackgroundTask task = getOwnedTask(user, id);
|
||||
if (task.isTerminal()) {
|
||||
return task;
|
||||
}
|
||||
task.setStatus(BackgroundTaskStatus.RUNNING);
|
||||
task.setPublicStateJson(mergePublicStateJson(
|
||||
task.getPublicStateJson(),
|
||||
Map.of(
|
||||
STATE_PHASE_KEY, "running",
|
||||
STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(),
|
||||
STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts()
|
||||
),
|
||||
List.of(STATE_RETRY_SCHEDULED_KEY, STATE_NEXT_RETRY_AT_KEY)
|
||||
));
|
||||
return backgroundTaskRepository.save(task);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BackgroundTask markCompleted(User user, Long id) {
|
||||
BackgroundTask task = getOwnedTask(user, id);
|
||||
if (task.isTerminal()) {
|
||||
return task;
|
||||
}
|
||||
task.setStatus(BackgroundTaskStatus.COMPLETED);
|
||||
task.setNextRunAt(null);
|
||||
clearLease(task);
|
||||
task.setPublicStateJson(mergePublicStateJson(
|
||||
task.getPublicStateJson(),
|
||||
Map.of(
|
||||
STATE_PHASE_KEY, "completed",
|
||||
STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(),
|
||||
STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts(),
|
||||
STATE_HEARTBEAT_AT_KEY, LocalDateTime.now().toString()
|
||||
),
|
||||
removableStateKeys(RETRY_TRANSIENT_STATE_KEYS, RUNNING_TRANSIENT_STATE_KEYS)
|
||||
));
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
task.setErrorMessage(null);
|
||||
return backgroundTaskRepository.save(task);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BackgroundTask markFailed(User user, Long id, String errorMessage) {
|
||||
BackgroundTask task = getOwnedTask(user, id);
|
||||
if (task.isTerminal()) {
|
||||
return task;
|
||||
}
|
||||
task.setStatus(BackgroundTaskStatus.FAILED);
|
||||
task.setNextRunAt(null);
|
||||
clearLease(task);
|
||||
task.setPublicStateJson(mergePublicStateJson(
|
||||
task.getPublicStateJson(),
|
||||
Map.of(
|
||||
STATE_PHASE_KEY, "failed",
|
||||
STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(),
|
||||
STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts(),
|
||||
STATE_LAST_FAILURE_MESSAGE_KEY, StringUtils.hasText(errorMessage) ? errorMessage.trim() : "task failed",
|
||||
STATE_LAST_FAILURE_AT_KEY, LocalDateTime.now().toString(),
|
||||
STATE_FAILURE_CATEGORY_KEY, BackgroundTaskFailureCategory.UNKNOWN.name(),
|
||||
STATE_HEARTBEAT_AT_KEY, LocalDateTime.now().toString()
|
||||
),
|
||||
removableStateKeys(List.of(STATE_RETRY_SCHEDULED_KEY, STATE_NEXT_RETRY_AT_KEY), RUNNING_TRANSIENT_STATE_KEYS)
|
||||
));
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
task.setErrorMessage(StringUtils.hasText(errorMessage) ? errorMessage.trim() : "task failed");
|
||||
return backgroundTaskRepository.save(task);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int requeueExpiredRunningTasks() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
int recovered = 0;
|
||||
for (Long taskId : backgroundTaskRepository.findExpiredRunningTaskIds(
|
||||
BackgroundTaskStatus.RUNNING,
|
||||
now,
|
||||
PageRequest.of(0, EXPIRED_RUNNING_TASK_BATCH_SIZE)
|
||||
)) {
|
||||
int requeued = backgroundTaskRepository.requeueExpiredRunningTask(
|
||||
taskId,
|
||||
BackgroundTaskStatus.RUNNING,
|
||||
BackgroundTaskStatus.QUEUED,
|
||||
now,
|
||||
now
|
||||
);
|
||||
if (requeued != 1) {
|
||||
continue;
|
||||
}
|
||||
BackgroundTask task = backgroundTaskRepository.findById(taskId)
|
||||
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found"));
|
||||
resetTaskToQueued(task);
|
||||
backgroundTaskRepository.save(task);
|
||||
recovered += 1;
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
public List<Long> findQueuedTaskIds(int limit) {
|
||||
if (limit <= 0) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
return backgroundTaskRepository.findReadyTaskIdsByStatusOrder(
|
||||
BackgroundTaskStatus.QUEUED,
|
||||
LocalDateTime.now(),
|
||||
PageRequest.of(0, limit)
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Optional<BackgroundTask> claimQueuedTask(Long id, String workerOwner, long leaseDurationSeconds) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime leaseExpiresAt = now.plusSeconds(Math.max(30L, leaseDurationSeconds));
|
||||
int claimed = backgroundTaskRepository.claimQueuedTask(
|
||||
id,
|
||||
BackgroundTaskStatus.QUEUED,
|
||||
BackgroundTaskStatus.RUNNING,
|
||||
workerOwner,
|
||||
leaseExpiresAt,
|
||||
now,
|
||||
now
|
||||
);
|
||||
if (claimed != 1) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Optional<BackgroundTask> task = backgroundTaskRepository.findById(id);
|
||||
task.ifPresent(claimedTask -> {
|
||||
claimedTask.setLeaseOwner(workerOwner);
|
||||
claimedTask.setLeaseExpiresAt(leaseExpiresAt);
|
||||
claimedTask.setHeartbeatAt(now);
|
||||
claimedTask.setPublicStateJson(mergePublicStateJson(
|
||||
claimedTask.getPublicStateJson(),
|
||||
runningStatePatch(claimedTask, workerOwner, now, leaseExpiresAt, true),
|
||||
RETRY_TRANSIENT_STATE_KEYS
|
||||
));
|
||||
});
|
||||
task.ifPresent(backgroundTaskRepository::save);
|
||||
return task;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BackgroundTask markWorkerTaskProgress(Long id,
|
||||
String workerOwner,
|
||||
Map<String, Object> publicStatePatch,
|
||||
long leaseDurationSeconds) {
|
||||
LeaseTouch leaseTouch = refreshLease(id, workerOwner, leaseDurationSeconds);
|
||||
BackgroundTask task = backgroundTaskRepository.findById(id)
|
||||
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found"));
|
||||
task.setLeaseOwner(workerOwner);
|
||||
task.setLeaseExpiresAt(leaseTouch.leaseExpiresAt());
|
||||
task.setHeartbeatAt(leaseTouch.now());
|
||||
Map<String, Object> nextPatch = new LinkedHashMap<>(runningStatePatch(
|
||||
task,
|
||||
workerOwner,
|
||||
leaseTouch.now(),
|
||||
leaseTouch.leaseExpiresAt(),
|
||||
false
|
||||
));
|
||||
if (publicStatePatch != null) {
|
||||
nextPatch.putAll(publicStatePatch);
|
||||
}
|
||||
task.setPublicStateJson(mergePublicStateJson(task.getPublicStateJson(), nextPatch));
|
||||
return backgroundTaskRepository.save(task);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BackgroundTask markWorkerTaskCompleted(Long id,
|
||||
String workerOwner,
|
||||
Map<String, Object> publicStatePatch,
|
||||
long leaseDurationSeconds) {
|
||||
LeaseTouch leaseTouch = refreshLease(id, workerOwner, leaseDurationSeconds);
|
||||
BackgroundTask task = backgroundTaskRepository.findById(id)
|
||||
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found"));
|
||||
Map<String, Object> nextPatch = new LinkedHashMap<>(publicStatePatch == null ? Map.of() : publicStatePatch);
|
||||
nextPatch.put(STATE_PHASE_KEY, "completed");
|
||||
nextPatch.put(STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount());
|
||||
nextPatch.put(STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts());
|
||||
nextPatch.put(STATE_HEARTBEAT_AT_KEY, leaseTouch.now().toString());
|
||||
task.setPublicStateJson(mergePublicStateJson(
|
||||
task.getPublicStateJson(),
|
||||
nextPatch,
|
||||
removableStateKeys(RETRY_TRANSIENT_STATE_KEYS, RUNNING_TRANSIENT_STATE_KEYS)
|
||||
));
|
||||
task.setStatus(BackgroundTaskStatus.COMPLETED);
|
||||
task.setNextRunAt(null);
|
||||
clearLease(task);
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
task.setErrorMessage(null);
|
||||
return backgroundTaskRepository.save(task);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BackgroundTask markWorkerTaskFailed(Long id,
|
||||
String workerOwner,
|
||||
String errorMessage,
|
||||
BackgroundTaskFailureCategory failureCategory,
|
||||
long leaseDurationSeconds) {
|
||||
LeaseTouch leaseTouch = refreshLease(id, workerOwner, leaseDurationSeconds);
|
||||
BackgroundTask task = backgroundTaskRepository.findById(id)
|
||||
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found"));
|
||||
String normalizedErrorMessage = StringUtils.hasText(errorMessage) ? errorMessage.trim() : "task failed";
|
||||
LocalDateTime now = leaseTouch.now();
|
||||
if (failureCategory.isRetryable() && hasRemainingAttempts(task)) {
|
||||
long retryDelaySeconds = resolveRetryDelaySeconds(task.getType(), failureCategory, task.getAttemptCount());
|
||||
LocalDateTime nextRunAt = now.plusSeconds(retryDelaySeconds);
|
||||
task.setStatus(BackgroundTaskStatus.QUEUED);
|
||||
task.setNextRunAt(nextRunAt);
|
||||
clearLease(task);
|
||||
task.setFinishedAt(null);
|
||||
task.setErrorMessage(null);
|
||||
task.setPublicStateJson(mergePublicStateJson(
|
||||
task.getPublicStateJson(),
|
||||
Map.of(
|
||||
STATE_PHASE_KEY, "queued",
|
||||
STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(),
|
||||
STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts(),
|
||||
STATE_RETRY_SCHEDULED_KEY, true,
|
||||
STATE_NEXT_RETRY_AT_KEY, nextRunAt.toString(),
|
||||
STATE_RETRY_DELAY_SECONDS_KEY, retryDelaySeconds,
|
||||
STATE_LAST_FAILURE_MESSAGE_KEY, normalizedErrorMessage,
|
||||
STATE_LAST_FAILURE_AT_KEY, now.toString(),
|
||||
STATE_FAILURE_CATEGORY_KEY, failureCategory.name(),
|
||||
STATE_HEARTBEAT_AT_KEY, now.toString()
|
||||
),
|
||||
RUNNING_TRANSIENT_STATE_KEYS
|
||||
));
|
||||
return backgroundTaskRepository.save(task);
|
||||
}
|
||||
|
||||
task.setNextRunAt(null);
|
||||
clearLease(task);
|
||||
task.setPublicStateJson(mergePublicStateJson(
|
||||
task.getPublicStateJson(),
|
||||
Map.of(
|
||||
STATE_PHASE_KEY, "failed",
|
||||
STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(),
|
||||
STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts(),
|
||||
STATE_LAST_FAILURE_MESSAGE_KEY, normalizedErrorMessage,
|
||||
STATE_LAST_FAILURE_AT_KEY, now.toString(),
|
||||
STATE_FAILURE_CATEGORY_KEY, failureCategory.name(),
|
||||
STATE_HEARTBEAT_AT_KEY, now.toString()
|
||||
),
|
||||
removableStateKeys(List.of(STATE_RETRY_SCHEDULED_KEY, STATE_NEXT_RETRY_AT_KEY, STATE_RETRY_DELAY_SECONDS_KEY), RUNNING_TRANSIENT_STATE_KEYS)
|
||||
));
|
||||
task.setStatus(BackgroundTaskStatus.FAILED);
|
||||
task.setFinishedAt(now);
|
||||
task.setErrorMessage(normalizedErrorMessage);
|
||||
return backgroundTaskRepository.save(task);
|
||||
}
|
||||
|
||||
private String normalizeCorrelationId(String correlationId) {
|
||||
if (StringUtils.hasText(correlationId)) {
|
||||
return correlationId.trim();
|
||||
}
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
private void validateTaskTarget(BackgroundTaskType type, StoredFile file) {
|
||||
if (type == BackgroundTaskType.ARCHIVE) {
|
||||
return;
|
||||
}
|
||||
if (file.isDirectory()) {
|
||||
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "task target type is not supported");
|
||||
}
|
||||
if (type == BackgroundTaskType.EXTRACT && !isZipCompatibleArchive(file)) {
|
||||
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "extract task only supports zip-compatible archives");
|
||||
}
|
||||
if (type == BackgroundTaskType.MEDIA_META && !isMediaLike(file)) {
|
||||
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "media metadata task only supports media files");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> fileState(StoredFile file, String logicalPath) {
|
||||
Map<String, Object> state = new LinkedHashMap<>();
|
||||
state.put("fileId", file.getId());
|
||||
state.put("path", logicalPath);
|
||||
state.put("filename", file.getFilename());
|
||||
state.put("directory", file.isDirectory());
|
||||
state.put("contentType", file.getContentType());
|
||||
state.put("size", file.getSize());
|
||||
return state;
|
||||
}
|
||||
|
||||
private boolean isZipCompatibleArchive(StoredFile file) {
|
||||
String contentType = normalizeContentType(file.getContentType());
|
||||
if (contentType.contains("zip") || contentType.contains("java-archive")) {
|
||||
return true;
|
||||
}
|
||||
return hasExtension(file.getFilename(), ZIP_COMPATIBLE_EXTENSIONS);
|
||||
}
|
||||
|
||||
private boolean isMediaLike(StoredFile file) {
|
||||
String contentType = normalizeContentType(file.getContentType());
|
||||
if (contentType.startsWith("image/") || contentType.startsWith("video/") || contentType.startsWith("audio/")) {
|
||||
return true;
|
||||
}
|
||||
return hasExtension(file.getFilename(), MEDIA_EXTENSIONS);
|
||||
}
|
||||
|
||||
private String deriveExtractOutputDirectoryName(String filename) {
|
||||
if (!StringUtils.hasText(filename)) {
|
||||
return "extracted";
|
||||
}
|
||||
String trimmed = filename.trim();
|
||||
String lower = trimmed.toLowerCase(Locale.ROOT);
|
||||
for (String extension : ZIP_COMPATIBLE_EXTENSIONS) {
|
||||
if (lower.endsWith(extension) && trimmed.length() > extension.length()) {
|
||||
return trimmed.substring(0, trimmed.length() - extension.length());
|
||||
}
|
||||
}
|
||||
int lastDot = trimmed.lastIndexOf('.');
|
||||
if (lastDot > 0) {
|
||||
return trimmed.substring(0, lastDot);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private boolean hasExtension(String filename, List<String> extensions) {
|
||||
if (!StringUtils.hasText(filename)) {
|
||||
return false;
|
||||
}
|
||||
String normalized = filename.toLowerCase(Locale.ROOT);
|
||||
return extensions.stream().anyMatch(normalized::endsWith);
|
||||
}
|
||||
|
||||
private String normalizeContentType(String contentType) {
|
||||
if (!StringUtils.hasText(contentType)) {
|
||||
return "";
|
||||
}
|
||||
return contentType.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String buildLogicalPath(StoredFile file) {
|
||||
String parent = normalizeLogicalPath(file.getPath());
|
||||
if ("/".equals(parent)) {
|
||||
return "/" + file.getFilename();
|
||||
}
|
||||
return parent + "/" + file.getFilename();
|
||||
}
|
||||
|
||||
private String normalizeLogicalPath(String path) {
|
||||
if (!StringUtils.hasText(path)) {
|
||||
return "/";
|
||||
}
|
||||
String normalized = path.trim().replace('\\', '/');
|
||||
while (normalized.contains("//")) {
|
||||
normalized = normalized.replace("//", "/");
|
||||
}
|
||||
if (!normalized.startsWith("/")) {
|
||||
normalized = "/" + normalized;
|
||||
}
|
||||
while (normalized.length() > 1 && normalized.endsWith("/")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String toJson(Map<String, Object> value) {
|
||||
Map<String, Object> safeValue = value == null ? new LinkedHashMap<>() : new LinkedHashMap<>(value);
|
||||
try {
|
||||
return objectMapper.writeValueAsString(safeValue);
|
||||
} catch (JsonProcessingException ex) {
|
||||
throw new IllegalStateException("Failed to serialize background task state", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> parseJsonObject(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
try {
|
||||
return objectMapper.readValue(value, new TypeReference<LinkedHashMap<String, Object>>() {
|
||||
});
|
||||
} catch (JsonProcessingException ex) {
|
||||
throw new IllegalStateException("Failed to parse background task state", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String mergePublicStateJson(String currentValue, Map<String, Object> patch) {
|
||||
return mergePublicStateJson(currentValue, patch, List.of());
|
||||
}
|
||||
|
||||
private String mergePublicStateJson(String currentValue, Map<String, Object> patch, List<String> keysToRemove) {
|
||||
Map<String, Object> nextPublicState = parseJsonObject(currentValue);
|
||||
if (keysToRemove != null) {
|
||||
keysToRemove.forEach(nextPublicState::remove);
|
||||
}
|
||||
if (patch != null) {
|
||||
nextPublicState.putAll(patch);
|
||||
}
|
||||
return toJson(nextPublicState);
|
||||
}
|
||||
|
||||
private String resetPublicStateForRetry(String privateStateJson, int attemptCount, int maxAttempts) {
|
||||
Map<String, Object> nextPublicState = parseJsonObject(privateStateJson);
|
||||
nextPublicState.remove("taskType");
|
||||
nextPublicState.put(STATE_PHASE_KEY, "queued");
|
||||
nextPublicState.putAll(retryStatePatch(attemptCount, maxAttempts));
|
||||
return toJson(nextPublicState);
|
||||
}
|
||||
|
||||
private void resetTaskToQueued(BackgroundTask task) {
|
||||
task.setNextRunAt(null);
|
||||
clearLease(task);
|
||||
task.setPublicStateJson(resetPublicStateForRetry(task.getPrivateStateJson(), task.getAttemptCount(), task.getMaxAttempts()));
|
||||
task.setStatus(BackgroundTaskStatus.QUEUED);
|
||||
task.setFinishedAt(null);
|
||||
task.setErrorMessage(null);
|
||||
}
|
||||
|
||||
private int resolveMaxAttempts(BackgroundTaskType type) {
|
||||
return switch (type) {
|
||||
case ARCHIVE -> 4;
|
||||
case EXTRACT -> 3;
|
||||
case MEDIA_META -> 2;
|
||||
default -> 1;
|
||||
};
|
||||
}
|
||||
|
||||
private Map<String, Object> retryStatePatch(int attemptCount, int maxAttempts) {
|
||||
Map<String, Object> patch = new LinkedHashMap<>();
|
||||
patch.put(STATE_ATTEMPT_COUNT_KEY, attemptCount);
|
||||
patch.put(STATE_MAX_ATTEMPTS_KEY, maxAttempts);
|
||||
return patch;
|
||||
}
|
||||
|
||||
private boolean hasRemainingAttempts(BackgroundTask task) {
|
||||
return task.getAttemptCount() != null
|
||||
&& task.getMaxAttempts() != null
|
||||
&& task.getAttemptCount() < task.getMaxAttempts();
|
||||
}
|
||||
|
||||
private long resolveRetryDelaySeconds(BackgroundTaskType type,
|
||||
BackgroundTaskFailureCategory failureCategory,
|
||||
Integer attemptCount) {
|
||||
int safeAttemptCount = attemptCount == null ? 1 : Math.max(1, attemptCount);
|
||||
long baseDelaySeconds = switch (type) {
|
||||
case ARCHIVE -> 30L;
|
||||
case EXTRACT -> 45L;
|
||||
case MEDIA_META -> 15L;
|
||||
default -> 30L;
|
||||
};
|
||||
if (failureCategory == BackgroundTaskFailureCategory.RATE_LIMITED) {
|
||||
baseDelaySeconds *= 4L;
|
||||
} else if (failureCategory == BackgroundTaskFailureCategory.UNKNOWN) {
|
||||
baseDelaySeconds *= 2L;
|
||||
}
|
||||
long delay = baseDelaySeconds * (1L << Math.min(safeAttemptCount - 1, 2));
|
||||
return Math.min(delay, baseDelaySeconds * 4L);
|
||||
}
|
||||
|
||||
private LeaseTouch refreshLease(Long id, String workerOwner, long leaseDurationSeconds) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime leaseExpiresAt = now.plusSeconds(Math.max(30L, leaseDurationSeconds));
|
||||
int refreshed = backgroundTaskRepository.refreshRunningTaskLease(
|
||||
id,
|
||||
BackgroundTaskStatus.RUNNING,
|
||||
workerOwner,
|
||||
leaseExpiresAt,
|
||||
now,
|
||||
now
|
||||
);
|
||||
if (refreshed != 1) {
|
||||
throw new BackgroundTaskLeaseLostException(id, workerOwner);
|
||||
}
|
||||
return new LeaseTouch(now, leaseExpiresAt);
|
||||
}
|
||||
|
||||
private Map<String, Object> runningStatePatch(BackgroundTask task,
|
||||
String workerOwner,
|
||||
LocalDateTime heartbeatAt,
|
||||
LocalDateTime leaseExpiresAt,
|
||||
boolean includeStartedAt) {
|
||||
Map<String, Object> patch = new LinkedHashMap<>();
|
||||
patch.put(STATE_PHASE_KEY, "running");
|
||||
patch.put(STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount());
|
||||
patch.put(STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts());
|
||||
patch.put(STATE_WORKER_OWNER_KEY, workerOwner);
|
||||
patch.put(STATE_HEARTBEAT_AT_KEY, heartbeatAt.toString());
|
||||
patch.put(STATE_LEASE_EXPIRES_AT_KEY, leaseExpiresAt.toString());
|
||||
if (includeStartedAt) {
|
||||
patch.put(STATE_STARTED_AT_KEY, heartbeatAt.toString());
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
private List<String> removableStateKeys(List<String> primary, List<String> secondary) {
|
||||
List<String> keys = new java.util.ArrayList<>(primary);
|
||||
keys.addAll(secondary);
|
||||
return keys;
|
||||
}
|
||||
|
||||
private void clearLease(BackgroundTask task) {
|
||||
task.setLeaseOwner(null);
|
||||
task.setLeaseExpiresAt(null);
|
||||
task.setHeartbeatAt(null);
|
||||
}
|
||||
|
||||
private record LeaseTouch(LocalDateTime now, LocalDateTime leaseExpiresAt) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.yoyuzh.files.tasks;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class BackgroundTaskStartupRecovery {
|
||||
|
||||
private final BackgroundTaskService backgroundTaskService;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void recoverOnStartup() {
|
||||
int recovered = backgroundTaskService.requeueExpiredRunningTasks();
|
||||
if (recovered > 0) {
|
||||
log.warn("Recovered {} expired RUNNING background task leases back to QUEUED on startup", recovered);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.files.tasks;
|
||||
|
||||
public enum BackgroundTaskStatus {
|
||||
QUEUED,
|
||||
RUNNING,
|
||||
FAILED,
|
||||
CANCELLED,
|
||||
COMPLETED
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.files.tasks;
|
||||
|
||||
public enum BackgroundTaskType {
|
||||
ARCHIVE,
|
||||
EXTRACT,
|
||||
STORAGE_POLICY_MIGRATION,
|
||||
THUMBNAIL,
|
||||
MEDIA_META,
|
||||
REMOTE_DOWNLOAD,
|
||||
HLS_TRANSCODE,
|
||||
CLEANUP
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user