完善前端直连oss功能,新增重命名以及删除文件功能,完善后端接口

This commit is contained in:
yoyuzh
2026-03-19 10:26:50 +08:00
parent 96079b7e5b
commit e0d859bd82
26 changed files with 2545 additions and 183 deletions

View File

@@ -0,0 +1,19 @@
package com.yoyuzh.config;
import com.yoyuzh.files.storage.FileContentStorage;
import com.yoyuzh.files.storage.LocalFileContentStorage;
import com.yoyuzh.files.storage.OssFileContentStorage;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FileStorageConfiguration {
@Bean
public FileContentStorage fileContentStorage(FileStorageProperties properties) {
if ("oss".equalsIgnoreCase(properties.getProvider())) {
return new OssFileContentStorage(properties);
}
return new LocalFileContentStorage(properties);
}
}

View File

@@ -5,15 +5,25 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.storage")
public class FileStorageProperties {
private String rootDir = "./storage";
private String provider = "local";
private final Local local = new Local();
private final Oss oss = new Oss();
private long maxFileSize = 50 * 1024 * 1024L;
public String getRootDir() {
return rootDir;
public String getProvider() {
return provider;
}
public void setRootDir(String rootDir) {
this.rootDir = rootDir;
public void setProvider(String provider) {
this.provider = provider;
}
public Local getLocal() {
return local;
}
public Oss getOss() {
return oss;
}
public long getMaxFileSize() {
@@ -23,4 +33,82 @@ public class FileStorageProperties {
public void setMaxFileSize(long maxFileSize) {
this.maxFileSize = maxFileSize;
}
// Backward-compatible convenience accessors used by existing tests and dev tooling.
public String getRootDir() {
return local.getRootDir();
}
public void setRootDir(String rootDir) {
local.setRootDir(rootDir);
}
public static class Local {
private String rootDir = "./storage";
public String getRootDir() {
return rootDir;
}
public void setRootDir(String rootDir) {
this.rootDir = rootDir;
}
}
public static class Oss {
private String endpoint;
private String bucket;
private String accessKeyId;
private String accessKeySecret;
private String publicBaseUrl;
private boolean privateBucket = true;
public String getEndpoint() {
return endpoint;
}
public void setEndpoint(String endpoint) {
this.endpoint = endpoint;
}
public String getBucket() {
return bucket;
}
public void setBucket(String bucket) {
this.bucket = bucket;
}
public String getAccessKeyId() {
return accessKeyId;
}
public void setAccessKeyId(String accessKeyId) {
this.accessKeyId = accessKeyId;
}
public String getAccessKeySecret() {
return accessKeySecret;
}
public void setAccessKeySecret(String accessKeySecret) {
this.accessKeySecret = accessKeySecret;
}
public String getPublicBaseUrl() {
return publicBaseUrl;
}
public void setPublicBaseUrl(String publicBaseUrl) {
this.publicBaseUrl = publicBaseUrl;
}
public boolean isPrivateBucket() {
return privateBucket;
}
public void setPrivateBucket(boolean privateBucket) {
this.privateBucket = privateBucket;
}
}
}

View File

@@ -91,7 +91,7 @@ public class SecurityConfig {
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(corsProperties.getAllowedOrigins());
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(false);
configuration.setMaxAge(3600L);

View File

@@ -0,0 +1,13 @@
package com.yoyuzh.files;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public record CompleteUploadRequest(
@NotBlank String path,
@NotBlank String filename,
@NotBlank String storageName,
String contentType,
@Min(0) long size
) {
}

View File

@@ -0,0 +1,4 @@
package com.yoyuzh.files;
public record DownloadUrlResponse(String url) {
}

View File

@@ -14,8 +14,10 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -38,6 +40,26 @@ public class FileController {
return ApiResponse.success(fileService.upload(userDetailsService.loadDomainUser(userDetails.getUsername()), path, file));
}
@Operation(summary = "初始化上传")
@PostMapping("/upload/initiate")
public ApiResponse<InitiateUploadResponse> initiateUpload(@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody InitiateUploadRequest request) {
return ApiResponse.success(fileService.initiateUpload(
userDetailsService.loadDomainUser(userDetails.getUsername()),
request
));
}
@Operation(summary = "完成上传")
@PostMapping("/upload/complete")
public ApiResponse<FileMetadataResponse> completeUpload(@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody CompleteUploadRequest request) {
return ApiResponse.success(fileService.completeUpload(
userDetailsService.loadDomainUser(userDetails.getUsername()),
request
));
}
@Operation(summary = "创建目录")
@PostMapping("/mkdir")
public ApiResponse<FileMetadataResponse> mkdir(@AuthenticationPrincipal UserDetails userDetails,
@@ -62,11 +84,30 @@ public class FileController {
@Operation(summary = "下载文件")
@GetMapping("/download/{fileId}")
public ResponseEntity<byte[]> download(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long fileId) {
public ResponseEntity<?> download(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long fileId) {
return fileService.download(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId);
}
@Operation(summary = "获取下载链接")
@GetMapping("/download/{fileId}/url")
public ApiResponse<DownloadUrlResponse> downloadUrl(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long fileId) {
return ApiResponse.success(fileService.getDownloadUrl(
userDetailsService.loadDomainUser(userDetails.getUsername()),
fileId
));
}
@Operation(summary = "重命名文件")
@PatchMapping("/{fileId}/rename")
public ApiResponse<FileMetadataResponse> rename(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long fileId,
@Valid @RequestBody RenameFileRequest request) {
return ApiResponse.success(
fileService.rename(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId, request.filename()));
}
@Operation(summary = "删除文件")
@DeleteMapping("/{fileId}")
public ApiResponse<Void> delete(@AuthenticationPrincipal UserDetails userDetails,

View File

@@ -5,6 +5,8 @@ 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.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpHeaders;
@@ -15,12 +17,9 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
@Service
@@ -28,52 +27,58 @@ public class FileService {
private static final List<String> DEFAULT_DIRECTORIES = List.of("下载", "文档", "图片");
private final StoredFileRepository storedFileRepository;
private final Path rootPath;
private final FileContentStorage fileContentStorage;
private final long maxFileSize;
public FileService(StoredFileRepository storedFileRepository, FileStorageProperties properties) {
public FileService(StoredFileRepository storedFileRepository,
FileContentStorage fileContentStorage,
FileStorageProperties properties) {
this.storedFileRepository = storedFileRepository;
this.rootPath = Path.of(properties.getRootDir()).toAbsolutePath().normalize();
this.fileContentStorage = fileContentStorage;
this.maxFileSize = properties.getMaxFileSize();
try {
Files.createDirectories(rootPath);
} catch (IOException ex) {
throw new IllegalStateException("无法初始化存储目录", ex);
}
}
@Transactional
public FileMetadataResponse upload(User user, String path, MultipartFile multipartFile) {
String normalizedPath = normalizeDirectoryPath(path);
String filename = StringUtils.cleanPath(multipartFile.getOriginalFilename());
if (!StringUtils.hasText(filename)) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空");
}
if (multipartFile.getSize() > maxFileSize) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制");
}
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedPath, filename)) {
throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在");
}
String filename = normalizeUploadFilename(multipartFile.getOriginalFilename());
validateUpload(user.getId(), normalizedPath, filename, multipartFile.getSize());
Path targetDir = resolveUserPath(user.getId(), normalizedPath);
Path targetFile = targetDir.resolve(filename).normalize();
try {
Files.createDirectories(targetDir);
Files.copy(multipartFile.getInputStream(), targetFile, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件上传失败");
}
fileContentStorage.upload(user.getId(), normalizedPath, filename, multipartFile);
return saveFileMetadata(user, normalizedPath, filename, filename, multipartFile.getContentType(), multipartFile.getSize());
}
StoredFile storedFile = new StoredFile();
storedFile.setUser(user);
storedFile.setFilename(filename);
storedFile.setPath(normalizedPath);
storedFile.setStorageName(filename);
storedFile.setContentType(multipartFile.getContentType());
storedFile.setSize(multipartFile.getSize());
storedFile.setDirectory(false);
return toResponse(storedFileRepository.save(storedFile));
public InitiateUploadResponse initiateUpload(User user, InitiateUploadRequest request) {
String normalizedPath = normalizeDirectoryPath(request.path());
String filename = normalizeLeafName(request.filename());
validateUpload(user.getId(), normalizedPath, filename, request.size());
PreparedUpload preparedUpload = fileContentStorage.prepareUpload(
user.getId(),
normalizedPath,
filename,
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 storageName = normalizeLeafName(request.storageName());
validateUpload(user.getId(), normalizedPath, filename, request.size());
fileContentStorage.completeUpload(user.getId(), normalizedPath, storageName, request.contentType(), request.size());
return saveFileMetadata(user, normalizedPath, filename, storageName, request.contentType(), request.size());
}
@Transactional
@@ -87,11 +92,8 @@ public class FileService {
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), parentPath, directoryName)) {
throw new BusinessException(ErrorCode.UNKNOWN, "目录已存在");
}
try {
Files.createDirectories(resolveUserPath(user.getId(), normalizedPath));
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "目录创建失败");
}
fileContentStorage.createDirectory(user.getId(), normalizedPath);
StoredFile storedFile = new StoredFile();
storedFile.setUser(user);
@@ -126,11 +128,8 @@ public class FileService {
continue;
}
try {
Files.createDirectories(resolveUserPath(user.getId(), "/").resolve(directoryName));
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "默认目录初始化失败");
}
String logicalPath = "/" + directoryName;
fileContentStorage.ensureDirectory(user.getId(), logicalPath);
StoredFile storedFile = new StoredFile();
storedFile.setUser(user);
@@ -146,53 +145,146 @@ public class FileService {
@Transactional
public void delete(User user, Long fileId) {
StoredFile storedFile = storedFileRepository.findById(fileId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
if (!storedFile.getUser().getId().equals(user.getId())) {
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "没有权限删除该文件");
}
try {
Path basePath = resolveUserPath(user.getId(), storedFile.getPath());
Path target = storedFile.isDirectory()
? basePath.resolve(storedFile.getFilename()).normalize()
: basePath.resolve(storedFile.getStorageName()).normalize();
Files.deleteIfExists(target);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "删除文件失败");
StoredFile storedFile = getOwnedFile(user, fileId, "删除");
if (storedFile.isDirectory()) {
String logicalPath = buildLogicalPath(storedFile);
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), logicalPath);
fileContentStorage.deleteDirectory(user.getId(), logicalPath, descendants);
if (!descendants.isEmpty()) {
storedFileRepository.deleteAll(descendants);
}
} else {
fileContentStorage.deleteFile(user.getId(), storedFile.getPath(), storedFile.getStorageName());
}
storedFileRepository.delete(storedFile);
}
public ResponseEntity<byte[]> download(User user, Long fileId) {
StoredFile storedFile = storedFileRepository.findById(fileId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
if (!storedFile.getUser().getId().equals(user.getId())) {
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "没有权限下载该文件");
@Transactional
public FileMetadataResponse rename(User user, Long fileId, String nextFilename) {
StoredFile storedFile = getOwnedFile(user, fileId, "重命名");
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<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath);
fileContentStorage.renameDirectory(user.getId(), oldLogicalPath, newLogicalPath, descendants);
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);
}
} else {
fileContentStorage.renameFile(user.getId(), storedFile.getPath(), storedFile.getStorageName(), sanitizedFilename);
}
storedFile.setFilename(sanitizedFilename);
storedFile.setStorageName(sanitizedFilename);
return toResponse(storedFileRepository.save(storedFile));
}
public ResponseEntity<?> download(User user, Long fileId) {
StoredFile storedFile = getOwnedFile(user, fileId, "下载");
if (storedFile.isDirectory()) {
throw new BusinessException(ErrorCode.UNKNOWN, "目录不支持下载");
}
try {
Path filePath = resolveUserPath(user.getId(), storedFile.getPath()).resolve(storedFile.getStorageName()).normalize();
byte[] body = Files.readAllBytes(filePath);
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(body);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在");
if (fileContentStorage.supportsDirectDownload()) {
return ResponseEntity.status(302)
.location(URI.create(fileContentStorage.createDownloadUrl(
user.getId(),
storedFile.getPath(),
storedFile.getStorageName(),
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.readFile(user.getId(), storedFile.getPath(), storedFile.getStorageName()));
}
public DownloadUrlResponse getDownloadUrl(User user, Long fileId) {
StoredFile storedFile = getOwnedFile(user, fileId, "下载");
if (storedFile.isDirectory()) {
throw new BusinessException(ErrorCode.UNKNOWN, "目录不支持下载");
}
if (fileContentStorage.supportsDirectDownload()) {
return new DownloadUrlResponse(fileContentStorage.createDownloadUrl(
user.getId(),
storedFile.getPath(),
storedFile.getStorageName(),
storedFile.getFilename()
));
}
return new DownloadUrlResponse("/api/files/download/" + storedFile.getId());
}
private FileMetadataResponse saveFileMetadata(User user,
String normalizedPath,
String filename,
String storageName,
String contentType,
long size) {
StoredFile storedFile = new StoredFile();
storedFile.setUser(user);
storedFile.setFilename(filename);
storedFile.setPath(normalizedPath);
storedFile.setStorageName(storageName);
storedFile.setContentType(contentType);
storedFile.setSize(size);
storedFile.setDirectory(false);
return toResponse(storedFileRepository.save(storedFile));
}
private StoredFile getOwnedFile(User user, Long fileId, String action) {
StoredFile storedFile = storedFileRepository.findById(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 void validateUpload(Long userId, String normalizedPath, String filename, long size) {
if (size > maxFileSize) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制");
}
if (storedFileRepository.existsByUserIdAndPathAndFilename(userId, normalizedPath, filename)) {
throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在");
}
}
private FileMetadataResponse toResponse(StoredFile storedFile) {
String logicalPath = storedFile.getPath();
if (storedFile.isDirectory()) {
logicalPath = "/".equals(storedFile.getPath())
? "/" + storedFile.getFilename()
: storedFile.getPath() + "/" + storedFile.getFilename();
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(),
@@ -221,16 +313,6 @@ public class FileService {
return normalized;
}
private Path resolveUserPath(Long userId, String normalizedPath) {
Path userRoot = rootPath.resolve(userId.toString()).normalize();
Path relative = "/".equals(normalizedPath) ? Path.of("") : Path.of(normalizedPath.substring(1));
Path resolved = userRoot.resolve(relative).normalize();
if (!resolved.startsWith(userRoot)) {
throw new BusinessException(ErrorCode.UNKNOWN, "路径不合法");
}
return resolved;
}
private String extractParentPath(String normalizedPath) {
int lastSlash = normalizedPath.lastIndexOf('/');
return lastSlash <= 0 ? "/" : normalizedPath.substring(0, lastSlash);
@@ -239,4 +321,21 @@ public class FileService {
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 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;
}
}

View File

@@ -0,0 +1,12 @@
package com.yoyuzh.files;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public record InitiateUploadRequest(
@NotBlank String path,
@NotBlank String filename,
String contentType,
@Min(0) long size
) {
}

View File

@@ -0,0 +1,12 @@
package com.yoyuzh.files;
import java.util.Map;
public record InitiateUploadResponse(
boolean direct,
String uploadUrl,
String method,
Map<String, String> headers,
String storageName
) {
}

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.files;
import jakarta.validation.constraints.NotBlank;
public record RenameFileRequest(
@NotBlank(message = "文件名不能为空")
String filename
) {
}

View File

@@ -28,5 +28,13 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
@Param("path") String path,
Pageable pageable);
@Query("""
select f from StoredFile f
where f.user.id = :userId and (f.path = :path or f.path like concat(:path, '/%'))
order by f.createdAt asc
""")
List<StoredFile> findByUserIdAndPathEqualsOrDescendant(@Param("userId") Long userId,
@Param("path") String path);
List<StoredFile> findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(Long userId);
}