feat(files): add v2 task and metadata workflows

This commit is contained in:
yoyuzh
2026-04-09 00:42:41 +08:00
parent c5362ebe31
commit 977eb60b17
60 changed files with 5218 additions and 72 deletions

View File

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

View File

@@ -0,0 +1,71 @@
package com.yoyuzh.api.v2.files;
import com.yoyuzh.api.v2.ApiV2ErrorCode;
import com.yoyuzh.api.v2.ApiV2Exception;
import com.yoyuzh.api.v2.ApiV2Response;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.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");
};
}
}

View File

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

View File

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

View File

@@ -0,0 +1,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);
}
}

View File

@@ -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
) {
}

View File

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

View File

@@ -0,0 +1,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
) {
}

View File

@@ -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()
);
}
}

View File

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

View File

@@ -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/*")

View 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;
}
}

View File

@@ -0,0 +1,8 @@
package com.yoyuzh.files;
public interface BackgroundTaskHandler {
boolean supports(BackgroundTaskType type);
BackgroundTaskHandlerResult handle(BackgroundTask task);
}

View File

@@ -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());
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.files;
public enum BackgroundTaskStatus {
QUEUED,
RUNNING,
FAILED,
CANCELLED,
COMPLETED
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.files;
public enum BackgroundTaskType {
ARCHIVE,
EXTRACT,
THUMBNAIL,
MEDIA_META,
REMOTE_DOWNLOAD,
HLS_TRANSCODE,
CLEANUP
}

View File

@@ -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()));
}
}

View 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;
}
}

View File

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

View 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());
}
}
}

View File

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

View 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;
}
}

View File

@@ -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);
}

View 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
) {
}

View File

@@ -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();
}
}

View File

@@ -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)) {

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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()
));
}
}

View 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()
);
}
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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\"");
}
}

View File

@@ -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;
}
}

View 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();
}
}