feat(files): add v2 task and metadata workflows
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.yoyuzh.api.v2.files;
|
||||
|
||||
import com.yoyuzh.PortalBackendApplication;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = PortalBackendApplication.class,
|
||||
properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:file_events_api_test;MODE=MySQL;DB_CLOSE_DELAY=-1",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||
"app.jwt.secret=0123456789abcdef0123456789abcdef",
|
||||
"app.storage.root-dir=./target/test-storage-file-events"
|
||||
}
|
||||
)
|
||||
@AutoConfigureMockMvc
|
||||
class FileEventsV2ControllerIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userRepository.deleteAll();
|
||||
User user = new User();
|
||||
user.setUsername("alice");
|
||||
user.setEmail("alice@example.com");
|
||||
user.setPhoneNumber("13800138000");
|
||||
user.setPasswordHash("encoded-password");
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRequireAuthenticationForFileEventStream() throws Exception {
|
||||
mockMvc.perform(get("/api/v2/files/events").with(anonymous()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldOpenStreamAndSendReadyEvent() throws Exception {
|
||||
var result = mockMvc.perform(get("/api/v2/files/events")
|
||||
.with(user("alice"))
|
||||
.param("path", "/docs")
|
||||
.header("X-Yoyuzh-Client-Id", "tab-1"))
|
||||
.andExpect(request().asyncStarted())
|
||||
.andReturn();
|
||||
|
||||
String body = result.getResponse().getContentAsString();
|
||||
assertThat(result.getResponse().getStatus()).isEqualTo(200);
|
||||
assertThat(result.getResponse().getContentType()).startsWith("text/event-stream");
|
||||
assertThat(body).contains("READY");
|
||||
assertThat(body).contains("/docs");
|
||||
assertThat(body).contains("tab-1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.yoyuzh.api.v2.files;
|
||||
|
||||
import com.yoyuzh.api.v2.ApiV2ExceptionHandler;
|
||||
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 org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.bind.support.WebDataBinderFactory;
|
||||
import org.springframework.web.context.request.NativeWebRequest;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.method.support.ModelAndViewContainer;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class FileSearchV2ControllerTest {
|
||||
|
||||
private FileSearchService fileSearchService;
|
||||
private CustomUserDetailsService userDetailsService;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
fileSearchService = mock(FileSearchService.class);
|
||||
userDetailsService = mock(CustomUserDetailsService.class);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(new FileSearchV2Controller(fileSearchService, userDetailsService))
|
||||
.setControllerAdvice(new ApiV2ExceptionHandler())
|
||||
.setCustomArgumentResolvers(authenticationPrincipalResolver())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSearchFilesWithV2Envelope() throws Exception {
|
||||
User user = createUser(7L);
|
||||
when(userDetailsService.loadDomainUser("alice")).thenReturn(user);
|
||||
when(fileSearchService.search(eq(user), any(FileSearchQuery.class))).thenReturn(new PageResponse<>(
|
||||
List.of(new FileMetadataResponse(
|
||||
10L,
|
||||
"notes.txt",
|
||||
"/docs",
|
||||
5L,
|
||||
"text/plain",
|
||||
false,
|
||||
LocalDateTime.of(2026, 4, 8, 10, 0)
|
||||
)),
|
||||
1,
|
||||
0,
|
||||
20
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/api/v2/files/search")
|
||||
.with(user(userDetails()))
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.param("name", "note")
|
||||
.param("type", "file")
|
||||
.param("sizeGte", "1")
|
||||
.param("sizeLte", "100")
|
||||
.param("createdGte", "2026-04-08T08:00:00")
|
||||
.param("createdLte", "2026-04-08T12:00:00")
|
||||
.param("updatedGte", "2026-04-08T09:00:00")
|
||||
.param("updatedLte", "2026-04-08T18:00:00")
|
||||
.param("page", "0")
|
||||
.param("size", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectUnsupportedTypeFilter() throws Exception {
|
||||
mockMvc.perform(get("/api/v2/files/search")
|
||||
.with(user(userDetails()))
|
||||
.param("type", "image"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(2400))
|
||||
.andExpect(jsonPath("$.msg").value("文件类型筛选只支持 file 或 directory"));
|
||||
}
|
||||
|
||||
private UserDetails userDetails() {
|
||||
return org.springframework.security.core.userdetails.User
|
||||
.withUsername("alice")
|
||||
.password("encoded")
|
||||
.authorities("ROLE_USER")
|
||||
.build();
|
||||
}
|
||||
|
||||
private HandlerMethodArgumentResolver authenticationPrincipalResolver() {
|
||||
UserDetails userDetails = userDetails();
|
||||
return new HandlerMethodArgumentResolver() {
|
||||
@Override
|
||||
public boolean supportsParameter(MethodParameter parameter) {
|
||||
return parameter.hasParameterAnnotation(AuthenticationPrincipal.class)
|
||||
&& UserDetails.class.isAssignableFrom(parameter.getParameterType());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object resolveArgument(MethodParameter parameter,
|
||||
ModelAndViewContainer mavContainer,
|
||||
NativeWebRequest webRequest,
|
||||
WebDataBinderFactory binderFactory) {
|
||||
return userDetails;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private User createUser(Long id) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
user.setUsername("alice");
|
||||
user.setEmail("alice@example.com");
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package com.yoyuzh.api.v2.shares;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.yoyuzh.PortalBackendApplication;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
import com.yoyuzh.files.FileBlob;
|
||||
import com.yoyuzh.files.FileBlobRepository;
|
||||
import com.yoyuzh.files.FileShareLink;
|
||||
import com.yoyuzh.files.FileShareLinkRepository;
|
||||
import com.yoyuzh.files.StoredFile;
|
||||
import com.yoyuzh.files.StoredFileRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Comparator;
|
||||
|
||||
import static org.hamcrest.Matchers.nullValue;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = PortalBackendApplication.class,
|
||||
properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:share_v2_api_test;MODE=MySQL;DB_CLOSE_DELAY=-1",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||
"app.jwt.secret=0123456789abcdef0123456789abcdef",
|
||||
"app.storage.root-dir=./target/test-storage-share-v2"
|
||||
}
|
||||
)
|
||||
@AutoConfigureMockMvc
|
||||
class ShareV2ControllerIntegrationTest {
|
||||
|
||||
private static final Path STORAGE_ROOT = Path.of("./target/test-storage-share-v2").toAbsolutePath().normalize();
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private StoredFileRepository storedFileRepository;
|
||||
|
||||
@Autowired
|
||||
private FileBlobRepository fileBlobRepository;
|
||||
|
||||
@Autowired
|
||||
private FileShareLinkRepository fileShareLinkRepository;
|
||||
|
||||
private Long sharedFileId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
fileShareLinkRepository.deleteAll();
|
||||
storedFileRepository.deleteAll();
|
||||
fileBlobRepository.deleteAll();
|
||||
userRepository.deleteAll();
|
||||
if (Files.exists(STORAGE_ROOT)) {
|
||||
try (var paths = Files.walk(STORAGE_ROOT)) {
|
||||
paths.sorted(Comparator.reverseOrder()).forEach(path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (Exception ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Files.createDirectories(STORAGE_ROOT);
|
||||
|
||||
User owner = new User();
|
||||
owner.setUsername("alice");
|
||||
owner.setEmail("alice@example.com");
|
||||
owner.setPhoneNumber("13800138000");
|
||||
owner.setPasswordHash("encoded-password");
|
||||
owner.setCreatedAt(LocalDateTime.now());
|
||||
owner = userRepository.save(owner);
|
||||
|
||||
User recipient = new User();
|
||||
recipient.setUsername("bob");
|
||||
recipient.setEmail("bob@example.com");
|
||||
recipient.setPhoneNumber("13800138001");
|
||||
recipient.setPasswordHash("encoded-password");
|
||||
recipient.setCreatedAt(LocalDateTime.now());
|
||||
userRepository.save(recipient);
|
||||
|
||||
FileBlob blob = new FileBlob();
|
||||
blob.setObjectKey("blobs/share-v2-notes");
|
||||
blob.setContentType("text/plain");
|
||||
blob.setSize(5L);
|
||||
blob.setCreatedAt(LocalDateTime.now());
|
||||
blob = fileBlobRepository.save(blob);
|
||||
|
||||
StoredFile file = new StoredFile();
|
||||
file.setUser(owner);
|
||||
file.setFilename("notes.txt");
|
||||
file.setPath("/docs");
|
||||
file.setContentType("text/plain");
|
||||
file.setSize(5L);
|
||||
file.setDirectory(false);
|
||||
file.setBlob(blob);
|
||||
sharedFileId = storedFileRepository.save(file).getId();
|
||||
|
||||
Path blobPath = STORAGE_ROOT.resolve("blobs").resolve("share-v2-notes");
|
||||
Files.createDirectories(blobPath.getParent());
|
||||
Files.writeString(blobPath, "hello", StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateReadVerifyImportAndDeleteOwnV2Share() throws Exception {
|
||||
String createResponse = mockMvc.perform(post("/api/v2/shares")
|
||||
.with(user("alice"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileId": %d,
|
||||
"password": "Share123",
|
||||
"shareName": "course-share",
|
||||
"allowImport": true,
|
||||
"allowDownload": true
|
||||
}
|
||||
""".formatted(sharedFileId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.token").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.shareName").value("course-share"))
|
||||
.andExpect(jsonPath("$.data.passwordRequired").value(true))
|
||||
.andExpect(jsonPath("$.data.file.filename").value("notes.txt"))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
String token = JsonPath.read(createResponse, "$.data.token");
|
||||
Long shareId = ((Number) JsonPath.read(createResponse, "$.data.id")).longValue();
|
||||
|
||||
mockMvc.perform(get("/api/v2/shares/{token}", token).with(anonymous()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.passwordRequired").value(true))
|
||||
.andExpect(jsonPath("$.data.passwordVerified").value(false))
|
||||
.andExpect(jsonPath("$.data.file").value(nullValue()));
|
||||
|
||||
mockMvc.perform(post("/api/v2/shares/{token}/verify-password", token)
|
||||
.with(anonymous())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"password": "WrongPass1!"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(2400));
|
||||
|
||||
mockMvc.perform(post("/api/v2/shares/{token}/verify-password", token)
|
||||
.with(anonymous())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"password": "Share123"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.passwordVerified").value(true))
|
||||
.andExpect(jsonPath("$.data.file.filename").value("notes.txt"));
|
||||
|
||||
mockMvc.perform(post("/api/v2/shares/{token}/import", token)
|
||||
.with(user("bob"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"path": "/download",
|
||||
"password": "Share123"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.filename").value("notes.txt"))
|
||||
.andExpect(jsonPath("$.data.path").value("/download"));
|
||||
|
||||
mockMvc.perform(get("/api/v2/shares/mine")
|
||||
.with(user("alice"))
|
||||
.param("page", "0")
|
||||
.param("size", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].id").value(shareId))
|
||||
.andExpect(jsonPath("$.data.items[0].file.filename").value("notes.txt"));
|
||||
|
||||
mockMvc.perform(delete("/api/v2/shares/{id}", shareId)
|
||||
.with(user("alice")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
mockMvc.perform(get("/api/v2/shares/mine")
|
||||
.with(user("alice"))
|
||||
.param("page", "0")
|
||||
.param("size", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.total").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectDisabledOrExpiredV2ShareImports() throws Exception {
|
||||
String disabledResponse = mockMvc.perform(post("/api/v2/shares")
|
||||
.with(user("alice"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileId": %d,
|
||||
"allowImport": false,
|
||||
"allowDownload": true
|
||||
}
|
||||
""".formatted(sharedFileId)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
String disabledToken = JsonPath.read(disabledResponse, "$.data.token");
|
||||
|
||||
mockMvc.perform(post("/api/v2/shares/{token}/import", disabledToken)
|
||||
.with(user("bob"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"path": "/download"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(2403));
|
||||
|
||||
String expiringResponse = mockMvc.perform(post("/api/v2/shares")
|
||||
.with(user("alice"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileId": %d,
|
||||
"allowImport": true,
|
||||
"allowDownload": true
|
||||
}
|
||||
""".formatted(sharedFileId)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
String expiringToken = JsonPath.read(expiringResponse, "$.data.token");
|
||||
|
||||
FileShareLink expiringShare = fileShareLinkRepository.findByToken(expiringToken).orElseThrow();
|
||||
expiringShare.setExpiresAt(LocalDateTime.now().minusMinutes(1));
|
||||
fileShareLinkRepository.save(expiringShare);
|
||||
|
||||
mockMvc.perform(post("/api/v2/shares/{token}/import", expiringToken)
|
||||
.with(user("bob"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"path": "/download"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(2404));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDenyDeletingOtherUsersShare() throws Exception {
|
||||
String createResponse = mockMvc.perform(post("/api/v2/shares")
|
||||
.with(user("alice"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileId": %d
|
||||
}
|
||||
""".formatted(sharedFileId)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
Long shareId = ((Number) JsonPath.read(createResponse, "$.data.id")).longValue();
|
||||
|
||||
mockMvc.perform(delete("/api/v2/shares/{id}", shareId)
|
||||
.with(user("bob")))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(2404));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package com.yoyuzh.api.v2.tasks;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.yoyuzh.PortalBackendApplication;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
import com.yoyuzh.files.BackgroundTask;
|
||||
import com.yoyuzh.files.BackgroundTaskRepository;
|
||||
import com.yoyuzh.files.BackgroundTaskStatus;
|
||||
import com.yoyuzh.files.BackgroundTaskType;
|
||||
import com.yoyuzh.files.StoredFile;
|
||||
import com.yoyuzh.files.StoredFileRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = PortalBackendApplication.class,
|
||||
properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:background_task_api_test;MODE=MySQL;DB_CLOSE_DELAY=-1",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||
"app.jwt.secret=0123456789abcdef0123456789abcdef",
|
||||
"app.storage.root-dir=./target/test-storage-background-task"
|
||||
}
|
||||
)
|
||||
@AutoConfigureMockMvc
|
||||
class BackgroundTaskV2ControllerIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private BackgroundTaskRepository backgroundTaskRepository;
|
||||
|
||||
@Autowired
|
||||
private StoredFileRepository storedFileRepository;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private Long archiveDirectoryId;
|
||||
private Long archiveFileId;
|
||||
private Long extractFileId;
|
||||
private Long mediaFileId;
|
||||
private Long foreignFileId;
|
||||
private Long deletedFileId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
backgroundTaskRepository.deleteAll();
|
||||
storedFileRepository.deleteAll();
|
||||
userRepository.deleteAll();
|
||||
|
||||
User alice = new User();
|
||||
alice.setUsername("alice");
|
||||
alice.setEmail("alice@example.com");
|
||||
alice.setPhoneNumber("13800138000");
|
||||
alice.setPasswordHash("encoded-password");
|
||||
alice.setCreatedAt(LocalDateTime.now());
|
||||
userRepository.save(alice);
|
||||
|
||||
User bob = new User();
|
||||
bob.setUsername("bob");
|
||||
bob.setEmail("bob@example.com");
|
||||
bob.setPhoneNumber("13800138001");
|
||||
bob.setPasswordHash("encoded-password");
|
||||
bob.setCreatedAt(LocalDateTime.now());
|
||||
bob = userRepository.save(bob);
|
||||
|
||||
archiveDirectoryId = storedFileRepository.save(createFile(alice, "/docs", "archive", true, null, 0L, null)).getId();
|
||||
archiveFileId = storedFileRepository.save(createFile(alice, "/docs", "archive-source.txt", false, "text/plain", 12L, null)).getId();
|
||||
extractFileId = storedFileRepository.save(createFile(alice, "/docs", "extract.zip", false, "application/zip", 32L, null)).getId();
|
||||
mediaFileId = storedFileRepository.save(createFile(alice, "/docs", "media.png", false, "image/png", 24L, null)).getId();
|
||||
foreignFileId = storedFileRepository.save(createFile(bob, "/docs", "foreign.zip", false, "application/zip", 32L, null)).getId();
|
||||
deletedFileId = storedFileRepository.save(createFile(alice, "/docs", "deleted.zip", false, "application/zip", 32L, LocalDateTime.now())).getId();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRequireAuthenticationForTaskEndpoints() throws Exception {
|
||||
mockMvc.perform(get("/api/v2/tasks").with(anonymous()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
mockMvc.perform(post("/api/v2/tasks/archive")
|
||||
.with(anonymous())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileId": 1,
|
||||
"path": "/docs"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldQueueListGetAndCancelOwnedTasks() throws Exception {
|
||||
String archiveResponse = mockMvc.perform(post("/api/v2/tasks/archive")
|
||||
.with(user("alice"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileId": %d,
|
||||
"path": "/docs/archive",
|
||||
"correlationId": "archive-1"
|
||||
}
|
||||
""".formatted(archiveDirectoryId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.type").value("ARCHIVE"))
|
||||
.andExpect(jsonPath("$.data.status").value("QUEUED"))
|
||||
.andExpect(jsonPath("$.data.publicStateJson", containsString("\"fileId\":" + archiveDirectoryId)))
|
||||
.andExpect(jsonPath("$.data.publicStateJson", containsString("\"path\":\"/docs/archive\"")))
|
||||
.andExpect(jsonPath("$.data.publicStateJson", containsString("\"directory\":true")))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
String extractResponse = mockMvc.perform(post("/api/v2/tasks/extract")
|
||||
.with(user("alice"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileId": %d,
|
||||
"path": "/docs/extract.zip"
|
||||
}
|
||||
""".formatted(extractFileId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.type").value("EXTRACT"))
|
||||
.andExpect(jsonPath("$.data.status").value("QUEUED"))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
String mediaResponse = mockMvc.perform(post("/api/v2/tasks/media-metadata")
|
||||
.with(user("alice"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileId": %d,
|
||||
"path": "/docs/media.png"
|
||||
}
|
||||
""".formatted(mediaFileId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.type").value("MEDIA_META"))
|
||||
.andExpect(jsonPath("$.data.status").value("QUEUED"))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
Long archiveId = ((Number) JsonPath.read(archiveResponse, "$.data.id")).longValue();
|
||||
Long extractId = ((Number) JsonPath.read(extractResponse, "$.data.id")).longValue();
|
||||
Long mediaId = ((Number) JsonPath.read(mediaResponse, "$.data.id")).longValue();
|
||||
|
||||
mockMvc.perform(get("/api/v2/tasks")
|
||||
.with(user("alice"))
|
||||
.param("page", "0")
|
||||
.param("size", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.total").value(3))
|
||||
.andExpect(jsonPath("$.data.items[0].id").value(mediaId));
|
||||
|
||||
mockMvc.perform(get("/api/v2/tasks/{id}", archiveId).with(user("alice")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.id").value(archiveId))
|
||||
.andExpect(jsonPath("$.data.privateStateJson").doesNotExist());
|
||||
|
||||
mockMvc.perform(delete("/api/v2/tasks/{id}", extractId).with(user("alice")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.status").value("CANCELLED"));
|
||||
|
||||
BackgroundTask cancelled = backgroundTaskRepository.findById(extractId).orElseThrow();
|
||||
assertThat(cancelled.getStatus()).isEqualTo(BackgroundTaskStatus.CANCELLED);
|
||||
assertThat(cancelled.getFinishedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectOtherUsersTaskAccess() throws Exception {
|
||||
String response = mockMvc.perform(post("/api/v2/tasks/archive")
|
||||
.with(user("alice"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileId": %d,
|
||||
"path": "/docs/archive-source.txt"
|
||||
}
|
||||
""".formatted(archiveFileId)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
Long taskId = ((Number) JsonPath.read(response, "$.data.id")).longValue();
|
||||
|
||||
mockMvc.perform(get("/api/v2/tasks/{id}", taskId).with(user("bob")))
|
||||
.andExpect(status().isNotFound());
|
||||
|
||||
mockMvc.perform(delete("/api/v2/tasks/{id}", taskId).with(user("bob")))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidTaskTargetsBeforeQueueing() throws Exception {
|
||||
mockMvc.perform(post("/api/v2/tasks/archive")
|
||||
.with(user("alice"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileId": %d,
|
||||
"path": "/docs/foreign.zip"
|
||||
}
|
||||
""".formatted(foreignFileId)))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(2404));
|
||||
|
||||
mockMvc.perform(post("/api/v2/tasks/archive")
|
||||
.with(user("alice"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileId": %d,
|
||||
"path": "/docs/deleted.zip"
|
||||
}
|
||||
""".formatted(deletedFileId)))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(2404));
|
||||
|
||||
mockMvc.perform(post("/api/v2/tasks/archive")
|
||||
.with(user("alice"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileId": %d,
|
||||
"path": "/docs/client-path.zip"
|
||||
}
|
||||
""".formatted(extractFileId)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(2400));
|
||||
}
|
||||
|
||||
private StoredFile createFile(User user,
|
||||
String path,
|
||||
String filename,
|
||||
boolean directory,
|
||||
String contentType,
|
||||
Long size,
|
||||
LocalDateTime deletedAt) {
|
||||
StoredFile file = new StoredFile();
|
||||
file.setUser(user);
|
||||
file.setPath(path);
|
||||
file.setFilename(filename);
|
||||
file.setDirectory(directory);
|
||||
file.setContentType(contentType);
|
||||
file.setSize(size);
|
||||
file.setDeletedAt(deletedAt);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yoyuzh.api.v2.ApiV2Exception;
|
||||
import com.yoyuzh.auth.User;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class BackgroundTaskServiceTest {
|
||||
|
||||
@Mock
|
||||
private BackgroundTaskRepository backgroundTaskRepository;
|
||||
|
||||
@Mock
|
||||
private StoredFileRepository storedFileRepository;
|
||||
|
||||
private BackgroundTaskService backgroundTaskService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
backgroundTaskService = new BackgroundTaskService(backgroundTaskRepository, storedFileRepository, new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectTaskCreationForForeignFile() {
|
||||
User user = createUser(7L);
|
||||
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(99L, 7L)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> backgroundTaskService.createQueuedFileTask(
|
||||
user,
|
||||
BackgroundTaskType.ARCHIVE,
|
||||
99L,
|
||||
"/docs/foreign.txt",
|
||||
null
|
||||
)).isInstanceOf(ApiV2Exception.class)
|
||||
.hasMessage("file not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectTaskCreationForDeletedFile() {
|
||||
User user = createUser(7L);
|
||||
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(100L, 7L)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> backgroundTaskService.createQueuedFileTask(
|
||||
user,
|
||||
BackgroundTaskType.ARCHIVE,
|
||||
100L,
|
||||
"/docs/deleted.txt",
|
||||
null
|
||||
)).isInstanceOf(ApiV2Exception.class)
|
||||
.hasMessage("file not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectTaskCreationWhenRequestedPathDoesNotMatchFile() {
|
||||
User user = createUser(7L);
|
||||
StoredFile file = createStoredFile(11L, user, "/docs", "real.txt", false, "text/plain", 3L);
|
||||
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(11L, 7L)).thenReturn(Optional.of(file));
|
||||
|
||||
assertThatThrownBy(() -> backgroundTaskService.createQueuedFileTask(
|
||||
user,
|
||||
BackgroundTaskType.ARCHIVE,
|
||||
11L,
|
||||
"/docs/fake.txt",
|
||||
null
|
||||
)).isInstanceOf(ApiV2Exception.class)
|
||||
.hasMessage("task path does not match file path");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectExtractTaskForDirectory() {
|
||||
User user = createUser(7L);
|
||||
StoredFile directory = createStoredFile(12L, user, "/", "bundle", true, null, 0L);
|
||||
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(12L, 7L)).thenReturn(Optional.of(directory));
|
||||
|
||||
assertThatThrownBy(() -> backgroundTaskService.createQueuedFileTask(
|
||||
user,
|
||||
BackgroundTaskType.EXTRACT,
|
||||
12L,
|
||||
"/bundle",
|
||||
null
|
||||
)).isInstanceOf(ApiV2Exception.class)
|
||||
.hasMessage("task target type is not supported");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectMediaMetadataTaskForNonMediaFile() {
|
||||
User user = createUser(7L);
|
||||
StoredFile file = createStoredFile(13L, user, "/docs", "notes.txt", false, "text/plain", 9L);
|
||||
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(13L, 7L)).thenReturn(Optional.of(file));
|
||||
|
||||
assertThatThrownBy(() -> backgroundTaskService.createQueuedFileTask(
|
||||
user,
|
||||
BackgroundTaskType.MEDIA_META,
|
||||
13L,
|
||||
"/docs/notes.txt",
|
||||
null
|
||||
)).isInstanceOf(ApiV2Exception.class)
|
||||
.hasMessage("media metadata task only supports media files");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateTaskStateFromServerFilePath() {
|
||||
User user = createUser(7L);
|
||||
StoredFile file = createStoredFile(14L, user, "/docs", "photo.png", false, "image/png", 15L);
|
||||
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(14L, 7L)).thenReturn(Optional.of(file));
|
||||
when(backgroundTaskRepository.save(any(BackgroundTask.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
BackgroundTask task = backgroundTaskService.createQueuedFileTask(
|
||||
user,
|
||||
BackgroundTaskType.MEDIA_META,
|
||||
14L,
|
||||
"/docs/photo.png",
|
||||
"media-1"
|
||||
);
|
||||
|
||||
assertThat(task.getPublicStateJson()).contains("\"fileId\":14");
|
||||
assertThat(task.getPublicStateJson()).contains("\"path\":\"/docs/photo.png\"");
|
||||
assertThat(task.getPublicStateJson()).contains("\"filename\":\"photo.png\"");
|
||||
assertThat(task.getPublicStateJson()).contains("\"directory\":false");
|
||||
assertThat(task.getPublicStateJson()).contains("\"contentType\":\"image/png\"");
|
||||
assertThat(task.getPublicStateJson()).contains("\"size\":15");
|
||||
assertThat(task.getPrivateStateJson()).contains("\"taskType\":\"MEDIA_META\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldClaimQueuedTaskOnlyWhenRepositoryTransitionSucceeds() {
|
||||
BackgroundTask task = createTask(1L, BackgroundTaskStatus.RUNNING);
|
||||
when(backgroundTaskRepository.claimQueuedTask(
|
||||
eq(1L),
|
||||
eq(BackgroundTaskStatus.QUEUED),
|
||||
eq(BackgroundTaskStatus.RUNNING),
|
||||
any()
|
||||
)).thenReturn(1);
|
||||
when(backgroundTaskRepository.findById(1L)).thenReturn(Optional.of(task));
|
||||
|
||||
Optional<BackgroundTask> result = backgroundTaskService.claimQueuedTask(1L);
|
||||
|
||||
assertThat(result).containsSame(task);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotClaimTaskWhenRepositoryTransitionWasSkipped() {
|
||||
when(backgroundTaskRepository.claimQueuedTask(
|
||||
eq(2L),
|
||||
eq(BackgroundTaskStatus.QUEUED),
|
||||
eq(BackgroundTaskStatus.RUNNING),
|
||||
any()
|
||||
)).thenReturn(0);
|
||||
|
||||
Optional<BackgroundTask> result = backgroundTaskService.claimQueuedTask(2L);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCompleteRunningWorkerTaskAndMergePublicState() {
|
||||
BackgroundTask task = createTask(3L, BackgroundTaskStatus.RUNNING);
|
||||
task.setPublicStateJson("{\"fileId\":11}");
|
||||
when(backgroundTaskRepository.findById(3L)).thenReturn(Optional.of(task));
|
||||
when(backgroundTaskRepository.save(any(BackgroundTask.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
BackgroundTask result = backgroundTaskService.markWorkerTaskCompleted(3L, Map.of("worker", "noop"));
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(BackgroundTaskStatus.COMPLETED);
|
||||
assertThat(result.getFinishedAt()).isNotNull();
|
||||
assertThat(result.getErrorMessage()).isNull();
|
||||
assertThat(result.getPublicStateJson()).contains("\"fileId\":11");
|
||||
assertThat(result.getPublicStateJson()).contains("\"worker\":\"noop\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRecordWorkerFailureMessage() {
|
||||
BackgroundTask task = createTask(4L, BackgroundTaskStatus.RUNNING);
|
||||
when(backgroundTaskRepository.findById(4L)).thenReturn(Optional.of(task));
|
||||
when(backgroundTaskRepository.save(any(BackgroundTask.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
BackgroundTask result = backgroundTaskService.markWorkerTaskFailed(4L, "media parser unavailable");
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(BackgroundTaskStatus.FAILED);
|
||||
assertThat(result.getFinishedAt()).isNotNull();
|
||||
assertThat(result.getErrorMessage()).isEqualTo("media parser unavailable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFindQueuedTaskIdsInCreatedOrderLimit() {
|
||||
BackgroundTask first = createTask(5L, BackgroundTaskStatus.QUEUED);
|
||||
BackgroundTask second = createTask(6L, BackgroundTaskStatus.QUEUED);
|
||||
when(backgroundTaskRepository.findByStatusOrderByCreatedAtAsc(eq(BackgroundTaskStatus.QUEUED), any()))
|
||||
.thenReturn(List.of(first, second));
|
||||
|
||||
List<Long> result = backgroundTaskService.findQueuedTaskIds(2);
|
||||
|
||||
assertThat(result).containsExactly(5L, 6L);
|
||||
}
|
||||
|
||||
private BackgroundTask createTask(Long id, BackgroundTaskStatus status) {
|
||||
BackgroundTask task = new BackgroundTask();
|
||||
task.setId(id);
|
||||
task.setType(BackgroundTaskType.MEDIA_META);
|
||||
task.setStatus(status);
|
||||
task.setUserId(7L);
|
||||
task.setPublicStateJson("{}");
|
||||
task.setPrivateStateJson("{}");
|
||||
return task;
|
||||
}
|
||||
|
||||
private User createUser(Long id) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
user.setUsername("alice");
|
||||
return user;
|
||||
}
|
||||
|
||||
private StoredFile createStoredFile(Long id,
|
||||
User user,
|
||||
String path,
|
||||
String filename,
|
||||
boolean directory,
|
||||
String contentType,
|
||||
Long size) {
|
||||
StoredFile file = new StoredFile();
|
||||
file.setId(id);
|
||||
file.setUser(user);
|
||||
file.setPath(path);
|
||||
file.setFilename(filename);
|
||||
file.setDirectory(directory);
|
||||
file.setContentType(contentType);
|
||||
file.setSize(size);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class BackgroundTaskWorkerTest {
|
||||
|
||||
@Mock
|
||||
private BackgroundTaskService backgroundTaskService;
|
||||
@Mock
|
||||
private BackgroundTaskHandler backgroundTaskHandler;
|
||||
|
||||
private BackgroundTaskWorker backgroundTaskWorker;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
backgroundTaskWorker = new BackgroundTaskWorker(backgroundTaskService, List.of(backgroundTaskHandler));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldClaimAndCompleteQueuedTaskThroughNoopHandler() {
|
||||
BackgroundTask task = createTask(1L, BackgroundTaskType.ARCHIVE, BackgroundTaskStatus.RUNNING);
|
||||
when(backgroundTaskService.findQueuedTaskIds(5)).thenReturn(List.of(1L));
|
||||
when(backgroundTaskService.claimQueuedTask(1L)).thenReturn(Optional.of(task));
|
||||
when(backgroundTaskHandler.supports(BackgroundTaskType.ARCHIVE)).thenReturn(true);
|
||||
when(backgroundTaskHandler.handle(task)).thenReturn(new BackgroundTaskHandlerResult(Map.of("worker", "noop")));
|
||||
|
||||
int processedCount = backgroundTaskWorker.processQueuedTasks(5);
|
||||
|
||||
assertThat(processedCount).isEqualTo(1);
|
||||
verify(backgroundTaskHandler).handle(task);
|
||||
verify(backgroundTaskService).markWorkerTaskCompleted(1L, Map.of("worker", "noop"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSkipTaskThatWasNotClaimed() {
|
||||
when(backgroundTaskService.findQueuedTaskIds(5)).thenReturn(List.of(1L));
|
||||
when(backgroundTaskService.claimQueuedTask(1L)).thenReturn(Optional.empty());
|
||||
|
||||
int processedCount = backgroundTaskWorker.processQueuedTasks(5);
|
||||
|
||||
assertThat(processedCount).isZero();
|
||||
verify(backgroundTaskHandler, never()).handle(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMarkTaskFailedWhenHandlerThrows() {
|
||||
BackgroundTask task = createTask(2L, BackgroundTaskType.MEDIA_META, BackgroundTaskStatus.RUNNING);
|
||||
when(backgroundTaskService.findQueuedTaskIds(5)).thenReturn(List.of(2L));
|
||||
when(backgroundTaskService.claimQueuedTask(2L)).thenReturn(Optional.of(task));
|
||||
when(backgroundTaskHandler.supports(BackgroundTaskType.MEDIA_META)).thenReturn(true);
|
||||
when(backgroundTaskHandler.handle(task)).thenThrow(new IllegalStateException("media parser unavailable"));
|
||||
|
||||
int processedCount = backgroundTaskWorker.processQueuedTasks(5);
|
||||
|
||||
assertThat(processedCount).isEqualTo(1);
|
||||
verify(backgroundTaskService).markWorkerTaskFailed(2L, "media parser unavailable");
|
||||
}
|
||||
|
||||
private BackgroundTask createTask(Long id, BackgroundTaskType type, BackgroundTaskStatus status) {
|
||||
BackgroundTask task = new BackgroundTask();
|
||||
task.setId(id);
|
||||
task.setType(type);
|
||||
task.setStatus(status);
|
||||
task.setUserId(7L);
|
||||
task.setPublicStateJson("{}");
|
||||
task.setPrivateStateJson("{}");
|
||||
return task;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.PortalBackendApplication;
|
||||
import com.yoyuzh.admin.AdminMetricsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = PortalBackendApplication.class,
|
||||
properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:file_events_service_test;MODE=MySQL;DB_CLOSE_DELAY=-1",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||
"app.jwt.secret=0123456789abcdef0123456789abcdef",
|
||||
"app.storage.root-dir=./target/test-storage-file-events-service"
|
||||
}
|
||||
)
|
||||
class FileEventPersistenceIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private FileService fileService;
|
||||
|
||||
@Autowired
|
||||
private FileEventRepository fileEventRepository;
|
||||
|
||||
@MockBean
|
||||
private StoredFileRepository storedFileRepository;
|
||||
|
||||
@MockBean
|
||||
private FileBlobRepository fileBlobRepository;
|
||||
|
||||
@MockBean
|
||||
private FileEntityRepository fileEntityRepository;
|
||||
|
||||
@MockBean
|
||||
private StoredFileEntityRepository storedFileEntityRepository;
|
||||
|
||||
@MockBean
|
||||
private FileContentStorage fileContentStorage;
|
||||
|
||||
@MockBean
|
||||
private FileShareLinkRepository fileShareLinkRepository;
|
||||
|
||||
@MockBean
|
||||
private AdminMetricsService adminMetricsService;
|
||||
|
||||
@MockBean
|
||||
private StoragePolicyService storagePolicyService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
fileEventRepository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPersistRenameEventWhenFileChanges() {
|
||||
User user = new User();
|
||||
user.setId(7L);
|
||||
user.setUsername("alice");
|
||||
user.setEmail("alice@example.com");
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
StoredFile file = new StoredFile();
|
||||
file.setId(10L);
|
||||
file.setUser(user);
|
||||
file.setFilename("notes.txt");
|
||||
file.setPath("/docs");
|
||||
file.setContentType("text/plain");
|
||||
file.setSize(5L);
|
||||
file.setDirectory(false);
|
||||
file.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(file));
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "paper.txt")).thenReturn(false);
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
FileMetadataResponse response = fileService.rename(user, 10L, "paper.txt");
|
||||
|
||||
assertThat(response.filename()).isEqualTo("paper.txt");
|
||||
assertThat(fileEventRepository.count()).isEqualTo(1L);
|
||||
|
||||
FileEvent event = fileEventRepository.findAll().get(0);
|
||||
assertThat(event.getEventType()).isEqualTo(FileEventType.RENAMED);
|
||||
assertThat(event.getFileId()).isEqualTo(10L);
|
||||
assertThat(event.getFromPath()).isEqualTo("/docs/notes.txt");
|
||||
assertThat(event.getToPath()).isEqualTo("/docs/paper.txt");
|
||||
assertThat(event.getPayloadJson()).contains("\"action\":\"RENAMED\"");
|
||||
assertThat(event.getPayloadJson()).contains("\"filename\":\"paper.txt\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.api.v2.ApiV2Exception;
|
||||
import com.yoyuzh.auth.User;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FileSearchServiceTest {
|
||||
|
||||
@Mock
|
||||
private StoredFileRepository storedFileRepository;
|
||||
|
||||
private FileSearchService fileSearchService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
fileSearchService = new FileSearchService(storedFileRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSearchOwnedActiveFiles() {
|
||||
User user = createUser(7L);
|
||||
StoredFile file = createFile(10L, user, "/docs", "notes.txt", false);
|
||||
LocalDateTime createdGte = LocalDateTime.of(2026, 4, 8, 8, 0);
|
||||
LocalDateTime createdLte = LocalDateTime.of(2026, 4, 8, 12, 0);
|
||||
LocalDateTime updatedGte = LocalDateTime.of(2026, 4, 8, 9, 0);
|
||||
LocalDateTime updatedLte = LocalDateTime.of(2026, 4, 8, 18, 0);
|
||||
when(storedFileRepository.searchUserFiles(
|
||||
eq(7L),
|
||||
eq("note"),
|
||||
eq(false),
|
||||
eq(1L),
|
||||
eq(100L),
|
||||
eq(createdGte),
|
||||
eq(createdLte),
|
||||
eq(updatedGte),
|
||||
eq(updatedLte),
|
||||
eq(PageRequest.of(0, 20))
|
||||
)).thenReturn(new PageImpl<>(List.of(file), PageRequest.of(0, 20), 1));
|
||||
|
||||
var response = fileSearchService.search(user, new FileSearchQuery(
|
||||
" note ",
|
||||
false,
|
||||
1L,
|
||||
100L,
|
||||
createdGte,
|
||||
createdLte,
|
||||
updatedGte,
|
||||
updatedLte,
|
||||
0,
|
||||
20
|
||||
));
|
||||
|
||||
assertThat(response.total()).isEqualTo(1);
|
||||
assertThat(response.items()).hasSize(1);
|
||||
assertThat(response.items().get(0).filename()).isEqualTo("notes.txt");
|
||||
assertThat(response.items().get(0).path()).isEqualTo("/docs");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnDirectoryLogicalPathForDirectoryResults() {
|
||||
User user = createUser(7L);
|
||||
StoredFile directory = createFile(11L, user, "/docs", "archive", true);
|
||||
when(storedFileRepository.searchUserFiles(
|
||||
eq(7L),
|
||||
eq(null),
|
||||
eq(true),
|
||||
eq(null),
|
||||
eq(null),
|
||||
eq(null),
|
||||
eq(null),
|
||||
eq(null),
|
||||
eq(null),
|
||||
eq(PageRequest.of(0, 20))
|
||||
)).thenReturn(new PageImpl<>(List.of(directory), PageRequest.of(0, 20), 1));
|
||||
|
||||
var response = fileSearchService.search(user, new FileSearchQuery(
|
||||
null,
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
20
|
||||
));
|
||||
|
||||
assertThat(response.items().get(0).path()).isEqualTo("/docs/archive");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidSearchRange() {
|
||||
User user = createUser(7L);
|
||||
|
||||
assertThatThrownBy(() -> fileSearchService.search(user, new FileSearchQuery(
|
||||
null,
|
||||
null,
|
||||
100L,
|
||||
1L,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
20
|
||||
))).isInstanceOf(ApiV2Exception.class)
|
||||
.hasMessageContaining("文件大小范围不合法");
|
||||
}
|
||||
|
||||
private User createUser(Long id) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
user.setUsername("user-" + id);
|
||||
user.setEmail("user-" + id + "@example.com");
|
||||
return user;
|
||||
}
|
||||
|
||||
private StoredFile createFile(Long id, User user, String path, String filename, boolean directory) {
|
||||
StoredFile file = new StoredFile();
|
||||
file.setId(id);
|
||||
file.setUser(user);
|
||||
file.setFilename(filename);
|
||||
file.setPath(path);
|
||||
file.setContentType(directory ? "directory" : "text/plain");
|
||||
file.setSize(directory ? 0L : 5L);
|
||||
file.setDirectory(directory);
|
||||
file.setCreatedAt(LocalDateTime.of(2026, 4, 8, 10, 0));
|
||||
file.setUpdatedAt(LocalDateTime.of(2026, 4, 8, 11, 0));
|
||||
return file;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.Color;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MediaMetadataBackgroundTaskHandlerTest {
|
||||
|
||||
@Mock
|
||||
private StoredFileRepository storedFileRepository;
|
||||
@Mock
|
||||
private FileMetadataRepository fileMetadataRepository;
|
||||
@Mock
|
||||
private FileContentStorage fileContentStorage;
|
||||
|
||||
private MediaMetadataBackgroundTaskHandler handler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
handler = new MediaMetadataBackgroundTaskHandler(
|
||||
storedFileRepository,
|
||||
fileMetadataRepository,
|
||||
fileContentStorage,
|
||||
new ObjectMapper()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExtractImageMetadataFromPngBlob() throws Exception {
|
||||
BackgroundTask task = createTask(11L);
|
||||
StoredFile file = createFile(11L, false, "image/png", 64L, "blobs/photo.png");
|
||||
byte[] pngBytes = createPngBytes(2, 1);
|
||||
|
||||
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(11L, 7L)).thenReturn(Optional.of(file));
|
||||
when(fileContentStorage.readBlob("blobs/photo.png")).thenReturn(pngBytes);
|
||||
when(fileMetadataRepository.findByFileIdAndName(11L, "media:contentType")).thenReturn(Optional.empty());
|
||||
when(fileMetadataRepository.findByFileIdAndName(11L, "media:size")).thenReturn(Optional.empty());
|
||||
when(fileMetadataRepository.findByFileIdAndName(11L, "media:width")).thenReturn(Optional.empty());
|
||||
when(fileMetadataRepository.findByFileIdAndName(11L, "media:height")).thenReturn(Optional.empty());
|
||||
when(fileMetadataRepository.save(any(FileMetadata.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
BackgroundTaskHandlerResult result = handler.handle(task);
|
||||
|
||||
assertThat(result.publicStatePatch()).containsEntry("worker", "media-metadata");
|
||||
assertThat(result.publicStatePatch()).containsEntry("metadataExtracted", true);
|
||||
assertThat(result.publicStatePatch()).containsEntry("mediaContentType", "image/png");
|
||||
assertThat(result.publicStatePatch()).containsEntry("mediaSize", 64L);
|
||||
assertThat(result.publicStatePatch()).containsEntry("mediaWidth", 2);
|
||||
assertThat(result.publicStatePatch()).containsEntry("mediaHeight", 1);
|
||||
verify(fileContentStorage).readBlob("blobs/photo.png");
|
||||
|
||||
ArgumentCaptor<FileMetadata> captor = ArgumentCaptor.forClass(FileMetadata.class);
|
||||
verify(fileMetadataRepository, times(4)).save(captor.capture());
|
||||
List<FileMetadata> saved = captor.getAllValues();
|
||||
assertThat(saved).extracting(FileMetadata::getName)
|
||||
.containsExactly("media:contentType", "media:size", "media:width", "media:height");
|
||||
assertThat(saved).extracting(FileMetadata::getValue)
|
||||
.containsExactly("image/png", "64", "2", "1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldWriteBaseMetadataForVideoBlobWithoutDimensions() {
|
||||
BackgroundTask task = createTask(12L);
|
||||
StoredFile file = createFile(12L, false, "video/mp4", 128L, "blobs/movie.mp4");
|
||||
|
||||
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(12L, 7L)).thenReturn(Optional.of(file));
|
||||
when(fileContentStorage.readBlob("blobs/movie.mp4")).thenReturn(new byte[] {0, 1, 2});
|
||||
when(fileMetadataRepository.findByFileIdAndName(12L, "media:contentType")).thenReturn(Optional.empty());
|
||||
when(fileMetadataRepository.findByFileIdAndName(12L, "media:size")).thenReturn(Optional.empty());
|
||||
when(fileMetadataRepository.save(any(FileMetadata.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
BackgroundTaskHandlerResult result = handler.handle(task);
|
||||
|
||||
assertThat(result.publicStatePatch()).containsEntry("worker", "media-metadata");
|
||||
assertThat(result.publicStatePatch()).containsEntry("metadataExtracted", true);
|
||||
assertThat(result.publicStatePatch()).containsEntry("mediaContentType", "video/mp4");
|
||||
assertThat(result.publicStatePatch()).containsEntry("mediaSize", 128L);
|
||||
assertThat(result.publicStatePatch()).doesNotContainKeys("mediaWidth", "mediaHeight");
|
||||
verify(fileMetadataRepository, times(2)).save(any(FileMetadata.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectMissingFileDirectoryOrBlob() {
|
||||
BackgroundTask task = createTask(13L);
|
||||
|
||||
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(13L, 7L)).thenReturn(Optional.empty());
|
||||
assertThatThrownBy(() -> handler.handle(task))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("media metadata task file not found");
|
||||
|
||||
StoredFile directory = createFile(13L, true, null, 0L, null);
|
||||
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(13L, 7L)).thenReturn(Optional.of(directory));
|
||||
assertThatThrownBy(() -> handler.handle(task))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("media metadata task only supports files");
|
||||
|
||||
StoredFile missingBlob = createFile(13L, false, "image/png", 10L, null);
|
||||
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(13L, 7L)).thenReturn(Optional.of(missingBlob));
|
||||
assertThatThrownBy(() -> handler.handle(task))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("media metadata task requires blob");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepNoopHandlerLimitedToArchiveAndExtract() {
|
||||
NoopBackgroundTaskHandler noop = new NoopBackgroundTaskHandler();
|
||||
assertThat(noop.supports(BackgroundTaskType.ARCHIVE)).isTrue();
|
||||
assertThat(noop.supports(BackgroundTaskType.EXTRACT)).isTrue();
|
||||
assertThat(noop.supports(BackgroundTaskType.MEDIA_META)).isFalse();
|
||||
}
|
||||
|
||||
private BackgroundTask createTask(Long fileId) {
|
||||
BackgroundTask task = new BackgroundTask();
|
||||
task.setId(99L);
|
||||
task.setType(BackgroundTaskType.MEDIA_META);
|
||||
task.setStatus(BackgroundTaskStatus.RUNNING);
|
||||
task.setUserId(7L);
|
||||
task.setPublicStateJson("{\"fileId\":" + fileId + "}");
|
||||
task.setPrivateStateJson("{\"fileId\":" + fileId + ",\"taskType\":\"MEDIA_META\"}");
|
||||
return task;
|
||||
}
|
||||
|
||||
private StoredFile createFile(Long id, boolean directory, String contentType, Long size, String objectKey) {
|
||||
StoredFile file = new StoredFile();
|
||||
file.setId(id);
|
||||
file.setDirectory(directory);
|
||||
file.setContentType(contentType);
|
||||
file.setSize(size);
|
||||
if (objectKey != null) {
|
||||
FileBlob blob = new FileBlob();
|
||||
blob.setId(100L);
|
||||
blob.setObjectKey(objectKey);
|
||||
blob.setContentType(contentType);
|
||||
blob.setSize(size);
|
||||
file.setBlob(blob);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private byte[] createPngBytes(int width, int height) throws Exception {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||
image.setRGB(0, 0, Color.RED.getRGB());
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "png", out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -457,3 +457,110 @@
|
||||
- 2026-04-08 阶段 3 第三小步 API 补充:新增 `PUT /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}`,请求体为 `{ "etag": "...", "size": 8388608 }`,用于记录当前用户上传会话的 part 元数据并返回 v2 会话响应。该接口会校验 part 范围和会话状态,当前只更新 `uploadedPartsJson`,不接收或合并真实文件分片内容。
|
||||
- 2026-04-08 阶段 3 第四小步 API 补充:本小步没有新增对外 API。后端新增上传会话过期清理任务,只处理未完成且已过期的会话,并把它们标记为 `EXPIRED`;已完成会话和旧 `/api/files/**` 上传接口响应不变。
|
||||
- 2026-04-08 阶段 4 第一小步 API 补充:本小步没有新增存储策略管理 API。v2 上传会话响应新增 `storagePolicyId`,用于标识该会话绑定的默认存储策略;当前该字段只服务后续 multipart/多策略迁移,旧 `/api/files/**` 上传下载接口响应不变。
|
||||
|
||||
## 2026-04-08 阶段 5 文件搜索第一小步
|
||||
|
||||
`GET /api/v2/files/search`
|
||||
|
||||
说明:
|
||||
|
||||
- 需要登录,且只返回当前用户自己的未删除文件或目录。
|
||||
- 返回 v2 envelope,`data` 结构复用 `PageResponse<FileMetadataResponse>`:`items`、`total`、`page`、`size`。
|
||||
- 支持查询参数:`name`、`type`、`sizeGte`、`sizeLte`、`createdGte`、`createdLte`、`updatedGte`、`updatedLte`、`page`、`size`。
|
||||
- `type` 支持 `file`、`directory`、`folder`、`all`;时间参数使用 ISO 日期时间格式,例如 `2026-04-08T12:00:00`。
|
||||
- 当前搜索只基于 `StoredFile` 固定字段,不启用标签或 metadata 条件过滤;旧 `/api/files/list` 与上传下载分享接口保持不变。
|
||||
|
||||
## 2026-04-08 阶段 5 文件搜索第二小步
|
||||
|
||||
- 前端通过 `front/src/lib/file-search.ts` 接入 `GET /api/v2/files/search`,该 helper 会拼接 `name`、`type`、`sizeGte/sizeLte`、`createdGte/createdLte`、`updatedGte/updatedLte`、`page`、`size`,并复用 `apiV2Request()` 的生产端点、认证与 client id 头。
|
||||
- `front/src/pages/Files.tsx` 的桌面端文件页新增独立搜索视图,搜索结果不写入 `getFilesListCacheKey(...)`,清空搜索后回到当前目录列表;移动端搜索尚未接入。
|
||||
|
||||
## 2026-04-08 阶段 5 分享二期后端最小骨架
|
||||
|
||||
`POST /api/v2/shares`
|
||||
|
||||
需要登录。
|
||||
|
||||
- 为当前用户自己的非目录文件创建分享。
|
||||
- 请求字段:`fileId`,以及可选的 `password`、`expiresAt`、`maxDownloads`、`allowImport`、`allowDownload`、`shareName`。
|
||||
- 密码只保存 hash,不在响应中返回。
|
||||
|
||||
`GET /api/v2/shares/{token}`
|
||||
|
||||
公开访问。
|
||||
|
||||
- 返回分享摘要。
|
||||
- 如果分享设置了密码,在校验前不返回 `file` 详情。
|
||||
|
||||
`POST /api/v2/shares/{token}/verify-password`
|
||||
|
||||
公开访问。
|
||||
|
||||
- 校验分享密码,成功后返回可读分享摘要。
|
||||
- 响应永不返回 `passwordHash`。
|
||||
|
||||
`POST /api/v2/shares/{token}/import`
|
||||
|
||||
需要登录。
|
||||
|
||||
- 把分享文件导入当前用户网盘。
|
||||
- 分享过期、密码错误或未提供、`allowImport=false`、`maxDownloads` 已耗尽时拒绝导入。
|
||||
|
||||
`GET /api/v2/shares/mine`
|
||||
|
||||
需要登录。
|
||||
|
||||
- 分页列出当前用户创建的分享。
|
||||
|
||||
`DELETE /api/v2/shares/{id}`
|
||||
|
||||
需要登录。
|
||||
|
||||
- 只删除当前用户自己的分享。
|
||||
|
||||
- 旧 `/api/files/share-links/**` 接口保留兼容;当前 `allowDownload` 已落库并返回,但还没有独立 v2 下载路由消费它。
|
||||
|
||||
## 2026-04-08 阶段 5 文件事件流最小闭环
|
||||
|
||||
`GET /api/v2/files/events?path=/`
|
||||
|
||||
说明:
|
||||
- 需要登录,返回 `text/event-stream`
|
||||
- 请求头支持 `X-Yoyuzh-Client-Id`
|
||||
- 首次连接会先推送一个轻量 `READY` 事件
|
||||
- 事件写入 `FileEvent` 表,字段包含 `userId`、`eventType`、`fileId`、`fromPath`、`toPath`、`clientId`、`payloadJson`、`createdAt`
|
||||
- 当前后端已做同用户广播、路径前缀过滤和同 `clientId` 自身事件抑制
|
||||
- 前端通过 `front/src/lib/file-events.ts` 以 fetch stream 订阅该 SSE,复用鉴权与 `X-Yoyuzh-Client-Id` 请求头;桌面 `Files` 与移动 `MobileFiles` 收到变更事件后会失效当前目录缓存并刷新当前目录列表
|
||||
|
||||
## 2026-04-08 阶段 6 任务框架与 worker 后端最小骨架
|
||||
|
||||
`GET /api/v2/tasks`
|
||||
|
||||
需要登录。分页列出当前用户自己的后台任务。
|
||||
|
||||
`GET /api/v2/tasks/{id}`
|
||||
|
||||
需要登录。只返回当前用户自己的任务详情。
|
||||
|
||||
`DELETE /api/v2/tasks/{id}`
|
||||
|
||||
需要登录。取消当前用户自己的任务,`QUEUED` / `RUNNING` 会转为 `CANCELLED` 并写入 `finishedAt`,终态任务保持原样。
|
||||
|
||||
`POST /api/v2/tasks/archive`
|
||||
|
||||
需要登录。创建 `ARCHIVE` 类型的 `QUEUED` 任务;`fileId` 必须属于当前用户且未删除,`path` 必须匹配服务端派生逻辑路径,暂允许文件和目录;当前 worker 只做 no-op 占位完成,不执行真实压缩。
|
||||
|
||||
`POST /api/v2/tasks/extract`
|
||||
|
||||
需要登录。创建 `EXTRACT` 类型的 `QUEUED` 任务;`fileId` 必须属于当前用户且未删除,`path` 必须匹配服务端派生逻辑路径,并拒绝目录和非压缩包类文件;当前 worker 只做 no-op 占位完成,不执行真实解压。
|
||||
|
||||
`POST /api/v2/tasks/media-metadata`
|
||||
|
||||
需要登录。创建 `MEDIA_META` 类型的 `QUEUED` 任务;`fileId` 必须属于当前用户且未删除,`path` 必须匹配服务端派生逻辑路径,并拒绝目录和非媒体类文件;worker 会重新按 `userId + fileId` 加载文件,写入 `media:contentType`、`media:size`,对 ImageIO 可识别图片额外写 `media:width` 和 `media:height`。当前仍不做缩略图、视频时长或前端任务面板。
|
||||
|
||||
补充说明:
|
||||
|
||||
- worker 会定时领取少量 `QUEUED` 任务并切换为 `RUNNING`,完成后标记 `COMPLETED`,异常时标记 `FAILED` 并写入 `errorMessage`。
|
||||
- 已取消或其他终态任务不会被重新执行。
|
||||
- 创建成功后的任务 state 使用服务端文件信息,至少包含 `fileId`、`path`、`filename`、`directory`、`contentType`、`size`。
|
||||
- 桌面端 `Files` 页面会拉取最近 10 条任务、提供 `QUEUED/RUNNING` 取消按钮,并可为当前选中文件创建 `MEDIA_META` 任务;移动端与 archive/extract 的前端入口暂未接入。
|
||||
|
||||
@@ -453,3 +453,37 @@ Android 壳补充说明:
|
||||
- 2026-04-08 `files/storage` 合并补充:S3 存储实现拆出多吉云临时密钥客户端与运行期会话提供器。`S3FileContentStorage` 现在通过 `S3SessionProvider.currentSession()` 获取当前 bucket、`S3Client` 和 `S3Presigner`,避免每次操作重复内联多吉云 token 解析逻辑;测试环境可直接注入 mock S3 client/presigner。该改动没有引入 multipart,仍是单对象 PUT/HEAD/GET/COPY/DELETE 路径。
|
||||
- 2026-04-08 阶段 4 第二小步补充:`FileService` 在创建新的 `FileEntity.VERSION` 时会通过 `StoragePolicyService.ensureDefaultPolicy()` 写入默认 `storagePolicyId`;`FileEntityBackfillService` 对历史 `FileBlob` 回填新实体时也写入同一默认策略。复用已有实体时保持原策略字段不变,只增加引用计数,避免在兼容迁移阶段覆盖历史数据。
|
||||
- 2026-04-08 阶段 4 第三小步补充:管理台新增只读存储策略列表。`AdminController` 暴露 `GET /api/admin/storage-policies`,`AdminService` 通过白名单 DTO 返回策略基础字段和结构化 `StoragePolicyCapabilities`;前端 `react-admin` 新增 `storagePolicies` 资源展示能力矩阵。该能力只做配置可视化,不改变旧上传下载路径,不暴露凭证,不启用策略编辑或 multipart。
|
||||
|
||||
## 2026-04-08 阶段 5 文件搜索第一小步
|
||||
|
||||
- 后端新增 `FileMetadata` 与 `FileMetadataRepository`,作为后续标签、缩略图状态、媒体属性和自定义属性的统一扩展表骨架。当前阶段只建表与仓储入口,不迁移现有回收站字段,也不改变旧 `/api/files/**` DTO。
|
||||
- 后端新增 `FileSearchService` 与 `GET /api/v2/files/search`,按当前登录用户查询未删除的 `StoredFile`,支持文件名、文件/目录类型、大小、创建时间、更新时间和分页过滤。
|
||||
- 搜索结果复用现有 `FileMetadataResponse`,因此旧网盘列表、下载、分享、回收站和上传链路不受影响;前端用户侧搜索 UI 和 metadata/tag 过滤留到后续小步接入。
|
||||
|
||||
## 2026-04-08 阶段 5 文件搜索第二小步
|
||||
|
||||
- 前端桌面端新增独立搜索模式:`front/src/lib/file-search.ts` 复用 `apiV2Request('/files/search', ...)`,并在 `front/src/lib/file-search.test.ts` 覆盖参数编码、空参数跳过和 v2 数据解包。
|
||||
- `front/src/pages/Files.tsx` 同时保留目录视图和搜索结果视图,搜索结果不写入 `getFilesListCacheKey(...)`,也不影响原有目录缓存和上传主链路;移动端文件页暂未接入搜索。
|
||||
|
||||
## 2026-04-08 阶段 5 分享二期后端最小骨架
|
||||
|
||||
- 旧分享仍保留在 `/api/files/share-links/**`,用于兼容当前前端公开分享页和旧导入路径。
|
||||
- 新 v2 分享位于 `com.yoyuzh.api.v2.shares` 与 `ShareV2Service`;`FileShareLink` 新增 `passwordHash`、`expiresAt`、`maxDownloads`、`downloadCount`、`viewCount`、`allowImport`、`allowDownload`、`shareName` 策略字段。
|
||||
- 公开端点仅包括 `GET /api/v2/shares/{token}` 与 `POST /api/v2/shares/{token}/verify-password`;创建、导入、我的分享列表和删除仍需要登录。
|
||||
- 密码分享在校验前隐藏 `file` 详情;v2 导入会在复用旧导入落库链路前校验过期时间、密码、`allowImport` 和 `maxDownloads`。当前 `allowDownload` 只落库和返回,尚未接入独立 v2 下载路由。
|
||||
|
||||
## 2026-04-08 阶段 5 文件事件流最小闭环
|
||||
|
||||
- 后端新增 `FileEvent` / `FileEventType` / `FileEventRepository` / `FileEventService`,并暴露受保护的 `GET /api/v2/files/events` SSE 入口。
|
||||
- 当前事件流以用户为广播边界,支持 `path` 前缀过滤和 `X-Yoyuzh-Client-Id` 自身事件抑制;首次连接会收到 `READY` 事件。
|
||||
- `FileService` 只在上传、导入、复制、移动、重命名、删除、恢复这些核心变更点记录最小事件。
|
||||
- 前端新增 `front/src/lib/file-events.ts`,通过 fetch stream 复用鉴权和 `X-Yoyuzh-Client-Id` 请求头,不直接使用原生 `EventSource`;桌面 `Files` 与移动 `MobileFiles` 已在当前目录订阅事件,收到变更后失效当前目录缓存并刷新列表。
|
||||
|
||||
## 2026-04-08 阶段 6 任务框架与 worker 后端最小骨架
|
||||
|
||||
- 后端新增 `BackgroundTask` / `BackgroundTaskType` / `BackgroundTaskStatus` / `BackgroundTaskRepository` / `BackgroundTaskService`,用于承载后续压缩、解压、缩略图、媒体元数据和清理类后台工作。
|
||||
- 新增受保护的 `/api/v2/tasks/**`:`GET /api/v2/tasks`、`GET /api/v2/tasks/{id}`、`DELETE /api/v2/tasks/{id}`,以及 `POST /api/v2/tasks/archive`、`POST /api/v2/tasks/extract`、`POST /api/v2/tasks/media-metadata` 占位创建接口。
|
||||
- 任务创建入口集中在 `BackgroundTaskService` 校验 `StoredFile`:`fileId` 必须属于当前用户且未删除,请求 `path` 必须匹配由 `StoredFile.path + filename` 派生的真实逻辑路径;`ARCHIVE` 暂允许文件和目录,`EXTRACT` 仅允许压缩包类文件,`MEDIA_META` 仅允许媒体类文件。任务 public/private state 使用服务端派生的 `fileId`、`path`、`filename`、`directory`、`contentType`、`size`。
|
||||
- 当前实现新增了 worker 最小调度:定时扫描少量 `QUEUED` 任务,通过状态条件更新完成 claim,`MEDIA_META` 任务会进入独立 handler 写入基础媒体元数据与图片宽高,其余任务类型执行 no-op handler 后标记 `COMPLETED`;handler 异常会标记 `FAILED` 并记录错误原因,已取消任务不会被领取。
|
||||
- 当前仍不包含真实压缩、解压、缩略图、媒体元数据解析、重试/恢复策略或前端队列展示。
|
||||
- 桌面端 `front/src/pages/Files.tsx` 已接入最近 10 条后台任务查看与取消入口,并可为当前选中文件创建 `MEDIA_META` 任务;移动端与 archive/extract 的前端入口仍未接入。
|
||||
|
||||
@@ -628,11 +628,13 @@ export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadReques
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiDownload(path: string) {
|
||||
export async function apiDownload(path: string, init: ApiRequestInit = {}) {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('Accept', '*/*');
|
||||
|
||||
const response = await performRequest(path, {
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
},
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
197
front/src/lib/background-tasks.test.ts
Normal file
197
front/src/lib/background-tasks.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildBackgroundTasksPath,
|
||||
cancelBackgroundTask,
|
||||
createMediaMetadataTask,
|
||||
getBackgroundTask,
|
||||
listBackgroundTasks,
|
||||
parseBackgroundTaskState,
|
||||
} from './background-tasks';
|
||||
|
||||
test('buildBackgroundTasksPath defaults to the first ten tasks', () => {
|
||||
assert.equal(buildBackgroundTasksPath(), '/tasks?page=0&size=10');
|
||||
});
|
||||
|
||||
test('listBackgroundTasks requests the v2 task list and unwraps the page payload', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
try {
|
||||
let requestUrl = '';
|
||||
let requestMethod = '';
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requestUrl = String(input);
|
||||
requestMethod = init?.method || 'GET';
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'MEDIA_META',
|
||||
status: 'QUEUED',
|
||||
userId: 7,
|
||||
publicStateJson: '{"fileId":1}',
|
||||
correlationId: 'corr-1',
|
||||
errorMessage: null,
|
||||
createdAt: '2026-04-09T10:00:00',
|
||||
updatedAt: '2026-04-09T10:00:00',
|
||||
finishedAt: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 10,
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const payload = await listBackgroundTasks();
|
||||
|
||||
assert.equal(requestUrl, '/api/v2/tasks?page=0&size=10');
|
||||
assert.equal(requestMethod, 'GET');
|
||||
assert.deepEqual(payload, {
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'MEDIA_META',
|
||||
status: 'QUEUED',
|
||||
userId: 7,
|
||||
publicStateJson: '{"fileId":1}',
|
||||
correlationId: 'corr-1',
|
||||
errorMessage: null,
|
||||
createdAt: '2026-04-09T10:00:00',
|
||||
updatedAt: '2026-04-09T10:00:00',
|
||||
finishedAt: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 10,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('getBackgroundTask and cancelBackgroundTask hit the task detail endpoints', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
try {
|
||||
const calls: Array<{url: string; method: string}> = [];
|
||||
globalThis.fetch = async (input, init) => {
|
||||
calls.push({
|
||||
url: String(input),
|
||||
method: init?.method || 'GET',
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
id: 123,
|
||||
type: 'ARCHIVE',
|
||||
status: 'COMPLETED',
|
||||
userId: 7,
|
||||
publicStateJson: '{"worker":"noop"}',
|
||||
correlationId: null,
|
||||
errorMessage: null,
|
||||
createdAt: '2026-04-09T10:00:00',
|
||||
updatedAt: '2026-04-09T10:00:00',
|
||||
finishedAt: '2026-04-09T10:01:00',
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
await getBackgroundTask(123);
|
||||
await cancelBackgroundTask(123);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{ url: '/api/v2/tasks/123', method: 'GET' },
|
||||
{ url: '/api/v2/tasks/123', method: 'DELETE' },
|
||||
]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('createMediaMetadataTask sends the queued file task payload', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
try {
|
||||
let requestUrl = '';
|
||||
let requestMethod = '';
|
||||
let requestBody = '';
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requestUrl = String(input);
|
||||
requestMethod = init?.method || 'GET';
|
||||
requestBody = String(init?.body || '');
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
id: 123,
|
||||
type: 'MEDIA_META',
|
||||
status: 'QUEUED',
|
||||
userId: 7,
|
||||
publicStateJson: '{"fileId":9}',
|
||||
correlationId: 'media-9',
|
||||
errorMessage: null,
|
||||
createdAt: '2026-04-09T10:00:00',
|
||||
updatedAt: '2026-04-09T10:00:00',
|
||||
finishedAt: null,
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const payload = await createMediaMetadataTask({
|
||||
fileId: 9,
|
||||
path: '/docs/photo.png',
|
||||
correlationId: 'media-9',
|
||||
});
|
||||
|
||||
assert.equal(requestUrl, '/api/v2/tasks/media-metadata');
|
||||
assert.equal(requestMethod, 'POST');
|
||||
assert.deepEqual(JSON.parse(requestBody), {
|
||||
fileId: 9,
|
||||
path: '/docs/photo.png',
|
||||
correlationId: 'media-9',
|
||||
});
|
||||
assert.equal(payload.status, 'QUEUED');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('parseBackgroundTaskState handles valid and invalid JSON defensively', () => {
|
||||
assert.deepEqual(parseBackgroundTaskState(null), {});
|
||||
assert.deepEqual(parseBackgroundTaskState(''), {});
|
||||
assert.deepEqual(parseBackgroundTaskState('not-json'), {});
|
||||
assert.deepEqual(parseBackgroundTaskState('[]'), {});
|
||||
assert.deepEqual(parseBackgroundTaskState('{"worker":"media-metadata","fileId":9}'), {
|
||||
worker: 'media-metadata',
|
||||
fileId: 9,
|
||||
});
|
||||
});
|
||||
93
front/src/lib/background-tasks.ts
Normal file
93
front/src/lib/background-tasks.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { apiV2Request } from './api';
|
||||
import type { PageResponse } from './types';
|
||||
|
||||
export type BackgroundTaskType = 'ARCHIVE' | 'EXTRACT' | 'MEDIA_META';
|
||||
|
||||
export type BackgroundTaskStatus = 'QUEUED' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'CANCELLED';
|
||||
|
||||
export interface BackgroundTask {
|
||||
id: number;
|
||||
type: BackgroundTaskType;
|
||||
status: BackgroundTaskStatus;
|
||||
userId: number;
|
||||
publicStateJson: string;
|
||||
correlationId: string | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
finishedAt: string | null;
|
||||
}
|
||||
|
||||
export type BackgroundTaskState = Record<string, unknown>;
|
||||
|
||||
export interface BackgroundTaskPage extends PageResponse<BackgroundTask> {}
|
||||
|
||||
export interface ListBackgroundTasksParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface CreateMediaMetadataTaskParams {
|
||||
fileId: number;
|
||||
path: string;
|
||||
correlationId?: string;
|
||||
}
|
||||
|
||||
function appendNumberParam(searchParams: URLSearchParams, key: string, value?: number) {
|
||||
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
export function buildBackgroundTasksPath(params: ListBackgroundTasksParams = {}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
appendNumberParam(searchParams, 'page', params.page ?? 0);
|
||||
appendNumberParam(searchParams, 'size', params.size ?? 10);
|
||||
|
||||
const query = searchParams.toString();
|
||||
return query ? `/tasks?${query}` : '/tasks';
|
||||
}
|
||||
|
||||
export function listBackgroundTasks(params: ListBackgroundTasksParams = {}) {
|
||||
return apiV2Request<BackgroundTaskPage>(buildBackgroundTasksPath(params));
|
||||
}
|
||||
|
||||
export function getBackgroundTask(id: number) {
|
||||
return apiV2Request<BackgroundTask>(`/tasks/${id}`);
|
||||
}
|
||||
|
||||
export function cancelBackgroundTask(id: number) {
|
||||
return apiV2Request<BackgroundTask>(`/tasks/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export function createMediaMetadataTask(params: CreateMediaMetadataTaskParams) {
|
||||
return apiV2Request<BackgroundTask>('/tasks/media-metadata', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
fileId: params.fileId,
|
||||
path: params.path,
|
||||
correlationId: params.correlationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function parseBackgroundTaskState(publicStateJson?: string | null): BackgroundTaskState {
|
||||
if (!publicStateJson) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(publicStateJson);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return parsed as BackgroundTaskState;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
59
front/src/lib/file-events.test.ts
Normal file
59
front/src/lib/file-events.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildFileEventsPath, createSseEventParser, getFileEventsReconnectDelayMs } from './file-events';
|
||||
|
||||
test('buildFileEventsPath encodes the watched path', () => {
|
||||
assert.equal(
|
||||
buildFileEventsPath('/课程资料/2026 春'),
|
||||
'/files/events?path=%2F%E8%AF%BE%E7%A8%8B%E8%B5%84%E6%96%99%2F2026+%E6%98%A5',
|
||||
);
|
||||
});
|
||||
|
||||
test('createSseEventParser ignores READY and returns file events', () => {
|
||||
const parser = createSseEventParser();
|
||||
|
||||
const events = parser.push([
|
||||
'event: READY',
|
||||
'data: {"eventType":"READY","path":"/"}',
|
||||
'',
|
||||
'event: CREATED',
|
||||
'data: {"eventType":"CREATED","fileId":42,"fromPath":null,"toPath":"/notes.txt","clientId":"other","createdAt":"2026-04-08T12:00:00","payload":"{}"}',
|
||||
'',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
eventType: 'CREATED',
|
||||
fileId: 42,
|
||||
fromPath: null,
|
||||
toPath: '/notes.txt',
|
||||
clientId: 'other',
|
||||
createdAt: '2026-04-08T12:00:00',
|
||||
payload: '{}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('createSseEventParser keeps partial chunks until the event is complete', () => {
|
||||
const parser = createSseEventParser();
|
||||
|
||||
assert.deepEqual(parser.push('event: RENAMED\ndata: {"eventType":"REN'), []);
|
||||
|
||||
assert.deepEqual(parser.push('AMED","fileId":7,"fromPath":"/old.txt","toPath":"/new.txt"}\n\n'), [
|
||||
{
|
||||
eventType: 'RENAMED',
|
||||
fileId: 7,
|
||||
fromPath: '/old.txt',
|
||||
toPath: '/new.txt',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('getFileEventsReconnectDelayMs uses capped backoff for stream reconnects', () => {
|
||||
assert.equal(getFileEventsReconnectDelayMs(0), 1000);
|
||||
assert.equal(getFileEventsReconnectDelayMs(1), 1500);
|
||||
assert.equal(getFileEventsReconnectDelayMs(2), 2250);
|
||||
assert.equal(getFileEventsReconnectDelayMs(10), 5000);
|
||||
});
|
||||
220
front/src/lib/file-events.ts
Normal file
220
front/src/lib/file-events.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { apiDownload } from './api';
|
||||
|
||||
export type FileEventType = 'CREATED' | 'UPDATED' | 'RENAMED' | 'MOVED' | 'DELETED' | 'RESTORED';
|
||||
|
||||
export interface FileEventMessage {
|
||||
eventType: FileEventType;
|
||||
fileId?: number | null;
|
||||
fromPath?: string | null;
|
||||
toPath?: string | null;
|
||||
clientId?: string | null;
|
||||
createdAt?: string;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
export interface FileEventsSubscription {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
interface SubscribeFileEventsOptions {
|
||||
onError?: (error: unknown) => void;
|
||||
onFileEvent: (event: FileEventMessage) => void;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const READY_EVENT_TYPE = 'READY';
|
||||
const FILE_EVENT_TYPES = new Set<FileEventType>([
|
||||
'CREATED',
|
||||
'UPDATED',
|
||||
'RENAMED',
|
||||
'MOVED',
|
||||
'DELETED',
|
||||
'RESTORED',
|
||||
]);
|
||||
const FILE_EVENTS_RECONNECT_INITIAL_DELAY_MS = 1000;
|
||||
const FILE_EVENTS_RECONNECT_MAX_DELAY_MS = 5000;
|
||||
const FILE_EVENTS_RECONNECT_MULTIPLIER = 1.5;
|
||||
|
||||
export function getFileEventsReconnectDelayMs(attempt: number) {
|
||||
return Math.min(
|
||||
Math.round(FILE_EVENTS_RECONNECT_INITIAL_DELAY_MS * FILE_EVENTS_RECONNECT_MULTIPLIER ** Math.max(0, attempt)),
|
||||
FILE_EVENTS_RECONNECT_MAX_DELAY_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
|
||||
const handleAbort = () => {
|
||||
clearTimeout(timeoutId);
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
reject(signal.reason);
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', handleAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function buildFileEventsPath(path: string) {
|
||||
const normalizedPath = path.trim() || '/';
|
||||
const searchParams = new URLSearchParams({
|
||||
path: normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`,
|
||||
});
|
||||
return `/files/events?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
function parseSseBlock(block: string): FileEventMessage | null {
|
||||
const dataLines: string[] = [];
|
||||
let eventName = '';
|
||||
|
||||
for (const line of block.split('\n')) {
|
||||
if (!line || line.startsWith(':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separatorIndex = line.indexOf(':');
|
||||
const field = separatorIndex >= 0 ? line.slice(0, separatorIndex) : line;
|
||||
const rawValue = separatorIndex >= 0 ? line.slice(separatorIndex + 1) : '';
|
||||
const value = rawValue.startsWith(' ') ? rawValue.slice(1) : rawValue;
|
||||
|
||||
if (field === 'event') {
|
||||
eventName = value;
|
||||
} else if (field === 'data') {
|
||||
dataLines.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventName === READY_EVENT_TYPE || dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(dataLines.join('\n')) as {
|
||||
clientId?: string | null;
|
||||
createdAt?: string;
|
||||
eventType?: string;
|
||||
fileId?: number | null;
|
||||
fromPath?: string | null;
|
||||
payload?: unknown;
|
||||
toPath?: string | null;
|
||||
};
|
||||
if (payload.eventType === READY_EVENT_TYPE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventType = payload.eventType || eventName;
|
||||
if (!FILE_EVENT_TYPES.has(eventType as FileEventType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
eventType: eventType as FileEventType,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSseEventParser() {
|
||||
let buffer = '';
|
||||
|
||||
return {
|
||||
push(chunk: string) {
|
||||
buffer += chunk.replace(/\r\n/g, '\n');
|
||||
const events: FileEventMessage[] = [];
|
||||
|
||||
while (true) {
|
||||
const eventBoundary = buffer.indexOf('\n\n');
|
||||
if (eventBoundary < 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const block = buffer.slice(0, eventBoundary);
|
||||
buffer = buffer.slice(eventBoundary + 2);
|
||||
const event = parseSseBlock(block);
|
||||
if (event) {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeFileEvents({
|
||||
onError,
|
||||
onFileEvent,
|
||||
path,
|
||||
}: SubscribeFileEventsOptions): FileEventsSubscription {
|
||||
const abortController = new AbortController();
|
||||
let closed = false;
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||
|
||||
const readStream = async () => {
|
||||
let reconnectAttempt = 0;
|
||||
|
||||
while (!closed) {
|
||||
let streamHadData = false;
|
||||
try {
|
||||
const parser = createSseEventParser();
|
||||
const response = await apiDownload(`/v2${buildFileEventsPath(path)}`, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('文件事件流不可用');
|
||||
}
|
||||
|
||||
reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (!closed) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
streamHadData = true;
|
||||
for (const event of parser.push(decoder.decode(value, { stream: true }))) {
|
||||
onFileEvent(event);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!closed) {
|
||||
onError?.(error);
|
||||
}
|
||||
} finally {
|
||||
reader?.releaseLock();
|
||||
reader = null;
|
||||
}
|
||||
|
||||
if (closed) {
|
||||
break;
|
||||
}
|
||||
|
||||
const nextAttempt = streamHadData ? 0 : reconnectAttempt;
|
||||
await sleep(getFileEventsReconnectDelayMs(nextAttempt), abortController.signal).catch(() => undefined);
|
||||
reconnectAttempt = streamHadData ? 0 : reconnectAttempt + 1;
|
||||
if (abortController.signal.aborted) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void readStream();
|
||||
|
||||
return {
|
||||
close() {
|
||||
closed = true;
|
||||
abortController.abort();
|
||||
reader?.cancel().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
96
front/src/lib/file-search.test.ts
Normal file
96
front/src/lib/file-search.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildFileSearchPath, searchFiles } from './file-search';
|
||||
|
||||
test('buildFileSearchPath includes search filters and encodes values', () => {
|
||||
assert.equal(
|
||||
buildFileSearchPath({
|
||||
name: '课件 2026',
|
||||
type: 'folder',
|
||||
sizeGte: 1024,
|
||||
sizeLte: 4096,
|
||||
createdGte: '2026-04-08T12:00:00',
|
||||
updatedLte: '2026-04-08T23:59:59',
|
||||
page: 2,
|
||||
size: 50,
|
||||
}),
|
||||
'/files/search?name=%E8%AF%BE%E4%BB%B6+2026&type=folder&sizeGte=1024&sizeLte=4096&createdGte=2026-04-08T12%3A00%3A00&updatedLte=2026-04-08T23%3A59%3A59&page=2&size=50',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildFileSearchPath skips empty filters', () => {
|
||||
assert.equal(
|
||||
buildFileSearchPath({
|
||||
name: ' ',
|
||||
type: 'all',
|
||||
createdGte: '',
|
||||
}),
|
||||
'/files/search?type=all',
|
||||
);
|
||||
});
|
||||
|
||||
test('searchFiles uses the v2 search endpoint and unwraps the page payload', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
try {
|
||||
let requestUrl = '';
|
||||
globalThis.fetch = async (input) => {
|
||||
requestUrl = String(input);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
filename: '说明.txt',
|
||||
path: '/',
|
||||
size: 12,
|
||||
contentType: 'text/plain',
|
||||
directory: false,
|
||||
createdAt: '2026-04-08T12:00:00',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 20,
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const payload = await searchFiles({
|
||||
name: '说明',
|
||||
type: 'file',
|
||||
page: 0,
|
||||
size: 20,
|
||||
});
|
||||
|
||||
assert.equal(requestUrl, '/api/v2/files/search?name=%E8%AF%B4%E6%98%8E&type=file&page=0&size=20');
|
||||
assert.deepEqual(payload, {
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
filename: '说明.txt',
|
||||
path: '/',
|
||||
size: 12,
|
||||
contentType: 'text/plain',
|
||||
directory: false,
|
||||
createdAt: '2026-04-08T12:00:00',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 20,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
56
front/src/lib/file-search.ts
Normal file
56
front/src/lib/file-search.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { apiV2Request } from './api';
|
||||
import type { FileMetadata, PageResponse } from './types';
|
||||
|
||||
export type FileSearchType = 'file' | 'directory' | 'folder' | 'all';
|
||||
|
||||
export interface FileSearchParams {
|
||||
name?: string;
|
||||
type?: FileSearchType;
|
||||
sizeGte?: number;
|
||||
sizeLte?: number;
|
||||
createdGte?: string;
|
||||
createdLte?: string;
|
||||
updatedGte?: string;
|
||||
updatedLte?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
function appendStringParam(searchParams: URLSearchParams, key: string, value?: string) {
|
||||
const normalizedValue = value?.trim();
|
||||
if (!normalizedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchParams.set(key, normalizedValue);
|
||||
}
|
||||
|
||||
function appendNumberParam(searchParams: URLSearchParams, key: string, value?: number) {
|
||||
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
export function buildFileSearchPath(params: FileSearchParams = {}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
appendStringParam(searchParams, 'name', params.name);
|
||||
appendStringParam(searchParams, 'type', params.type);
|
||||
appendNumberParam(searchParams, 'sizeGte', params.sizeGte);
|
||||
appendNumberParam(searchParams, 'sizeLte', params.sizeLte);
|
||||
appendStringParam(searchParams, 'createdGte', params.createdGte);
|
||||
appendStringParam(searchParams, 'createdLte', params.createdLte);
|
||||
appendStringParam(searchParams, 'updatedGte', params.updatedGte);
|
||||
appendStringParam(searchParams, 'updatedLte', params.updatedLte);
|
||||
appendNumberParam(searchParams, 'page', params.page);
|
||||
appendNumberParam(searchParams, 'size', params.size);
|
||||
|
||||
const query = searchParams.toString();
|
||||
return query ? `/files/search?${query}` : '/files/search';
|
||||
}
|
||||
|
||||
export function searchFiles(params: FileSearchParams = {}) {
|
||||
return apiV2Request<PageResponse<FileMetadata>>(buildFileSearchPath(params));
|
||||
}
|
||||
@@ -27,8 +27,9 @@ import { ApiError, apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadReq
|
||||
import { copyFileToNetdiskPath } from '@/src/lib/file-copy';
|
||||
import { moveFileToNetdiskPath } from '@/src/lib/file-move';
|
||||
import { resolveStoredFileType, type FileTypeKind } from '@/src/lib/file-type';
|
||||
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
|
||||
import { readCachedValue, removeCachedValue, writeCachedValue } from '@/src/lib/cache';
|
||||
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
|
||||
import { subscribeFileEvents } from '@/src/lib/file-events';
|
||||
import { ellipsizeFileName } from '@/src/lib/file-name';
|
||||
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
|
||||
import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types';
|
||||
@@ -192,6 +193,22 @@ export default function MobileFiles() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = subscribeFileEvents({
|
||||
path: toBackendPath(currentPath),
|
||||
onFileEvent: () => {
|
||||
const activePath = currentPathRef.current;
|
||||
removeCachedValue(getFilesListCacheKey(toBackendPath(activePath)));
|
||||
loadCurrentPath(activePath).catch(() => undefined);
|
||||
},
|
||||
onError: () => undefined,
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.close();
|
||||
};
|
||||
}, [currentPath]);
|
||||
|
||||
const handleBreadcrumbClick = (index: number) => {
|
||||
setCurrentPath(currentPath.slice(0, index + 1));
|
||||
};
|
||||
|
||||
@@ -29,8 +29,16 @@ import { ApiError, apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadReq
|
||||
import { copyFileToNetdiskPath } from '@/src/lib/file-copy';
|
||||
import { moveFileToNetdiskPath } from '@/src/lib/file-move';
|
||||
import { resolveStoredFileType, type FileTypeKind } from '@/src/lib/file-type';
|
||||
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
|
||||
import { readCachedValue, removeCachedValue, writeCachedValue } from '@/src/lib/cache';
|
||||
import {
|
||||
cancelBackgroundTask,
|
||||
createMediaMetadataTask,
|
||||
listBackgroundTasks,
|
||||
type BackgroundTask,
|
||||
} from '@/src/lib/background-tasks';
|
||||
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
|
||||
import { subscribeFileEvents } from '@/src/lib/file-events';
|
||||
import { searchFiles } from '@/src/lib/file-search';
|
||||
import { ellipsizeFileName } from '@/src/lib/file-name';
|
||||
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
|
||||
import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types';
|
||||
@@ -88,6 +96,10 @@ function toBackendPath(pathParts: string[]) {
|
||||
return toDirectoryPath(pathParts);
|
||||
}
|
||||
|
||||
function splitBackendPath(path: string) {
|
||||
return path.split('/').filter(Boolean);
|
||||
}
|
||||
|
||||
function DirectoryTreeItem({
|
||||
node,
|
||||
onSelect,
|
||||
@@ -221,6 +233,18 @@ export default function Files() {
|
||||
const [renameError, setRenameError] = useState('');
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [shareStatus, setShareStatus] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchAppliedQuery, setSearchAppliedQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<FileMetadata[] | null>(null);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [searchError, setSearchError] = useState('');
|
||||
const [selectedSearchFile, setSelectedSearchFile] = useState<FileMetadata | null>(null);
|
||||
const searchRequestIdRef = useRef(0);
|
||||
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTask[]>([]);
|
||||
const [backgroundTasksLoading, setBackgroundTasksLoading] = useState(false);
|
||||
const [backgroundTasksError, setBackgroundTasksError] = useState('');
|
||||
const [backgroundTaskNotice, setBackgroundTaskNotice] = useState<{ kind: 'success' | 'error'; message: string } | null>(null);
|
||||
const [backgroundTaskActionId, setBackgroundTaskActionId] = useState<number | null>(null);
|
||||
|
||||
const recordDirectoryChildren = (pathParts: string[], items: FileMetadata[]) => {
|
||||
setDirectoryChildren((previous) => {
|
||||
@@ -337,12 +361,47 @@ export default function Files() {
|
||||
directoryInputRef.current.setAttribute('directory', '');
|
||||
}, []);
|
||||
|
||||
const handleSidebarClick = (pathParts: string[]) => {
|
||||
useEffect(() => {
|
||||
const subscription = subscribeFileEvents({
|
||||
path: toBackendPath(currentPath),
|
||||
onFileEvent: () => {
|
||||
const activePath = currentPathRef.current;
|
||||
removeCachedValue(getFilesListCacheKey(toBackendPath(activePath)));
|
||||
loadCurrentPath(activePath).catch(() => undefined);
|
||||
},
|
||||
onError: () => undefined,
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.close();
|
||||
};
|
||||
}, [currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBackgroundTasks();
|
||||
}, []);
|
||||
|
||||
const clearSearchState = () => {
|
||||
searchRequestIdRef.current += 1;
|
||||
setSearchQuery('');
|
||||
setSearchAppliedQuery('');
|
||||
setSearchResults(null);
|
||||
setSearchLoading(false);
|
||||
setSearchError('');
|
||||
setSelectedSearchFile(null);
|
||||
};
|
||||
|
||||
const handleNavigateToPath = (pathParts: string[]) => {
|
||||
clearSearchState();
|
||||
setCurrentPath(pathParts);
|
||||
setSelectedFile(null);
|
||||
setActiveDropdown(null);
|
||||
};
|
||||
|
||||
const handleSidebarClick = (pathParts: string[]) => {
|
||||
handleNavigateToPath(pathParts);
|
||||
};
|
||||
|
||||
const handleDirectoryToggle = async (pathParts: string[]) => {
|
||||
const path = toBackendPath(pathParts);
|
||||
let shouldLoadChildren = false;
|
||||
@@ -377,15 +436,125 @@ export default function Files() {
|
||||
|
||||
const handleFolderDoubleClick = (file: UiFile) => {
|
||||
if (file.type === 'folder') {
|
||||
setCurrentPath([...currentPath, file.name]);
|
||||
setSelectedFile(null);
|
||||
handleNavigateToPath([...currentPath, file.name]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBreadcrumbClick = (index: number) => {
|
||||
setCurrentPath(currentPath.slice(0, index + 1));
|
||||
handleNavigateToPath(currentPath.slice(0, index + 1));
|
||||
};
|
||||
|
||||
const handleSearchSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const nextQuery = searchQuery.trim();
|
||||
if (!nextQuery) {
|
||||
clearSearchState();
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = searchRequestIdRef.current + 1;
|
||||
searchRequestIdRef.current = requestId;
|
||||
setSearchAppliedQuery(nextQuery);
|
||||
setSearchLoading(true);
|
||||
setSearchError('');
|
||||
setSearchResults(null);
|
||||
setSelectedSearchFile(null);
|
||||
setSelectedFile(null);
|
||||
setActiveDropdown(null);
|
||||
|
||||
try {
|
||||
const response = await searchFiles({
|
||||
name: nextQuery,
|
||||
type: 'all',
|
||||
page: 0,
|
||||
size: 100,
|
||||
});
|
||||
|
||||
if (searchRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchResults(response.items);
|
||||
} catch (error) {
|
||||
if (searchRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchResults([]);
|
||||
setSearchError(error instanceof Error ? error.message : '搜索失败');
|
||||
} finally {
|
||||
if (searchRequestIdRef.current === requestId) {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadBackgroundTasks = async () => {
|
||||
setBackgroundTasksLoading(true);
|
||||
setBackgroundTasksError('');
|
||||
|
||||
try {
|
||||
const response = await listBackgroundTasks({ page: 0, size: 10 });
|
||||
setBackgroundTasks(response.items);
|
||||
} catch (error) {
|
||||
setBackgroundTasksError(error instanceof Error ? error.message : '获取后台任务失败');
|
||||
} finally {
|
||||
setBackgroundTasksLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateMediaMetadataTask = async () => {
|
||||
if (!selectedFile || selectedFile.type === 'folder') {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskPath = currentPath.length === 0 ? `/${selectedFile.name}` : `${toBackendPath(currentPath)}/${selectedFile.name}`;
|
||||
const correlationId = `media-meta:${selectedFile.id}:${Date.now()}`;
|
||||
|
||||
setBackgroundTaskNotice(null);
|
||||
setBackgroundTaskActionId(selectedFile.id);
|
||||
|
||||
try {
|
||||
await createMediaMetadataTask({
|
||||
fileId: selectedFile.id,
|
||||
path: taskPath,
|
||||
correlationId,
|
||||
});
|
||||
setBackgroundTaskNotice({
|
||||
kind: 'success',
|
||||
message: '已创建媒体信息提取任务,可在右侧后台任务面板查看状态。',
|
||||
});
|
||||
await loadBackgroundTasks();
|
||||
} catch (error) {
|
||||
setBackgroundTaskNotice({
|
||||
kind: 'error',
|
||||
message: error instanceof Error ? error.message : '创建媒体信息提取任务失败',
|
||||
});
|
||||
} finally {
|
||||
setBackgroundTaskActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelBackgroundTask = async (taskId: number) => {
|
||||
setBackgroundTaskNotice(null);
|
||||
setBackgroundTaskActionId(taskId);
|
||||
|
||||
try {
|
||||
await cancelBackgroundTask(taskId);
|
||||
setBackgroundTaskNotice({
|
||||
kind: 'success',
|
||||
message: `已取消任务 ${taskId},后台列表已刷新。`,
|
||||
});
|
||||
await loadBackgroundTasks();
|
||||
} catch (error) {
|
||||
setBackgroundTaskNotice({
|
||||
kind: 'error',
|
||||
message: error instanceof Error ? error.message : '取消任务失败',
|
||||
});
|
||||
} finally {
|
||||
setBackgroundTaskActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openRenameModal = (file: UiFile) => {
|
||||
@@ -753,6 +922,7 @@ export default function Files() {
|
||||
};
|
||||
|
||||
const directoryTree = buildDirectoryTree(directoryChildren, currentPath, expandedDirectories);
|
||||
const isSearchActive = searchAppliedQuery.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]">
|
||||
@@ -857,8 +1027,174 @@ export default function Files() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="border-b border-white/10 p-4 pt-0" onSubmit={handleSearchSubmit}>
|
||||
<div className="mt-3 flex flex-col gap-2 md:flex-row">
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="按文件名搜索"
|
||||
className="h-10 border-white/10 bg-black/20 text-white placeholder:text-slate-500 focus-visible:ring-[#336EFF]"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="shrink-0" disabled={searchLoading}>
|
||||
{searchLoading ? '搜索中...' : '搜索'}
|
||||
</Button>
|
||||
{isSearchActive ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="shrink-0 border-white/10 text-slate-300 hover:bg-white/10"
|
||||
onClick={() => {
|
||||
clearSearchState();
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{searchError ? <p className="mt-2 text-sm text-red-400">{searchError}</p> : null}
|
||||
</form>
|
||||
|
||||
{/* File List */}
|
||||
{isSearchActive ? (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{searchLoading ? (
|
||||
<div className="flex flex-col items-center justify-center space-y-3 py-12 text-slate-500">
|
||||
<Folder className="h-12 w-12 opacity-20" />
|
||||
<p className="text-sm">搜索中...</p>
|
||||
</div>
|
||||
) : (searchResults?.length ?? 0) === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center space-y-3 py-12 text-slate-500">
|
||||
<Folder className="h-12 w-12 opacity-20" />
|
||||
<p className="text-sm">未找到匹配项</p>
|
||||
</div>
|
||||
) : viewMode === 'list' ? (
|
||||
<table className="w-full table-fixed border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-white/5 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
<th className="w-[40%] pb-3 pl-4 font-medium">名称</th>
|
||||
<th className="hidden w-[26%] pb-3 font-medium md:table-cell">位置</th>
|
||||
<th className="hidden w-[20%] pb-3 font-medium lg:table-cell">修改时间</th>
|
||||
<th className="w-[10%] pb-3 font-medium">大小</th>
|
||||
<th className="w-[4%] pb-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{searchResults?.map((file) => {
|
||||
const uiFile = toUiFile(file);
|
||||
const selected = selectedSearchFile?.id === file.id;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={file.id}
|
||||
onClick={() => setSelectedSearchFile(file)}
|
||||
onDoubleClick={() => {
|
||||
if (file.directory) {
|
||||
handleNavigateToPath(splitBackendPath(file.path));
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group cursor-pointer border-b border-white/5 transition-colors last:border-0',
|
||||
selected ? 'bg-[#336EFF]/10' : 'hover:bg-white/[0.02]',
|
||||
)}
|
||||
>
|
||||
<td className="max-w-0 py-3 pl-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<FileTypeIcon type={uiFile.type} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<span
|
||||
className={cn('block truncate text-sm font-medium', selected ? 'text-[#336EFF]' : 'text-slate-200')}
|
||||
title={uiFile.name}
|
||||
>
|
||||
{ellipsizeFileName(uiFile.name, 48)}
|
||||
</span>
|
||||
<span className="hidden truncate text-xs text-slate-500 md:block" title={file.path}>
|
||||
{file.path}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden py-3 text-sm text-slate-400 md:table-cell">{file.path}</td>
|
||||
<td className="hidden py-3 text-sm text-slate-400 lg:table-cell">{uiFile.modified}</td>
|
||||
<td className="py-3 font-mono text-sm text-slate-400">{uiFile.size}</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
<FileActionMenu
|
||||
file={uiFile}
|
||||
activeDropdown={activeDropdown}
|
||||
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onMove={(targetFile) => openTargetActionModal(targetFile, 'move')}
|
||||
onCopy={(targetFile) => openTargetActionModal(targetFile, 'copy')}
|
||||
onRename={openRenameModal}
|
||||
onDelete={openDeleteModal}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
allowMutatingActions={false}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{searchResults?.map((file) => {
|
||||
const uiFile = toUiFile(file);
|
||||
const selected = selectedSearchFile?.id === file.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
onClick={() => setSelectedSearchFile(file)}
|
||||
onDoubleClick={() => {
|
||||
if (file.directory) {
|
||||
handleNavigateToPath(splitBackendPath(file.path));
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group relative flex cursor-pointer flex-col items-center rounded-xl border p-4 transition-all',
|
||||
selected
|
||||
? 'border-[#336EFF]/30 bg-[#336EFF]/10'
|
||||
: 'border-white/5 bg-white/[0.02] hover:border-white/10 hover:bg-white/[0.04]',
|
||||
)}
|
||||
>
|
||||
<div className="absolute right-2 top-2">
|
||||
<FileActionMenu
|
||||
file={uiFile}
|
||||
activeDropdown={activeDropdown}
|
||||
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onMove={(targetFile) => openTargetActionModal(targetFile, 'move')}
|
||||
onCopy={(targetFile) => openTargetActionModal(targetFile, 'copy')}
|
||||
onRename={openRenameModal}
|
||||
onDelete={openDeleteModal}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
allowMutatingActions={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FileTypeIcon type={uiFile.type} size="lg" className="mb-3 transition-transform duration-200 group-hover:scale-[1.03]" />
|
||||
|
||||
<span className={cn('w-full truncate px-2 text-center text-sm font-medium', selected ? 'text-[#336EFF]' : 'text-slate-200')}>
|
||||
{ellipsizeFileName(uiFile.name, 24)}
|
||||
</span>
|
||||
<span className={cn('mt-1 inline-flex rounded-full px-2 py-1 text-[11px] font-medium', getFileTypeTheme(uiFile.type).badgeClassName)}>
|
||||
{uiFile.typeLabel}
|
||||
</span>
|
||||
<span className="mt-2 text-xs text-slate-500">
|
||||
{uiFile.type === 'folder' ? uiFile.modified : uiFile.size}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn('flex-1 overflow-y-auto p-4', isSearchActive ? 'hidden' : '')}>
|
||||
{currentFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center space-y-3 py-12 text-slate-500">
|
||||
<Folder className="w-12 h-12 opacity-20" />
|
||||
@@ -989,13 +1325,13 @@ export default function Files() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Right Sidebar (Details) */}
|
||||
{selectedFile && (
|
||||
{/* Right Sidebar (Details + Tasks) */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="w-full lg:w-72 shrink-0"
|
||||
className="w-full lg:w-72 shrink-0 space-y-4"
|
||||
>
|
||||
{selectedFile && (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-4 border-b border-white/10">
|
||||
<CardTitle className="text-base">详细信息</CardTitle>
|
||||
@@ -1031,6 +1367,17 @@ export default function Files() {
|
||||
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => openTargetActionModal(selectedFile, 'copy')}>
|
||||
<Copy className="w-4 h-4" /> 复制到
|
||||
</Button>
|
||||
{selectedFile.type !== 'folder' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="col-span-2 w-full gap-2 border-white/10 bg-white/5 hover:bg-white/10"
|
||||
onClick={() => void handleCreateMediaMetadataTask()}
|
||||
disabled={backgroundTaskActionId === selectedFile.id}
|
||||
>
|
||||
<RotateCcw className={cn('w-4 h-4', backgroundTaskActionId === selectedFile.id ? 'animate-spin' : '')} />
|
||||
{backgroundTaskActionId === selectedFile.id ? '创建中...' : '提取媒体信息'}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 border-red-500/20 bg-red-500/5 text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
@@ -1062,9 +1409,98 @@ export default function Files() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b border-white/10 pb-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">后台任务</CardTitle>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
|
||||
onClick={() => void loadBackgroundTasks()}
|
||||
aria-label="刷新后台任务"
|
||||
>
|
||||
<RotateCcw className={cn('h-4 w-4', backgroundTasksLoading ? 'animate-spin' : '')} />
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{backgroundTaskNotice ? (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border px-3 py-2 text-xs leading-relaxed',
|
||||
backgroundTaskNotice.kind === 'error'
|
||||
? 'border-red-500/20 bg-red-500/10 text-red-200'
|
||||
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200',
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{backgroundTaskNotice.message}
|
||||
</div>
|
||||
) : null}
|
||||
{backgroundTasksError ? (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
||||
{backgroundTasksError}
|
||||
</div>
|
||||
) : null}
|
||||
{backgroundTasksLoading ? (
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.02] px-3 py-4 text-sm text-slate-400">
|
||||
加载最近任务中...
|
||||
</div>
|
||||
) : backgroundTasks.length === 0 ? (
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.02] px-3 py-4 text-sm text-slate-400">
|
||||
暂无后台任务
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[32rem] space-y-3 overflow-y-auto pr-1">
|
||||
{backgroundTasks.map((task) => {
|
||||
const canCancel = task.status === 'QUEUED' || task.status === 'RUNNING';
|
||||
return (
|
||||
<div key={task.id} className="rounded-xl border border-white/10 bg-white/[0.03] p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white">{getBackgroundTaskTypeLabel(task.type)}</p>
|
||||
<p className={cn('text-xs', getBackgroundTaskStatusClassName(task.status))}>
|
||||
{getBackgroundTaskStatusLabel(task.status)}
|
||||
</p>
|
||||
</div>
|
||||
{canCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="shrink-0 border-white/10 bg-white/5 px-3 text-xs text-slate-200 hover:bg-white/10"
|
||||
onClick={() => void handleCancelBackgroundTask(task.id)}
|
||||
disabled={backgroundTaskActionId === task.id}
|
||||
>
|
||||
{backgroundTaskActionId === task.id ? '取消中...' : '取消'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="min-w-0">
|
||||
<p className="text-slate-500">创建时间</p>
|
||||
<p className="truncate text-slate-300">{formatTaskDateTime(task.createdAt)}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-slate-500">完成时间</p>
|
||||
<p className="truncate text-slate-300">{task.finishedAt ? formatTaskDateTime(task.finishedAt) : '未完成'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{task.errorMessage ? (
|
||||
<div className="mt-3 break-words rounded-lg border border-red-500/20 bg-red-500/10 px-2 py-1 text-xs leading-relaxed text-red-200">
|
||||
{task.errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{renameModalOpen && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
@@ -1221,6 +1657,51 @@ function DetailItem({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatTaskDateTime(value: string) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
function getBackgroundTaskTypeLabel(type: BackgroundTask['type']) {
|
||||
switch (type) {
|
||||
case 'ARCHIVE':
|
||||
return '压缩任务';
|
||||
case 'EXTRACT':
|
||||
return '解压任务';
|
||||
case 'MEDIA_META':
|
||||
return '媒体信息提取任务';
|
||||
}
|
||||
}
|
||||
|
||||
function getBackgroundTaskStatusLabel(status: BackgroundTask['status']) {
|
||||
switch (status) {
|
||||
case 'QUEUED':
|
||||
return '排队中';
|
||||
case 'RUNNING':
|
||||
return '执行中';
|
||||
case 'COMPLETED':
|
||||
return '已完成';
|
||||
case 'FAILED':
|
||||
return '已失败';
|
||||
case 'CANCELLED':
|
||||
return '已取消';
|
||||
}
|
||||
}
|
||||
|
||||
function getBackgroundTaskStatusClassName(status: BackgroundTask['status']) {
|
||||
switch (status) {
|
||||
case 'QUEUED':
|
||||
return 'text-amber-300';
|
||||
case 'RUNNING':
|
||||
return 'text-sky-300';
|
||||
case 'COMPLETED':
|
||||
return 'text-emerald-300';
|
||||
case 'FAILED':
|
||||
return 'text-red-300';
|
||||
case 'CANCELLED':
|
||||
return 'text-slate-400';
|
||||
}
|
||||
}
|
||||
|
||||
function FileActionMenu({
|
||||
file,
|
||||
activeDropdown,
|
||||
@@ -1232,6 +1713,7 @@ function FileActionMenu({
|
||||
onRename,
|
||||
onDelete,
|
||||
onClose,
|
||||
allowMutatingActions = true,
|
||||
}: {
|
||||
file: UiFile;
|
||||
activeDropdown: number | null;
|
||||
@@ -1243,6 +1725,7 @@ function FileActionMenu({
|
||||
onRename: (file: UiFile) => void;
|
||||
onDelete: (file: UiFile) => void;
|
||||
onClose: () => void;
|
||||
allowMutatingActions?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative inline-block text-left">
|
||||
@@ -1295,6 +1778,8 @@ function FileActionMenu({
|
||||
<Share2 className="w-4 h-4" /> 分享链接
|
||||
</button>
|
||||
) : null}
|
||||
{allowMutatingActions ? (
|
||||
<>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
@@ -1335,6 +1820,8 @@ function FileActionMenu({
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> 删除
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -168,3 +168,9 @@
|
||||
- 2026-04-08 合并 `files/storage` 补提交后修复:`S3FileContentStorage` 改为复用 `DogeCloudS3SessionProvider` / `DogeCloudTmpTokenClient` 获取并缓存运行期 `S3Client` 与 `S3Presigner`,保留生产构造器 `S3FileContentStorage(FileStorageProperties)`,同时提供测试用注入构造器;S3 直传、签名下载、上传校验、读旧对象键 fallback、rename/move/copy、离线快传对象读写继续通过 `FileContentStorage` 统一抽象。
|
||||
- 2026-04-08 阶段 4 第二小步:新写入和回填生成的 `FileEntity.VERSION` 会记录默认 `StoragePolicy.id` 到 `storagePolicyId`,让物理实体可以追踪归属存储策略;复用已有 `FileEntity` 时只增加引用计数,不覆盖历史实体策略字段。旧 `/api/files/**` 读取路径仍继续依赖 `StoredFile.blob`。
|
||||
- 2026-04-08 阶段 4 第三小步:新增管理员只读存储策略查看能力,后端暴露 `GET /api/admin/storage-policies`,前端管理台新增“存储策略”资源列表和能力矩阵展示;该接口只返回白名单 DTO 与结构化 `StoragePolicyCapabilities`,不暴露凭证、不支持新增/编辑/启停/删除策略,也不启用真实 multipart。
|
||||
- 2026-04-08 阶段 5 第一小步:新增用户侧 v2 文件搜索最小闭环,后端暴露受保护的 `GET /api/v2/files/search`,复用 `StoredFile` 查询当前用户未删除文件,支持 `name`、`type=file|directory|folder|all`、`sizeGte/sizeLte`、`createdGte/createdLte`、`updatedGte/updatedLte` 与分页;同时新增 `FileMetadata` / `FileMetadataRepository` 扩展表骨架,暂不迁移回收站字段、暂不接入标签/metadata 过滤、暂不改前端上传队列和旧 `/api/files/**` 行为。
|
||||
- 2026-04-08 阶段 5 第二小步:前端桌面端接入最小搜索下游,新增 `front/src/lib/file-search.ts` 和 `front/src/lib/file-search.test.ts`,桌面 `front/src/pages/Files.tsx` 可通过 v2 search 单独搜索并展示结果,不写入 `getFilesListCacheKey(...)`,也不影响原有目录缓存和上传主链路;移动端暂未接入搜索,后续可按同一 helper 补入。
|
||||
- 2026-04-08 阶段 5 第三小步:新增分享二期后端最小骨架。`FileShareLink` 增加 `passwordHash`、`expiresAt`、`maxDownloads`、`downloadCount`、`viewCount`、`allowImport`、`allowDownload`、`shareName`;新增 `com.yoyuzh.api.v2.shares` 与 `ShareV2Service`,提供 v2 创建、公开读取、密码校验、导入、我的分享列表和删除。公开访问仅限 `GET /api/v2/shares/{token}` 与 `POST /api/v2/shares/{token}/verify-password`;创建、导入、我的分享、删除仍需登录。v2 导入会先校验过期时间、密码、`allowImport` 和 `maxDownloads`,再复用旧导入持久化链路;旧 `/api/files/share-links/**` 继续兼容。当前 `allowDownload` 只落库和返回,尚未接入独立 v2 下载路由。
|
||||
- 2026-04-08 阶段 5 第四小步:新增文件事件流前后端最小闭环。后端落地 `FileEvent` / `FileEventType` / `FileEventRepository` / `FileEventService`,并提供受保护的 `GET /api/v2/files/events?path=/` SSE 入口;当前可按用户广播、按路径前缀过滤、按 `X-Yoyuzh-Client-Id` 抑制自身事件,首次连接会收到 `READY` 事件。前端新增 fetch-stream 版 `front/src/lib/file-events.ts`,不直接使用无法带鉴权头的原生 `EventSource`;桌面 `Files` 与移动 `MobileFiles` 已订阅当前目录事件,收到文件变更后失效当前目录缓存并刷新列表,搜索结果状态不被清空。
|
||||
- 2026-04-08 阶段 6 第一步:新增后台任务框架与 worker 最小骨架。后端新增 `BackgroundTask` / `BackgroundTaskType` / `BackgroundTaskStatus` / `BackgroundTaskRepository` / `BackgroundTaskService`,并暴露受保护的 `GET /api/v2/tasks`、`GET /api/v2/tasks/{id}`、`DELETE /api/v2/tasks/{id}` 以及 `POST /api/v2/tasks/archive`、`POST /api/v2/tasks/extract`、`POST /api/v2/tasks/media-metadata` 占位创建接口;任务创建入口已校验 `fileId` 属于当前用户、未删除、请求 `path` 匹配服务端派生逻辑路径,并按任务类型限制目录/压缩包/媒体文件,任务 state 使用服务端文件信息;当前 worker 会定时领取 `QUEUED` 任务、切换为 `RUNNING`,其中 `MEDIA_META` 已由最小真实 handler 写入基础媒体元数据与图片宽高,其余任务类型仍通过 no-op handler 标记 `COMPLETED`,异常时标记 `FAILED` 并记录错误原因。压缩/解压/缩略图/视频时长/前端任务面板仍未接入。
|
||||
- 2026-04-09 桌面端 `Files` 已补最近 10 条后台任务面板,支持查看状态、取消 `QUEUED/RUNNING` 任务,并可为当前选中文件创建媒体信息提取任务;移动端和 archive/extract 的前端入口暂未接入。
|
||||
|
||||
@@ -4,6 +4,16 @@ $root = Split-Path -Parent $PSScriptRoot
|
||||
$mavenExe = 'mvn.cmd'
|
||||
$out = Join-Path $root 'backend-dev.out.log'
|
||||
$err = Join-Path $root 'backend-dev.err.log'
|
||||
$devJwtSecret = $env:APP_JWT_SECRET
|
||||
$devDatasourceUrl = $env:SPRING_DATASOURCE_URL
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($devJwtSecret)) {
|
||||
$devJwtSecret = 'local-dev-jwt-secret-2026-04-09-yoyuzh-portal-very-long'
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($devDatasourceUrl)) {
|
||||
$devDatasourceUrl = 'jdbc:h2:mem:yoyuzh_portal_dev;MODE=MySQL;DB_CLOSE_DELAY=-1'
|
||||
}
|
||||
|
||||
if (Test-Path $out) {
|
||||
Remove-Item $out -Force
|
||||
@@ -13,8 +23,8 @@ if (Test-Path $err) {
|
||||
}
|
||||
|
||||
$proc = Start-Process `
|
||||
-FilePath $mavenExe `
|
||||
-ArgumentList 'spring-boot:run', '-Dspring-boot.run.profiles=dev' `
|
||||
-FilePath 'cmd.exe' `
|
||||
-ArgumentList '/c', "set `"APP_JWT_SECRET=$devJwtSecret`" && set `"SPRING_DATASOURCE_URL=$devDatasourceUrl`" && $mavenExe spring-boot:run -Dspring-boot.run.profiles=dev" `
|
||||
-WorkingDirectory (Join-Path $root 'backend') `
|
||||
-PassThru `
|
||||
-RedirectStandardOutput $out `
|
||||
|
||||
@@ -12,9 +12,9 @@ if (Test-Path $frontendLogErr) {
|
||||
}
|
||||
|
||||
$proc = Start-Process `
|
||||
-FilePath 'cmd.exe' `
|
||||
-ArgumentList '/c', 'npm run dev -- --host 127.0.0.1 --port 4173' `
|
||||
-WorkingDirectory (Join-Path $root 'vue') `
|
||||
-FilePath 'npm.cmd' `
|
||||
-ArgumentList 'run', 'dev' `
|
||||
-WorkingDirectory (Join-Path $root 'front') `
|
||||
-PassThru `
|
||||
-RedirectStandardOutput $frontendLogOut `
|
||||
-RedirectStandardError $frontendLogErr
|
||||
@@ -22,10 +22,10 @@ $proc = Start-Process `
|
||||
Start-Sleep -Seconds 6
|
||||
|
||||
try {
|
||||
$resp = Invoke-WebRequest -Uri 'http://127.0.0.1:4173' -UseBasicParsing -TimeoutSec 5
|
||||
$resp = Invoke-WebRequest -Uri 'http://127.0.0.1:3000' -UseBasicParsing -TimeoutSec 5
|
||||
Write-Output "PID=$($proc.Id)"
|
||||
Write-Output "STATUS=$($resp.StatusCode)"
|
||||
Write-Output 'URL=http://127.0.0.1:4173'
|
||||
Write-Output 'URL=http://127.0.0.1:3000'
|
||||
}
|
||||
catch {
|
||||
Write-Output "PID=$($proc.Id)"
|
||||
|
||||
Reference in New Issue
Block a user