feat(files): add v2 task and metadata workflows
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
package com.yoyuzh.api.v2.files;
|
||||
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.files.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.FileMetadataResponse;
|
||||
import com.yoyuzh.files.FileSearchQuery;
|
||||
import com.yoyuzh.files.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,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,76 @@
|
||||
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.FileMetadataResponse;
|
||||
import com.yoyuzh.files.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.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));
|
||||
}
|
||||
|
||||
@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.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,20 @@
|
||||
package com.yoyuzh.api.v2.tasks;
|
||||
|
||||
import com.yoyuzh.files.BackgroundTaskStatus;
|
||||
import com.yoyuzh.files.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,112 @@
|
||||
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.BackgroundTask;
|
||||
import com.yoyuzh.files.BackgroundTaskService;
|
||||
import com.yoyuzh.files.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("/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
|
||||
) {
|
||||
}
|
||||
@@ -54,8 +54,22 @@ public class SecurityConfig {
|
||||
.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/*")
|
||||
|
||||
179
backend/src/main/java/com/yoyuzh/files/BackgroundTask.java
Normal file
179
backend/src/main/java/com/yoyuzh/files/BackgroundTask.java
Normal file
@@ -0,0 +1,179 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
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_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 = "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 (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 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,8 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
public interface BackgroundTaskHandler {
|
||||
|
||||
boolean supports(BackgroundTaskType type);
|
||||
|
||||
BackgroundTaskHandlerResult handle(BackgroundTask task);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record BackgroundTaskHandlerResult(Map<String, Object> publicStatePatch) {
|
||||
|
||||
public static BackgroundTaskHandlerResult empty() {
|
||||
return new BackgroundTaskHandlerResult(Map.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
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);
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
update BackgroundTask task
|
||||
set task.status = :runningStatus,
|
||||
task.errorMessage = null,
|
||||
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("updatedAt") LocalDateTime updatedAt);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
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 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 {
|
||||
|
||||
private static final List<String> ARCHIVE_EXTENSIONS = List.of(
|
||||
".zip", ".jar", ".war", ".7z", ".rar", ".tar", ".gz", ".tgz", ".bz2", ".xz"
|
||||
);
|
||||
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 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());
|
||||
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.setPublicStateJson(toJson(publicState));
|
||||
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.setFinishedAt(LocalDateTime.now());
|
||||
task.setErrorMessage(null);
|
||||
return backgroundTaskRepository.save(task);
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BackgroundTask markRunning(User user, Long id) {
|
||||
BackgroundTask task = getOwnedTask(user, id);
|
||||
if (task.isTerminal()) {
|
||||
return task;
|
||||
}
|
||||
task.setStatus(BackgroundTaskStatus.RUNNING);
|
||||
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.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.setFinishedAt(LocalDateTime.now());
|
||||
task.setErrorMessage(StringUtils.hasText(errorMessage) ? errorMessage.trim() : "task failed");
|
||||
return backgroundTaskRepository.save(task);
|
||||
}
|
||||
|
||||
public List<Long> findQueuedTaskIds(int limit) {
|
||||
if (limit <= 0) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
return backgroundTaskRepository.findByStatusOrderByCreatedAtAsc(
|
||||
BackgroundTaskStatus.QUEUED,
|
||||
PageRequest.of(0, limit)
|
||||
)
|
||||
.stream()
|
||||
.map(BackgroundTask::getId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Optional<BackgroundTask> claimQueuedTask(Long id) {
|
||||
int claimed = backgroundTaskRepository.claimQueuedTask(
|
||||
id,
|
||||
BackgroundTaskStatus.QUEUED,
|
||||
BackgroundTaskStatus.RUNNING,
|
||||
LocalDateTime.now()
|
||||
);
|
||||
if (claimed != 1) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return backgroundTaskRepository.findById(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BackgroundTask markWorkerTaskCompleted(Long id, Map<String, Object> publicStatePatch) {
|
||||
BackgroundTask task = backgroundTaskRepository.findById(id)
|
||||
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found"));
|
||||
if (task.isTerminal() || task.getStatus() != BackgroundTaskStatus.RUNNING) {
|
||||
return task;
|
||||
}
|
||||
|
||||
Map<String, Object> nextPublicState = parseJsonObject(task.getPublicStateJson());
|
||||
if (publicStatePatch != null) {
|
||||
nextPublicState.putAll(publicStatePatch);
|
||||
}
|
||||
task.setPublicStateJson(toJson(nextPublicState));
|
||||
task.setStatus(BackgroundTaskStatus.COMPLETED);
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
task.setErrorMessage(null);
|
||||
return backgroundTaskRepository.save(task);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BackgroundTask markWorkerTaskFailed(Long id, String errorMessage) {
|
||||
BackgroundTask task = backgroundTaskRepository.findById(id)
|
||||
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found"));
|
||||
if (task.isTerminal()) {
|
||||
return task;
|
||||
}
|
||||
|
||||
task.setStatus(BackgroundTaskStatus.FAILED);
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
task.setErrorMessage(StringUtils.hasText(errorMessage) ? errorMessage.trim() : "task failed");
|
||||
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 && !isArchiveLike(file)) {
|
||||
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "extract task only supports archive files");
|
||||
}
|
||||
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 isArchiveLike(StoredFile file) {
|
||||
String contentType = normalizeContentType(file.getContentType());
|
||||
if (contentType.contains("zip")
|
||||
|| contentType.contains("x-tar")
|
||||
|| contentType.contains("gzip")
|
||||
|| contentType.contains("x-7z")
|
||||
|| contentType.contains("x-rar")
|
||||
|| contentType.contains("java-archive")) {
|
||||
return true;
|
||||
}
|
||||
return hasExtension(file.getFilename(), ARCHIVE_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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
public enum BackgroundTaskStatus {
|
||||
QUEUED,
|
||||
RUNNING,
|
||||
FAILED,
|
||||
CANCELLED,
|
||||
COMPLETED
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
public enum BackgroundTaskType {
|
||||
ARCHIVE,
|
||||
EXTRACT,
|
||||
THUMBNAIL,
|
||||
MEDIA_META,
|
||||
REMOTE_DOWNLOAD,
|
||||
HLS_TRANSCODE,
|
||||
CLEANUP
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class BackgroundTaskWorker {
|
||||
|
||||
private static final int DEFAULT_BATCH_SIZE = 5;
|
||||
|
||||
private final BackgroundTaskService backgroundTaskService;
|
||||
private final List<BackgroundTaskHandler> handlers;
|
||||
|
||||
public BackgroundTaskWorker(BackgroundTaskService backgroundTaskService,
|
||||
List<BackgroundTaskHandler> handlers) {
|
||||
this.backgroundTaskService = backgroundTaskService;
|
||||
this.handlers = List.copyOf(handlers);
|
||||
}
|
||||
|
||||
@Scheduled(
|
||||
fixedDelayString = "${app.background-tasks.worker.fixed-delay-ms:30000}",
|
||||
initialDelayString = "${app.background-tasks.worker.initial-delay-ms:30000}"
|
||||
)
|
||||
public void runScheduledBatch() {
|
||||
processQueuedTasks(DEFAULT_BATCH_SIZE);
|
||||
}
|
||||
|
||||
public int processQueuedTasks(int maxTasks) {
|
||||
int processedCount = 0;
|
||||
for (Long taskId : backgroundTaskService.findQueuedTaskIds(maxTasks)) {
|
||||
var claimedTask = backgroundTaskService.claimQueuedTask(taskId);
|
||||
if (claimedTask.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
execute(claimedTask.get());
|
||||
processedCount += 1;
|
||||
}
|
||||
return processedCount;
|
||||
}
|
||||
|
||||
private void execute(BackgroundTask task) {
|
||||
try {
|
||||
BackgroundTaskHandler handler = findHandler(task);
|
||||
BackgroundTaskHandlerResult result = handler.handle(task);
|
||||
backgroundTaskService.markWorkerTaskCompleted(task.getId(), result.publicStatePatch());
|
||||
} catch (Exception ex) {
|
||||
String message = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
|
||||
backgroundTaskService.markWorkerTaskFailed(task.getId(), message);
|
||||
}
|
||||
}
|
||||
|
||||
private BackgroundTaskHandler findHandler(BackgroundTask task) {
|
||||
return handlers.stream()
|
||||
.filter(handler -> handler.supports(task.getType()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("No background task handler for " + task.getType()));
|
||||
}
|
||||
}
|
||||
131
backend/src/main/java/com/yoyuzh/files/FileEvent.java
Normal file
131
backend/src/main/java/com/yoyuzh/files/FileEvent.java
Normal file
@@ -0,0 +1,131 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
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;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface FileEventRepository extends JpaRepository<FileEvent, Long> {
|
||||
}
|
||||
240
backend/src/main/java/com/yoyuzh/files/FileEventService.java
Normal file
240
backend/src/main/java/com/yoyuzh/files/FileEventService.java
Normal file
@@ -0,0 +1,240 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
10
backend/src/main/java/com/yoyuzh/files/FileEventType.java
Normal file
10
backend/src/main/java/com/yoyuzh/files/FileEventType.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
public enum FileEventType {
|
||||
CREATED,
|
||||
UPDATED,
|
||||
RENAMED,
|
||||
MOVED,
|
||||
DELETED,
|
||||
RESTORED
|
||||
}
|
||||
129
backend/src/main/java/com/yoyuzh/files/FileMetadata.java
Normal file
129
backend/src/main/java/com/yoyuzh/files/FileMetadata.java
Normal file
@@ -0,0 +1,129 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
17
backend/src/main/java/com/yoyuzh/files/FileSearchQuery.java
Normal file
17
backend/src/main/java/com/yoyuzh/files/FileSearchQuery.java
Normal file
@@ -0,0 +1,17 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
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,91 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.api.v2.ApiV2ErrorCode;
|
||||
import com.yoyuzh.api.v2.ApiV2Exception;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,8 @@ public class FileService {
|
||||
private final String packageDownloadSecret;
|
||||
private final long packageDownloadTtlSeconds;
|
||||
private final Clock clock;
|
||||
@Autowired(required = false)
|
||||
private FileEventService fileEventService;
|
||||
|
||||
@Autowired
|
||||
public FileService(StoredFileRepository storedFileRepository,
|
||||
@@ -246,6 +248,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()) {
|
||||
@@ -254,11 +257,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())
|
||||
@@ -276,6 +282,7 @@ public class FileService {
|
||||
item.setRecycleRoot(false);
|
||||
}
|
||||
storedFileRepository.saveAll(recycleGroupItems);
|
||||
recordFileEvent(user, FileEventType.RESTORED, recycleRoot, fromPath, toPath);
|
||||
return toResponse(recycleRoot);
|
||||
}
|
||||
|
||||
@@ -297,6 +304,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);
|
||||
@@ -326,12 +334,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);
|
||||
@@ -366,7 +377,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
|
||||
@@ -720,6 +733,7 @@ public class FileService {
|
||||
storedFile.setPrimaryEntity(primaryEntity);
|
||||
StoredFile savedFile = storedFileRepository.save(storedFile);
|
||||
savePrimaryEntityRelation(savedFile, primaryEntity);
|
||||
recordFileEvent(user, FileEventType.CREATED, savedFile, null, buildLogicalPath(savedFile));
|
||||
return toResponse(savedFile);
|
||||
}
|
||||
|
||||
@@ -1053,6 +1067,7 @@ public class FileService {
|
||||
if (!savedFile.isDirectory() && savedFile.getPrimaryEntity() != null) {
|
||||
savePrimaryEntityRelation(savedFile, savedFile.getPrimaryEntity());
|
||||
}
|
||||
recordFileEvent(owner, FileEventType.CREATED, savedFile, null, buildLogicalPath(savedFile));
|
||||
return savedFile;
|
||||
}
|
||||
|
||||
@@ -1093,6 +1108,32 @@ public class FileService {
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
|
||||
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) {
|
||||
String cleaned = StringUtils.cleanPath(filename == null ? "" : filename).trim();
|
||||
if (!StringUtils.hasText(cleaned)) {
|
||||
|
||||
@@ -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;
|
||||
@@ -40,11 +41,51 @@ public class FileShareLink {
|
||||
@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() {
|
||||
@@ -86,4 +127,95 @@ public class FileShareLink {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
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;
|
||||
|
||||
@@ -7,6 +9,12 @@ import java.util.Optional;
|
||||
|
||||
public interface FileShareLinkRepository extends JpaRepository<FileShareLink, Long> {
|
||||
|
||||
@EntityGraph(attributePaths = {"owner", "file", "file.user"})
|
||||
@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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Component
|
||||
@Transactional
|
||||
public class MediaMetadataBackgroundTaskHandler implements BackgroundTaskHandler {
|
||||
|
||||
private static final String MEDIA_CONTENT_TYPE = "media:contentType";
|
||||
private static final String MEDIA_SIZE = "media:size";
|
||||
private static final String MEDIA_WIDTH = "media:width";
|
||||
private static final String MEDIA_HEIGHT = "media:height";
|
||||
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final FileMetadataRepository fileMetadataRepository;
|
||||
private final FileContentStorage fileContentStorage;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public MediaMetadataBackgroundTaskHandler(StoredFileRepository storedFileRepository,
|
||||
FileMetadataRepository fileMetadataRepository,
|
||||
FileContentStorage fileContentStorage,
|
||||
ObjectMapper objectMapper) {
|
||||
this.storedFileRepository = storedFileRepository;
|
||||
this.fileMetadataRepository = fileMetadataRepository;
|
||||
this.fileContentStorage = fileContentStorage;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(BackgroundTaskType type) {
|
||||
return type == BackgroundTaskType.MEDIA_META;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BackgroundTaskHandlerResult handle(BackgroundTask task) {
|
||||
Long fileId = readFileId(task);
|
||||
StoredFile file = storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(fileId, task.getUserId())
|
||||
.orElseThrow(() -> new IllegalStateException("media metadata task file not found"));
|
||||
if (file.isDirectory()) {
|
||||
throw new IllegalStateException("media metadata task only supports files");
|
||||
}
|
||||
FileBlob blob = Optional.ofNullable(file.getBlob())
|
||||
.orElseThrow(() -> new IllegalStateException("media metadata task requires blob"));
|
||||
if (!StringUtils.hasText(blob.getObjectKey())) {
|
||||
throw new IllegalStateException("media metadata task requires blob");
|
||||
}
|
||||
|
||||
String contentType = firstText(file.getContentType(), blob.getContentType());
|
||||
long size = firstLong(file.getSize(), blob.getSize());
|
||||
byte[] content = Optional.ofNullable(fileContentStorage.readBlob(blob.getObjectKey()))
|
||||
.orElseThrow(() -> new IllegalStateException("media metadata task requires blob content"));
|
||||
|
||||
Map<String, Object> publicStatePatch = new LinkedHashMap<>();
|
||||
publicStatePatch.put("worker", "media-metadata");
|
||||
publicStatePatch.put("metadataExtracted", true);
|
||||
publicStatePatch.put("mediaContentType", contentType);
|
||||
publicStatePatch.put("mediaSize", size);
|
||||
|
||||
upsertMetadata(file, MEDIA_CONTENT_TYPE, contentType);
|
||||
upsertMetadata(file, MEDIA_SIZE, String.valueOf(size));
|
||||
|
||||
try {
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(content));
|
||||
if (image != null) {
|
||||
upsertMetadata(file, MEDIA_WIDTH, String.valueOf(image.getWidth()));
|
||||
upsertMetadata(file, MEDIA_HEIGHT, String.valueOf(image.getHeight()));
|
||||
publicStatePatch.put("mediaWidth", image.getWidth());
|
||||
publicStatePatch.put("mediaHeight", image.getHeight());
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("media metadata task failed to read image dimensions", ex);
|
||||
}
|
||||
|
||||
return new BackgroundTaskHandlerResult(publicStatePatch);
|
||||
}
|
||||
|
||||
private void upsertMetadata(StoredFile file, String name, String value) {
|
||||
FileMetadata metadata = fileMetadataRepository.findByFileIdAndName(file.getId(), name)
|
||||
.orElseGet(FileMetadata::new);
|
||||
metadata.setFile(file);
|
||||
metadata.setName(name);
|
||||
metadata.setValue(value == null ? "" : value);
|
||||
metadata.setPublicVisible(true);
|
||||
fileMetadataRepository.save(metadata);
|
||||
}
|
||||
|
||||
private Long readFileId(BackgroundTask task) {
|
||||
Long fileId = extractLong(parseState(task.getPrivateStateJson()).get("fileId"));
|
||||
if (fileId != null) {
|
||||
return fileId;
|
||||
}
|
||||
fileId = extractLong(parseState(task.getPublicStateJson()).get("fileId"));
|
||||
if (fileId != null) {
|
||||
return fileId;
|
||||
}
|
||||
throw new IllegalStateException("media metadata task missing fileId");
|
||||
}
|
||||
|
||||
private Map<String, Object> parseState(String json) {
|
||||
if (!StringUtils.hasText(json)) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<LinkedHashMap<String, Object>>() {
|
||||
});
|
||||
} catch (JsonProcessingException ex) {
|
||||
throw new IllegalStateException("media metadata 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 firstText(String primary, String fallback) {
|
||||
if (StringUtils.hasText(primary)) {
|
||||
return primary.trim();
|
||||
}
|
||||
if (StringUtils.hasText(fallback)) {
|
||||
return fallback.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private long firstLong(Long primary, Long fallback) {
|
||||
if (primary != null) {
|
||||
return primary;
|
||||
}
|
||||
if (fallback != null) {
|
||||
return fallback;
|
||||
}
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Component
|
||||
public class NoopBackgroundTaskHandler implements BackgroundTaskHandler {
|
||||
|
||||
private static final Set<BackgroundTaskType> SUPPORTED_TYPES = Set.of(
|
||||
BackgroundTaskType.ARCHIVE,
|
||||
BackgroundTaskType.EXTRACT
|
||||
);
|
||||
|
||||
@Override
|
||||
public boolean supports(BackgroundTaskType type) {
|
||||
return SUPPORTED_TYPES.contains(type);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BackgroundTaskHandlerResult handle(BackgroundTask task) {
|
||||
return new BackgroundTaskHandlerResult(Map.of(
|
||||
"worker", "noop",
|
||||
"message", "worker skeleton accepted task without running real file processing",
|
||||
"completedAt", LocalDateTime.now().toString()
|
||||
));
|
||||
}
|
||||
}
|
||||
172
backend/src/main/java/com/yoyuzh/files/ShareV2Service.java
Normal file
172
backend/src/main/java/com/yoyuzh/files/ShareV2Service.java
Normal file
@@ -0,0 +1,172 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
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 jakarta.transaction.Transactional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
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 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");
|
||||
}
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -86,6 +87,11 @@ public class StoredFile {
|
||||
}
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
public void preUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user