package com.yoyuzh.files; import com.yoyuzh.admin.AdminMetricsService; import com.yoyuzh.auth.User; import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.ErrorCode; import com.yoyuzh.common.PageResponse; import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.files.storage.FileContentStorage; import com.yoyuzh.files.storage.PreparedUpload; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Clock; import java.time.Instant; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Base64; import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.Locale; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @Service public class FileService { private static final List DEFAULT_DIRECTORIES = List.of("下载", "文档", "图片"); private static final String RECYCLE_BIN_PATH_PREFIX = "/.recycle"; private static final long RECYCLE_BIN_RETENTION_DAYS = 10L; private final StoredFileRepository storedFileRepository; private final FileBlobRepository fileBlobRepository; private final FileEntityRepository fileEntityRepository; private final StoredFileEntityRepository storedFileEntityRepository; private final FileContentStorage fileContentStorage; private final FileShareLinkRepository fileShareLinkRepository; private final AdminMetricsService adminMetricsService; private final StoragePolicyService storagePolicyService; private final long maxFileSize; private final String packageDownloadBaseUrl; private final String packageDownloadSecret; private final long packageDownloadTtlSeconds; private final Clock clock; @Autowired(required = false) private FileEventService fileEventService; @Autowired public FileService(StoredFileRepository storedFileRepository, FileBlobRepository fileBlobRepository, FileEntityRepository fileEntityRepository, StoredFileEntityRepository storedFileEntityRepository, FileContentStorage fileContentStorage, FileShareLinkRepository fileShareLinkRepository, AdminMetricsService adminMetricsService, StoragePolicyService storagePolicyService, FileStorageProperties properties) { this(storedFileRepository, fileBlobRepository, fileEntityRepository, storedFileEntityRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, storagePolicyService, properties, Clock.systemUTC()); } FileService(StoredFileRepository storedFileRepository, FileBlobRepository fileBlobRepository, FileEntityRepository fileEntityRepository, StoredFileEntityRepository storedFileEntityRepository, FileContentStorage fileContentStorage, FileShareLinkRepository fileShareLinkRepository, AdminMetricsService adminMetricsService, StoragePolicyService storagePolicyService, FileStorageProperties properties, Clock clock) { this.storedFileRepository = storedFileRepository; this.fileBlobRepository = fileBlobRepository; this.fileEntityRepository = fileEntityRepository; this.storedFileEntityRepository = storedFileEntityRepository; this.fileContentStorage = fileContentStorage; this.fileShareLinkRepository = fileShareLinkRepository; this.adminMetricsService = adminMetricsService; this.storagePolicyService = storagePolicyService; this.maxFileSize = properties.getMaxFileSize(); this.packageDownloadBaseUrl = StringUtils.hasText(properties.getS3().getPackageDownloadBaseUrl()) ? properties.getS3().getPackageDownloadBaseUrl().trim() : null; this.packageDownloadSecret = StringUtils.hasText(properties.getS3().getPackageDownloadSecret()) ? properties.getS3().getPackageDownloadSecret().trim() : null; this.packageDownloadTtlSeconds = Math.max(1, properties.getS3().getPackageDownloadTtlSeconds()); this.clock = clock; } FileService(StoredFileRepository storedFileRepository, FileBlobRepository fileBlobRepository, FileContentStorage fileContentStorage, FileShareLinkRepository fileShareLinkRepository, AdminMetricsService adminMetricsService, FileStorageProperties properties) { this(storedFileRepository, fileBlobRepository, null, null, fileContentStorage, fileShareLinkRepository, adminMetricsService, null, properties, Clock.systemUTC()); } FileService(StoredFileRepository storedFileRepository, FileBlobRepository fileBlobRepository, FileContentStorage fileContentStorage, FileShareLinkRepository fileShareLinkRepository, AdminMetricsService adminMetricsService, FileStorageProperties properties, Clock clock) { this(storedFileRepository, fileBlobRepository, null, null, fileContentStorage, fileShareLinkRepository, adminMetricsService, null, properties, clock); } @Transactional public FileMetadataResponse upload(User user, String path, MultipartFile multipartFile) { String normalizedPath = normalizeDirectoryPath(path); String filename = normalizeUploadFilename(multipartFile.getOriginalFilename()); validateUpload(user, normalizedPath, filename, multipartFile.getSize()); ensureDirectoryHierarchy(user, normalizedPath); String objectKey = createBlobObjectKey(); return executeAfterBlobStored(objectKey, () -> { fileContentStorage.uploadBlob(objectKey, multipartFile); FileBlob blob = createAndSaveBlob(objectKey, multipartFile.getContentType(), multipartFile.getSize()); return saveFileMetadata(user, normalizedPath, filename, multipartFile.getContentType(), multipartFile.getSize(), blob); }); } public InitiateUploadResponse initiateUpload(User user, InitiateUploadRequest request) { String normalizedPath = normalizeDirectoryPath(request.path()); String filename = normalizeLeafName(request.filename()); validateUpload(user, normalizedPath, filename, request.size()); String objectKey = createBlobObjectKey(); PreparedUpload preparedUpload = fileContentStorage.prepareBlobUpload( normalizedPath, filename, objectKey, request.contentType(), request.size() ); return new InitiateUploadResponse( preparedUpload.direct(), preparedUpload.uploadUrl(), preparedUpload.method(), preparedUpload.headers(), preparedUpload.storageName() ); } @Transactional public FileMetadataResponse completeUpload(User user, CompleteUploadRequest request) { String normalizedPath = normalizeDirectoryPath(request.path()); String filename = normalizeLeafName(request.filename()); String objectKey = normalizeBlobObjectKey(request.storageName()); validateUpload(user, normalizedPath, filename, request.size()); ensureDirectoryHierarchy(user, normalizedPath); return executeAfterBlobStored(objectKey, () -> { fileContentStorage.completeBlobUpload(objectKey, request.contentType(), request.size()); FileBlob blob = createAndSaveBlob(objectKey, request.contentType(), request.size()); return saveFileMetadata(user, normalizedPath, filename, request.contentType(), request.size(), blob); }); } @Transactional public FileMetadataResponse mkdir(User user, String path) { String normalizedPath = normalizeDirectoryPath(path); if ("/".equals(normalizedPath)) { throw new BusinessException(ErrorCode.UNKNOWN, "根目录无需创建"); } String parentPath = extractParentPath(normalizedPath); String directoryName = extractLeafName(normalizedPath); if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), parentPath, directoryName)) { throw new BusinessException(ErrorCode.UNKNOWN, "目录已存在"); } fileContentStorage.createDirectory(user.getId(), normalizedPath); StoredFile storedFile = new StoredFile(); storedFile.setUser(user); storedFile.setFilename(directoryName); storedFile.setPath(parentPath); storedFile.setContentType("directory"); storedFile.setSize(0L); storedFile.setDirectory(true); return toResponse(storedFileRepository.save(storedFile)); } public PageResponse list(User user, String path, int page, int size) { String normalizedPath = normalizeDirectoryPath(path); Page result = storedFileRepository.findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc( user.getId(), normalizedPath, PageRequest.of(page, size)); List items = result.getContent().stream().map(this::toResponse).toList(); return new PageResponse<>(items, result.getTotalElements(), page, size); } public List recent(User user) { return storedFileRepository.findTop12ByUserIdAndDirectoryFalseAndDeletedAtIsNullOrderByCreatedAtDesc(user.getId()) .stream() .map(this::toResponse) .toList(); } public PageResponse listRecycleBin(User user, int page, int size) { Page result = storedFileRepository.findRecycleBinRootsByUserId(user.getId(), PageRequest.of(page, size)); List items = result.getContent().stream().map(this::toRecycleBinResponse).toList(); return new PageResponse<>(items, result.getTotalElements(), page, size); } @Transactional public void ensureDefaultDirectories(User user) { for (String directoryName : DEFAULT_DIRECTORIES) { if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), "/", directoryName)) { continue; } String logicalPath = "/" + directoryName; fileContentStorage.ensureDirectory(user.getId(), logicalPath); StoredFile storedFile = new StoredFile(); storedFile.setUser(user); storedFile.setFilename(directoryName); storedFile.setPath("/"); storedFile.setContentType("directory"); storedFile.setSize(0L); storedFile.setDirectory(true); storedFileRepository.save(storedFile); } } @Transactional public void delete(User user, Long fileId) { StoredFile storedFile = getOwnedActiveFile(user, fileId, "删除"); String fromPath = buildLogicalPath(storedFile); List filesToRecycle = new ArrayList<>(); filesToRecycle.add(storedFile); if (storedFile.isDirectory()) { String logicalPath = buildLogicalPath(storedFile); List descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), logicalPath); 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 recycleGroupItems = loadRecycleGroupItems(recycleRoot); long additionalBytes = recycleGroupItems.stream() .filter(item -> !item.isDirectory()) .mapToLong(StoredFile::getSize) .sum(); ensureWithinStorageQuota(user, additionalBytes); validateRecycleRestoreTargets(user.getId(), recycleGroupItems); ensureRecycleRestoreParentHierarchy(user, recycleRoot); for (StoredFile item : recycleGroupItems) { item.setPath(requireRecycleOriginalPath(item)); item.setDeletedAt(null); item.setRecycleOriginalPath(null); item.setRecycleGroupId(null); item.setRecycleRoot(false); } storedFileRepository.saveAll(recycleGroupItems); recordFileEvent(user, FileEventType.RESTORED, recycleRoot, fromPath, toPath); return toResponse(recycleRoot); } @Scheduled(fixedDelay = 60 * 60 * 1000L) @Transactional public void pruneExpiredRecycleBinItems() { List expiredItems = storedFileRepository.findByDeletedAtBefore(LocalDateTime.now().minusDays(RECYCLE_BIN_RETENTION_DAYS)); if (expiredItems.isEmpty()) { return; } List blobsToDelete = collectBlobsToDelete( expiredItems.stream().filter(item -> !item.isDirectory()).toList() ); storedFileRepository.deleteAll(expiredItems); deleteBlobs(blobsToDelete); } @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); } if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), storedFile.getPath(), sanitizedFilename)) { throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在"); } if (storedFile.isDirectory()) { String oldLogicalPath = buildLogicalPath(storedFile); String newLogicalPath = "/".equals(storedFile.getPath()) ? "/" + sanitizedFilename : storedFile.getPath() + "/" + sanitizedFilename; List descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath); for (StoredFile descendant : descendants) { if (descendant.getPath().equals(oldLogicalPath)) { descendant.setPath(newLogicalPath); continue; } descendant.setPath(newLogicalPath + descendant.getPath().substring(oldLogicalPath.length())); } if (!descendants.isEmpty()) { storedFileRepository.saveAll(descendants); } } storedFile.setFilename(sanitizedFilename); 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); } ensureExistingDirectoryPath(user.getId(), normalizedTargetPath); if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedTargetPath, storedFile.getFilename())) { throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件"); } if (storedFile.isDirectory()) { String oldLogicalPath = buildLogicalPath(storedFile); String newLogicalPath = "/".equals(normalizedTargetPath) ? "/" + storedFile.getFilename() : normalizedTargetPath + "/" + storedFile.getFilename(); if (newLogicalPath.equals(oldLogicalPath) || newLogicalPath.startsWith(oldLogicalPath + "/")) { throw new BusinessException(ErrorCode.UNKNOWN, "不能移动到当前目录或其子目录"); } List descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath); for (StoredFile descendant : descendants) { if (descendant.getPath().equals(oldLogicalPath)) { descendant.setPath(newLogicalPath); continue; } descendant.setPath(newLogicalPath + descendant.getPath().substring(oldLogicalPath.length())); } if (!descendants.isEmpty()) { storedFileRepository.saveAll(descendants); } } storedFile.setPath(normalizedTargetPath); FileMetadataResponse response = toResponse(storedFileRepository.save(storedFile)); recordFileEvent(user, FileEventType.MOVED, storedFile, fromPath, buildLogicalPath(storedFile)); return response; } @Transactional public FileMetadataResponse copy(User user, Long fileId, String nextPath) { StoredFile storedFile = getOwnedActiveFile(user, fileId, "复制"); String normalizedTargetPath = normalizeDirectoryPath(nextPath); ensureExistingDirectoryPath(user.getId(), normalizedTargetPath); if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedTargetPath, storedFile.getFilename())) { throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件"); } if (!storedFile.isDirectory()) { ensureWithinStorageQuota(user, storedFile.getSize()); return toResponse(saveCopiedStoredFile(copyStoredFile(storedFile, user, normalizedTargetPath), user)); } String oldLogicalPath = buildLogicalPath(storedFile); String newLogicalPath = buildTargetLogicalPath(normalizedTargetPath, storedFile.getFilename()); if (newLogicalPath.equals(oldLogicalPath) || newLogicalPath.startsWith(oldLogicalPath + "/")) { throw new BusinessException(ErrorCode.UNKNOWN, "不能复制到当前目录或其子目录"); } List descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath); long additionalBytes = descendants.stream() .filter(descendant -> !descendant.isDirectory()) .mapToLong(StoredFile::getSize) .sum(); ensureWithinStorageQuota(user, additionalBytes); List copiedEntries = new ArrayList<>(); StoredFile copiedRoot = copyStoredFile(storedFile, user, normalizedTargetPath); copiedEntries.add(copiedRoot); descendants.stream() .sorted(Comparator .comparingInt((StoredFile descendant) -> descendant.getPath().length()) .thenComparing(descendant -> descendant.isDirectory() ? 0 : 1) .thenComparing(StoredFile::getFilename)) .forEach(descendant -> { String copiedPath = remapCopiedPath(descendant.getPath(), oldLogicalPath, newLogicalPath); if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), copiedPath, descendant.getFilename())) { throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件"); } copiedEntries.add(copyStoredFile(descendant, user, copiedPath)); }); StoredFile savedRoot = null; for (StoredFile copiedEntry : copiedEntries) { StoredFile savedEntry = saveCopiedStoredFile(copiedEntry, user); if (savedRoot == null) { savedRoot = savedEntry; } } return toResponse(savedRoot == null ? copiedRoot : savedRoot); } public ResponseEntity download(User user, Long fileId) { StoredFile storedFile = getOwnedActiveFile(user, fileId, "下载"); if (storedFile.isDirectory()) { return downloadDirectory(user, storedFile); } if (shouldUsePublicPackageDownload(storedFile)) { return ResponseEntity.status(302) .location(URI.create(buildPublicPackageDownloadUrl(storedFile))) .build(); } if (fileContentStorage.supportsDirectDownload()) { return ResponseEntity.status(302) .location(URI.create(fileContentStorage.createBlobDownloadUrl( getRequiredBlob(storedFile).getObjectKey(), storedFile.getFilename()))) .build(); } return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + URLEncoder.encode(storedFile.getFilename(), StandardCharsets.UTF_8)) .contentType(MediaType.parseMediaType( storedFile.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : storedFile.getContentType())) .body(fileContentStorage.readBlob(getRequiredBlob(storedFile).getObjectKey())); } public DownloadUrlResponse getDownloadUrl(User user, Long fileId) { StoredFile storedFile = getOwnedActiveFile(user, fileId, "下载"); if (storedFile.isDirectory()) { throw new BusinessException(ErrorCode.UNKNOWN, "目录不支持下载"); } adminMetricsService.recordDownloadTraffic(storedFile.getSize()); if (shouldUsePublicPackageDownload(storedFile)) { return new DownloadUrlResponse(buildPublicPackageDownloadUrl(storedFile)); } if (fileContentStorage.supportsDirectDownload()) { return new DownloadUrlResponse(fileContentStorage.createBlobDownloadUrl( getRequiredBlob(storedFile).getObjectKey(), storedFile.getFilename() )); } return new DownloadUrlResponse("/api/files/download/" + storedFile.getId()); } @Transactional public CreateFileShareLinkResponse createShareLink(User user, Long fileId) { StoredFile storedFile = getOwnedActiveFile(user, fileId, "分享"); if (storedFile.isDirectory()) { throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持分享链接"); } FileShareLink shareLink = new FileShareLink(); shareLink.setOwner(user); shareLink.setFile(storedFile); shareLink.setToken(UUID.randomUUID().toString().replace("-", "")); FileShareLink saved = fileShareLinkRepository.save(shareLink); return new CreateFileShareLinkResponse( saved.getToken(), storedFile.getFilename(), storedFile.getSize(), storedFile.getContentType(), saved.getCreatedAt() ); } public FileShareDetailsResponse getShareDetails(String token) { FileShareLink shareLink = getShareLink(token); StoredFile storedFile = shareLink.getFile(); return new FileShareDetailsResponse( shareLink.getToken(), shareLink.getOwner().getUsername(), storedFile.getFilename(), storedFile.getSize(), storedFile.getContentType(), storedFile.isDirectory(), shareLink.getCreatedAt() ); } @Transactional public FileMetadataResponse importSharedFile(User recipient, String token, String path) { FileShareLink shareLink = getShareLink(token); StoredFile sourceFile = shareLink.getFile(); if (sourceFile.isDirectory()) { throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持导入"); } return importReferencedBlob( recipient, path, sourceFile.getFilename(), sourceFile.getContentType(), sourceFile.getSize(), getRequiredBlob(sourceFile) ); } @Transactional public FileMetadataResponse importExternalFile(User recipient, String path, String filename, String contentType, long size, byte[] content) { String normalizedPath = normalizeDirectoryPath(path); String normalizedFilename = normalizeLeafName(filename); validateUpload(recipient, normalizedPath, normalizedFilename, size); ensureDirectoryHierarchy(recipient, normalizedPath); String objectKey = createBlobObjectKey(); return executeAfterBlobStored(objectKey, () -> { fileContentStorage.storeBlob(objectKey, contentType, content); FileBlob blob = createAndSaveBlob(objectKey, contentType, size); return saveFileMetadata( recipient, normalizedPath, normalizedFilename, contentType, size, blob ); }); } private ResponseEntity downloadDirectory(User user, StoredFile directory) { String logicalPath = buildLogicalPath(directory); String archiveName = directory.getFilename() + ".zip"; List descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), logicalPath) .stream() .sorted(Comparator.comparing(StoredFile::getPath).thenComparing(StoredFile::getFilename)) .toList(); byte[] archiveBytes; try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8)) { Set createdEntries = new LinkedHashSet<>(); writeDirectoryEntry(zipOutputStream, createdEntries, directory.getFilename() + "/"); for (StoredFile descendant : descendants) { String entryName = buildZipEntryName(directory.getFilename(), logicalPath, descendant); if (descendant.isDirectory()) { writeDirectoryEntry(zipOutputStream, createdEntries, entryName + "/"); continue; } ensureParentDirectoryEntries(zipOutputStream, createdEntries, entryName); writeFileEntry(zipOutputStream, createdEntries, entryName, fileContentStorage.readBlob(getRequiredBlob(descendant).getObjectKey())); } zipOutputStream.finish(); archiveBytes = outputStream.toByteArray(); } catch (IOException ex) { throw new BusinessException(ErrorCode.UNKNOWN, "目录压缩失败"); } return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + URLEncoder.encode(archiveName, StandardCharsets.UTF_8)) .contentType(MediaType.parseMediaType("application/zip")) .body(archiveBytes); } private boolean shouldUsePublicPackageDownload(StoredFile storedFile) { return fileContentStorage.supportsDirectDownload() && StringUtils.hasText(packageDownloadBaseUrl) && StringUtils.hasText(packageDownloadSecret) && isAppPackage(storedFile); } private boolean isAppPackage(StoredFile storedFile) { String filename = storedFile.getFilename() == null ? "" : storedFile.getFilename().toLowerCase(Locale.ROOT); String contentType = storedFile.getContentType() == null ? "" : storedFile.getContentType().toLowerCase(Locale.ROOT); return filename.endsWith(".apk") || filename.endsWith(".ipa") || "application/vnd.android.package-archive".equals(contentType) || "application/octet-stream".equals(contentType) && (filename.endsWith(".apk") || filename.endsWith(".ipa")); } private String buildPublicPackageDownloadUrl(StoredFile storedFile) { FileBlob blob = getRequiredBlob(storedFile); String base = packageDownloadBaseUrl.endsWith("/") ? packageDownloadBaseUrl.substring(0, packageDownloadBaseUrl.length() - 1) : packageDownloadBaseUrl; String path = "/" + trimLeadingSlash(blob.getObjectKey()); if (base.endsWith("/_dl")) { path = "/_dl" + path; } long expires = clock.instant().getEpochSecond() + packageDownloadTtlSeconds; String signature = buildSecureLinkSignature(path, expires); return base + "/" + trimLeadingSlash(blob.getObjectKey()) + "?md5=" + encodeQueryParam(signature) + "&expires=" + expires + "&response-content-disposition=" + encodeQueryParam(buildAsciiContentDisposition(storedFile.getFilename())); } private String buildAsciiContentDisposition(String filename) { String sanitized = sanitizeDownloadFilename(filename); StringBuilder disposition = new StringBuilder("attachment; filename=\"") .append(escapeContentDispositionFilename(buildAsciiDownloadFilename(sanitized))) .append("\""); if (StringUtils.hasText(sanitized)) { disposition.append("; filename*=UTF-8''") .append(sanitized); } return disposition.toString(); } private String buildAsciiDownloadFilename(String filename) { String normalized = sanitizeDownloadFilename(filename); if (!StringUtils.hasText(normalized)) { return "download"; } String sanitized = normalized.replaceAll("[\\r\\n]", "_"); StringBuilder ascii = new StringBuilder(sanitized.length()); for (int i = 0; i < sanitized.length(); i++) { char current = sanitized.charAt(i); if (current >= 32 && current <= 126 && current != '"' && current != '\\') { ascii.append(current); } else { ascii.append('_'); } } String fallback = ascii.toString().trim(); String extension = extractAsciiExtension(normalized); String baseName = extension.isEmpty() ? fallback : fallback.substring(0, Math.max(0, fallback.length() - extension.length())); if (baseName.replace("_", "").isBlank()) { return extension.isEmpty() ? "download" : "download" + extension; } return fallback; } private String sanitizeDownloadFilename(String filename) { return StringUtils.hasText(filename) ? filename.trim().replaceAll("[\\r\\n]", "_") : ""; } private String extractAsciiExtension(String filename) { int extensionIndex = filename.lastIndexOf('.'); if (extensionIndex > 0 && extensionIndex < filename.length() - 1) { String extension = filename.substring(extensionIndex).replaceAll("[^A-Za-z0-9.]", ""); return StringUtils.hasText(extension) ? extension : ""; } return ""; } private String escapeContentDispositionFilename(String filename) { return filename.replace("\\", "\\\\").replace("\"", "\\\""); } private String trimLeadingSlash(String value) { return value.startsWith("/") ? value.substring(1) : value; } private String buildSecureLinkSignature(String path, long expires) { try { MessageDigest digest = MessageDigest.getInstance("MD5"); byte[] hash = digest.digest((expires + path + " " + packageDownloadSecret).getBytes(StandardCharsets.UTF_8)); return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); } catch (Exception ex) { throw new IllegalStateException("生成下载签名失败", ex); } } private String encodeQueryParam(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); } private FileMetadataResponse saveFileMetadata(User user, String normalizedPath, String filename, String contentType, long size, FileBlob blob) { StoredFile storedFile = new StoredFile(); storedFile.setUser(user); storedFile.setFilename(filename); storedFile.setPath(normalizedPath); storedFile.setContentType(contentType); storedFile.setSize(size); storedFile.setDirectory(false); storedFile.setBlob(blob); FileEntity primaryEntity = createOrReferencePrimaryEntity(user, blob); storedFile.setPrimaryEntity(primaryEntity); StoredFile savedFile = storedFileRepository.save(storedFile); savePrimaryEntityRelation(savedFile, primaryEntity); recordFileEvent(user, FileEventType.CREATED, savedFile, null, buildLogicalPath(savedFile)); return toResponse(savedFile); } private FileEntity createOrReferencePrimaryEntity(User user, FileBlob blob) { if (fileEntityRepository == null) { return createTransientPrimaryEntity(user, blob); } Optional existingEntity = fileEntityRepository.findByObjectKeyAndEntityType( blob.getObjectKey(), FileEntityType.VERSION ); if (existingEntity.isPresent()) { FileEntity entity = existingEntity.get(); entity.setReferenceCount(entity.getReferenceCount() + 1); return fileEntityRepository.save(entity); } return fileEntityRepository.save(createTransientPrimaryEntity(user, blob)); } private FileEntity createTransientPrimaryEntity(User user, FileBlob blob) { FileEntity entity = new FileEntity(); entity.setObjectKey(blob.getObjectKey()); entity.setContentType(blob.getContentType()); entity.setSize(blob.getSize()); entity.setEntityType(FileEntityType.VERSION); entity.setReferenceCount(1); entity.setCreatedBy(user); entity.setStoragePolicyId(resolveDefaultStoragePolicyId()); return entity; } private Long resolveDefaultStoragePolicyId() { if (storagePolicyService == null) { return null; } return storagePolicyService.ensureDefaultPolicy().getId(); } private void savePrimaryEntityRelation(StoredFile storedFile, FileEntity primaryEntity) { if (storedFileEntityRepository == null) { return; } StoredFileEntity relation = new StoredFileEntity(); relation.setStoredFile(storedFile); relation.setFileEntity(primaryEntity); relation.setEntityRole("PRIMARY"); storedFileEntityRepository.save(relation); } private FileShareLink getShareLink(String token) { return fileShareLinkRepository.findByToken(token) .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "分享链接不存在")); } private RecycleBinItemResponse toRecycleBinResponse(StoredFile storedFile) { LocalDateTime deletedAt = storedFile.getDeletedAt(); if (deletedAt == null) { throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "回收站文件不存在"); } return new RecycleBinItemResponse( storedFile.getId(), storedFile.getFilename(), requireRecycleOriginalPath(storedFile), storedFile.getSize(), storedFile.getContentType(), storedFile.isDirectory(), storedFile.getCreatedAt(), deletedAt, deletedAt.plusDays(RECYCLE_BIN_RETENTION_DAYS) ); } private StoredFile getOwnedFile(User user, Long fileId, String action) { StoredFile storedFile = storedFileRepository.findDetailedById(fileId) .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在")); if (!storedFile.getUser().getId().equals(user.getId())) { throw new BusinessException(ErrorCode.PERMISSION_DENIED, "没有权限" + action + "该文件"); } return storedFile; } private StoredFile getOwnedActiveFile(User user, Long fileId, String action) { StoredFile storedFile = getOwnedFile(user, fileId, action); if (storedFile.getDeletedAt() != null) { throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"); } return storedFile; } private StoredFile getOwnedRecycleRootFile(User user, Long fileId) { StoredFile storedFile = getOwnedFile(user, fileId, "恢复"); if (storedFile.getDeletedAt() == null || !storedFile.isRecycleRoot()) { throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "回收站文件不存在"); } return storedFile; } private List loadRecycleGroupItems(StoredFile recycleRoot) { List items = storedFileRepository.findByRecycleGroupId(recycleRoot.getRecycleGroupId()); if (items.isEmpty()) { throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "回收站文件不存在"); } return items; } private void validateUpload(User user, String normalizedPath, String filename, long size) { long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes()); if (size > effectiveMaxUploadSize) { throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制"); } if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedPath, filename)) { throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在"); } ensureWithinStorageQuota(user, size); } private void ensureWithinStorageQuota(User user, long additionalBytes) { if (additionalBytes <= 0) { return; } long usedBytes = storedFileRepository.sumFileSizeByUserId(user.getId()); long quotaBytes = user.getStorageQuotaBytes(); if (usedBytes > Long.MAX_VALUE - additionalBytes || usedBytes + additionalBytes > quotaBytes) { throw new BusinessException(ErrorCode.UNKNOWN, "存储空间不足"); } } private void ensureDirectoryHierarchy(User user, String normalizedPath) { if ("/".equals(normalizedPath)) { return; } String[] segments = normalizedPath.substring(1).split("/"); String currentPath = "/"; for (String segment : segments) { Optional existing = storedFileRepository.findByUserIdAndPathAndFilename(user.getId(), currentPath, segment); if (existing.isPresent()) { if (!existing.get().isDirectory()) { throw new BusinessException(ErrorCode.UNKNOWN, "目标路径不是目录"); } currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment; continue; } String logicalPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment; fileContentStorage.ensureDirectory(user.getId(), logicalPath); StoredFile storedFile = new StoredFile(); storedFile.setUser(user); storedFile.setFilename(segment); storedFile.setPath(currentPath); storedFile.setContentType("directory"); storedFile.setSize(0L); storedFile.setDirectory(true); storedFileRepository.save(storedFile); currentPath = logicalPath; } } private void moveToRecycleBin(List filesToRecycle, Long recycleRootId) { if (filesToRecycle.isEmpty()) { return; } StoredFile recycleRoot = filesToRecycle.stream() .filter(item -> recycleRootId.equals(item.getId())) .findFirst() .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在")); String recycleGroupId = UUID.randomUUID().toString().replace("-", ""); LocalDateTime deletedAt = LocalDateTime.now(); String rootLogicalPath = buildLogicalPath(recycleRoot); String recycleRootPath = buildRecycleBinPath(recycleGroupId, recycleRoot.getPath()); String recycleRootLogicalPath = buildTargetLogicalPath(recycleRootPath, recycleRoot.getFilename()); List orderedItems = filesToRecycle.stream() .sorted(Comparator .comparingInt((StoredFile item) -> buildLogicalPath(item).length()) .thenComparing(item -> item.isDirectory() ? 0 : 1) .thenComparing(StoredFile::getFilename)) .toList(); for (StoredFile item : orderedItems) { String originalPath = item.getPath(); String recyclePath = recycleRootId.equals(item.getId()) ? recycleRootPath : remapCopiedPath(item.getPath(), rootLogicalPath, recycleRootLogicalPath); item.setDeletedAt(deletedAt); item.setRecycleOriginalPath(originalPath); item.setRecycleGroupId(recycleGroupId); item.setRecycleRoot(recycleRootId.equals(item.getId())); item.setPath(recyclePath); } storedFileRepository.saveAll(orderedItems); } private String buildRecycleBinPath(String recycleGroupId, String originalPath) { if ("/".equals(originalPath)) { return RECYCLE_BIN_PATH_PREFIX + "/" + recycleGroupId; } return RECYCLE_BIN_PATH_PREFIX + "/" + recycleGroupId + originalPath; } private String requireRecycleOriginalPath(StoredFile storedFile) { if (!StringUtils.hasText(storedFile.getRecycleOriginalPath())) { throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "回收站文件不存在"); } return storedFile.getRecycleOriginalPath(); } private void validateRecycleRestoreTargets(Long userId, List recycleGroupItems) { for (StoredFile item : recycleGroupItems) { String originalPath = requireRecycleOriginalPath(item); if (storedFileRepository.existsByUserIdAndPathAndFilename(userId, originalPath, item.getFilename())) { throw new BusinessException(ErrorCode.UNKNOWN, "原目录已存在同名文件,无法恢复"); } } } private void ensureRecycleRestoreParentHierarchy(User user, StoredFile recycleRoot) { ensureDirectoryHierarchy(user, requireRecycleOriginalPath(recycleRoot)); } private void ensureExistingDirectoryPath(Long userId, String normalizedPath) { if ("/".equals(normalizedPath)) { return; } String[] segments = normalizedPath.substring(1).split("/"); String currentPath = "/"; for (String segment : segments) { StoredFile directory = storedFileRepository.findByUserIdAndPathAndFilename(userId, currentPath, segment) .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "目标目录不存在")); if (!directory.isDirectory()) { throw new BusinessException(ErrorCode.UNKNOWN, "目标路径不是目录"); } currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment; } } private String normalizeUploadFilename(String originalFilename) { String filename = StringUtils.cleanPath(originalFilename); if (!StringUtils.hasText(filename)) { throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空"); } return normalizeLeafName(filename); } 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 normalizeDirectoryPath(String path) { if (!StringUtils.hasText(path) || "/".equals(path.trim())) { return "/"; } String normalized = path.replace("\\", "/").trim(); if (!normalized.startsWith("/")) { normalized = "/" + normalized; } normalized = normalized.replaceAll("/{2,}", "/"); if (normalized.contains("..")) { throw new BusinessException(ErrorCode.UNKNOWN, "路径不合法"); } if (normalized.endsWith("/") && normalized.length() > 1) { normalized = normalized.substring(0, normalized.length() - 1); } return normalized; } private String extractParentPath(String normalizedPath) { int lastSlash = normalizedPath.lastIndexOf('/'); return lastSlash <= 0 ? "/" : normalizedPath.substring(0, lastSlash); } private String extractLeafName(String normalizedPath) { return normalizedPath.substring(normalizedPath.lastIndexOf('/') + 1); } private String buildLogicalPath(StoredFile storedFile) { return "/".equals(storedFile.getPath()) ? "/" + storedFile.getFilename() : storedFile.getPath() + "/" + storedFile.getFilename(); } private String buildTargetLogicalPath(String normalizedTargetPath, String filename) { return "/".equals(normalizedTargetPath) ? "/" + filename : normalizedTargetPath + "/" + filename; } private String remapCopiedPath(String currentPath, String oldLogicalPath, String newLogicalPath) { if (currentPath.equals(oldLogicalPath)) { return newLogicalPath; } return newLogicalPath + currentPath.substring(oldLogicalPath.length()); } private StoredFile copyStoredFile(StoredFile source, User owner, String nextPath) { StoredFile copiedFile = new StoredFile(); copiedFile.setUser(owner); copiedFile.setFilename(source.getFilename()); copiedFile.setPath(nextPath); copiedFile.setContentType(source.getContentType()); copiedFile.setSize(source.getSize()); copiedFile.setDirectory(source.isDirectory()); copiedFile.setBlob(source.getBlob()); return copiedFile; } private StoredFile saveCopiedStoredFile(StoredFile copiedFile, User owner) { if (!copiedFile.isDirectory() && copiedFile.getBlob() != null && copiedFile.getPrimaryEntity() == null) { copiedFile.setPrimaryEntity(createOrReferencePrimaryEntity(owner, copiedFile.getBlob())); } StoredFile savedFile = storedFileRepository.save(copiedFile); if (!savedFile.isDirectory() && savedFile.getPrimaryEntity() != null) { savePrimaryEntityRelation(savedFile, savedFile.getPrimaryEntity()); } recordFileEvent(owner, FileEventType.CREATED, savedFile, null, buildLogicalPath(savedFile)); return savedFile; } private String buildZipEntryName(String rootDirectoryName, String rootLogicalPath, StoredFile storedFile) { StringBuilder entryName = new StringBuilder(rootDirectoryName).append('/'); if (!storedFile.getPath().equals(rootLogicalPath)) { entryName.append(storedFile.getPath().substring(rootLogicalPath.length() + 1)).append('/'); } entryName.append(storedFile.getFilename()); return entryName.toString(); } private void ensureParentDirectoryEntries(ZipOutputStream zipOutputStream, Set createdEntries, String entryName) throws IOException { int slashIndex = entryName.indexOf('/'); while (slashIndex >= 0) { writeDirectoryEntry(zipOutputStream, createdEntries, entryName.substring(0, slashIndex + 1)); slashIndex = entryName.indexOf('/', slashIndex + 1); } } private void writeDirectoryEntry(ZipOutputStream zipOutputStream, Set createdEntries, String entryName) throws IOException { if (!createdEntries.add(entryName)) { return; } zipOutputStream.putNextEntry(new ZipEntry(entryName)); zipOutputStream.closeEntry(); } private void writeFileEntry(ZipOutputStream zipOutputStream, Set createdEntries, String entryName, byte[] content) throws IOException { if (!createdEntries.add(entryName)) { return; } zipOutputStream.putNextEntry(new ZipEntry(entryName)); zipOutputStream.write(content); 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 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)) { throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空"); } if (cleaned.contains("/") || cleaned.contains("\\") || cleaned.contains("..")) { throw new BusinessException(ErrorCode.UNKNOWN, "文件名不合法"); } return cleaned; } private String createBlobObjectKey() { return "blobs/" + UUID.randomUUID(); } private String normalizeBlobObjectKey(String objectKey) { String cleaned = StringUtils.cleanPath(objectKey == null ? "" : objectKey).trim().replace("\\", "/"); if (!StringUtils.hasText(cleaned) || cleaned.contains("..") || cleaned.startsWith("/") || !cleaned.startsWith("blobs/")) { throw new BusinessException(ErrorCode.UNKNOWN, "上传对象标识不合法"); } return cleaned; } private T executeAfterBlobStored(String objectKey, BlobWriteOperation operation) { try { return operation.run(); } catch (RuntimeException ex) { try { fileContentStorage.deleteBlob(objectKey); } catch (RuntimeException cleanupEx) { ex.addSuppressed(cleanupEx); } throw ex; } } private FileBlob createAndSaveBlob(String objectKey, String contentType, long size) { FileBlob blob = new FileBlob(); blob.setObjectKey(objectKey); blob.setContentType(contentType); blob.setSize(size); return fileBlobRepository.save(blob); } private FileMetadataResponse importReferencedBlob(User recipient, String path, String filename, String contentType, long size, FileBlob blob) { String normalizedPath = normalizeDirectoryPath(path); String normalizedFilename = normalizeLeafName(filename); validateUpload(recipient, normalizedPath, normalizedFilename, size); ensureDirectoryHierarchy(recipient, normalizedPath); return saveFileMetadata( recipient, normalizedPath, normalizedFilename, contentType, size, blob ); } private FileBlob getRequiredBlob(StoredFile storedFile) { if (storedFile.isDirectory() || storedFile.getBlob() == null) { throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件内容不存在"); } return storedFile.getBlob(); } private List collectBlobsToDelete(List filesToDelete) { Map candidates = new HashMap<>(); for (StoredFile file : filesToDelete) { if (file.getBlob() == null || file.getBlob().getId() == null) { continue; } BlobDeletionCandidate candidate = candidates.computeIfAbsent( file.getBlob().getId(), ignored -> new BlobDeletionCandidate(file.getBlob()) ); candidate.referencesToDelete += 1; } List blobsToDelete = new ArrayList<>(); for (BlobDeletionCandidate candidate : candidates.values()) { long currentReferences = storedFileRepository.countByBlobId(candidate.blob.getId()); if (currentReferences == candidate.referencesToDelete) { blobsToDelete.add(candidate.blob); } } return blobsToDelete; } private void deleteBlobs(List blobsToDelete) { for (FileBlob blob : blobsToDelete) { fileContentStorage.deleteBlob(blob.getObjectKey()); fileBlobRepository.delete(blob); } } private static final class BlobDeletionCandidate { private final FileBlob blob; private long referencesToDelete; private BlobDeletionCandidate(FileBlob blob) { this.blob = blob; } } @FunctionalInterface private interface BlobWriteOperation { T run(); } }