完善前端直连oss功能,新增重命名以及删除文件功能,完善后端接口
This commit is contained in:
@@ -1,5 +1,27 @@
|
||||
YOYUZH_OSS_ENDPOINT=https://oss-ap-northeast-1.aliyuncs.com
|
||||
YOYUZH_OSS_BUCKET=yoyuzh-2026
|
||||
YOYUZH_OSS_PREFIX=
|
||||
YOYUZH_OSS_ACCESS_KEY_ID=
|
||||
YOYUZH_OSS_ACCESS_KEY_SECRET=
|
||||
# 复制本文件为 `.env.oss.local` 后再填写真实值:
|
||||
# cp .env.oss.example .env.oss.local
|
||||
#
|
||||
# 发布命令:
|
||||
# ./scripts/deploy-front-oss.mjs
|
||||
#
|
||||
# 仅预览将要上传的文件:
|
||||
# ./scripts/deploy-front-oss.mjs --skip-build --dry-run
|
||||
|
||||
# 阿里云 OSS Endpoint。
|
||||
# 当前项目使用东京区域 OSS,默认保持这个值即可。
|
||||
YOYUZH_OSS_ENDPOINT="https://oss-ap-northeast-1.aliyuncs.com"
|
||||
|
||||
# 前端静态站点所在的 OSS Bucket 名称。
|
||||
YOYUZH_OSS_BUCKET="yoyuzh-2026"
|
||||
|
||||
# 可选:上传到 Bucket 内的子目录。
|
||||
# 为空表示直接上传到 Bucket 根目录。
|
||||
YOYUZH_OSS_PREFIX=""
|
||||
|
||||
# 阿里云 AccessKey ID。
|
||||
# 不要把真实值提交到 git。
|
||||
YOYUZH_OSS_ACCESS_KEY_ID="YOUR_ACCESS_KEY_ID"
|
||||
|
||||
# 阿里云 AccessKey Secret。
|
||||
# 不要把真实值提交到 git。
|
||||
YOYUZH_OSS_ACCESS_KEY_SECRET="YOUR_ACCESS_KEY_SECRET"
|
||||
|
||||
@@ -86,9 +86,12 @@ CREATE INDEX IF NOT EXISTS idx_grade_user_semester ON portal_grade (user_id, sem
|
||||
- `POST /api/auth/login`
|
||||
- `GET /api/user/profile`
|
||||
- `POST /api/files/upload`
|
||||
- `POST /api/files/upload/initiate`
|
||||
- `POST /api/files/upload/complete`
|
||||
- `POST /api/files/mkdir`
|
||||
- `GET /api/files/list`
|
||||
- `GET /api/files/download/{fileId}`
|
||||
- `GET /api/files/download/{fileId}/url`
|
||||
- `DELETE /api/files/{fileId}`
|
||||
- `GET /api/cqu/schedule`
|
||||
- `GET /api/cqu/grades`
|
||||
@@ -106,3 +109,23 @@ app:
|
||||
```
|
||||
|
||||
当前 Java 后端保留了 HTTP 适配点;本地 `dev` 环境使用 mock 数据先把前后端链路跑通。
|
||||
|
||||
## OSS 直传说明
|
||||
|
||||
生产环境如果启用:
|
||||
|
||||
```env
|
||||
YOYUZH_STORAGE_PROVIDER=oss
|
||||
YOYUZH_OSS_ENDPOINT=https://oss-ap-northeast-1.aliyuncs.com
|
||||
YOYUZH_OSS_BUCKET=your-bucket
|
||||
YOYUZH_OSS_ACCESS_KEY_ID=...
|
||||
YOYUZH_OSS_ACCESS_KEY_SECRET=...
|
||||
```
|
||||
|
||||
前端会先调用后端拿签名上传地址,再由浏览器直接把文件内容传到 OSS。为保证浏览器可以直传,请在 OSS Bucket 上放行站点域名对应的 CORS 规则,至少允许:
|
||||
|
||||
- Origin: `https://yoyuzh.xyz`
|
||||
- Methods: `PUT`, `GET`, `HEAD`
|
||||
- Headers: `Content-Type`, `x-oss-*`
|
||||
|
||||
如果生产环境里曾经存在“数据库元数据已经在 OSS 模式下运行,但本地磁盘里没有对应文件”的历史数据,需要额外做一次对象迁移或元数据修复;否则旧记录在重命名/删除时仍可能失败。
|
||||
|
||||
@@ -65,6 +65,11 @@
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.aliyun.oss</groupId>
|
||||
<artifactId>aliyun-sdk-oss</artifactId>
|
||||
<version>3.17.4</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
public record DownloadUrlResponse(String url) {
|
||||
}
|
||||
@@ -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,
|
||||
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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
fileContentStorage.upload(user.getId(), normalizedPath, filename, multipartFile);
|
||||
return saveFileMetadata(user, normalizedPath, filename, filename, multipartFile.getContentType(), 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, "文件上传失败");
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
@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, "没有权限删除该文件");
|
||||
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);
|
||||
}
|
||||
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, "删除文件失败");
|
||||
} 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);
|
||||
|
||||
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(body);
|
||||
} catch (IOException ex) {
|
||||
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在");
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record RenameFileRequest(
|
||||
@NotBlank(message = "文件名不能为空")
|
||||
String filename
|
||||
) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class SecurityConfigTest {
|
||||
|
||||
@Test
|
||||
void corsConfigurationShouldAllowPatchRequests() {
|
||||
CorsProperties corsProperties = new CorsProperties();
|
||||
corsProperties.setAllowedOrigins(java.util.List.of("https://yoyuzh.xyz"));
|
||||
|
||||
SecurityConfig securityConfig = new SecurityConfig(
|
||||
null,
|
||||
null,
|
||||
new ObjectMapper(),
|
||||
corsProperties
|
||||
);
|
||||
|
||||
CorsConfigurationSource source = securityConfig.corsConfigurationSource();
|
||||
CorsConfiguration configuration = source.getCorsConfiguration(
|
||||
new org.springframework.mock.web.MockHttpServletRequest("OPTIONS", "/api/files/1/rename"));
|
||||
|
||||
assertThat(configuration).isNotNull();
|
||||
assertThat(configuration.getAllowedMethods()).contains("PATCH");
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,10 @@ package com.yoyuzh.files;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import com.yoyuzh.files.storage.PreparedUpload;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
@@ -13,14 +14,16 @@ import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
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.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -31,21 +34,20 @@ class FileServiceTest {
|
||||
@Mock
|
||||
private StoredFileRepository storedFileRepository;
|
||||
|
||||
private FileService fileService;
|
||||
@Mock
|
||||
private FileContentStorage fileContentStorage;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
private FileService fileService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
FileStorageProperties properties = new FileStorageProperties();
|
||||
properties.setRootDir(tempDir.toString());
|
||||
properties.setMaxFileSize(50 * 1024 * 1024);
|
||||
fileService = new FileService(storedFileRepository, properties);
|
||||
fileService = new FileService(storedFileRepository, fileContentStorage, properties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldStoreUploadedFileUnderUserDirectory() {
|
||||
void shouldStoreUploadedFileViaConfiguredStorage() {
|
||||
User user = createUser(7L);
|
||||
MockMultipartFile multipartFile = new MockMultipartFile(
|
||||
"file", "notes.txt", "text/plain", "hello".getBytes());
|
||||
@@ -60,8 +62,71 @@ class FileServiceTest {
|
||||
|
||||
assertThat(response.id()).isEqualTo(10L);
|
||||
assertThat(response.path()).isEqualTo("/docs");
|
||||
assertThat(response.directory()).isFalse();
|
||||
assertThat(tempDir.resolve("7/docs/notes.txt")).exists();
|
||||
verify(fileContentStorage).upload(7L, "/docs", "notes.txt", multipartFile);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldInitiateDirectUploadThroughStorage() {
|
||||
User user = createUser(7L);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
|
||||
when(fileContentStorage.prepareUpload(7L, "/docs", "notes.txt", "text/plain", 12L))
|
||||
.thenReturn(new PreparedUpload(true, "https://upload.example.com", "PUT", Map.of("Content-Type", "text/plain"), "notes.txt"));
|
||||
|
||||
InitiateUploadResponse response = fileService.initiateUpload(user,
|
||||
new InitiateUploadRequest("/docs", "notes.txt", "text/plain", 12L));
|
||||
|
||||
assertThat(response.direct()).isTrue();
|
||||
assertThat(response.uploadUrl()).isEqualTo("https://upload.example.com");
|
||||
verify(fileContentStorage).prepareUpload(7L, "/docs", "notes.txt", "text/plain", 12L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCompleteDirectUploadAndPersistMetadata() {
|
||||
User user = createUser(7L);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> {
|
||||
StoredFile file = invocation.getArgument(0);
|
||||
file.setId(11L);
|
||||
return file;
|
||||
});
|
||||
|
||||
FileMetadataResponse response = fileService.completeUpload(user,
|
||||
new CompleteUploadRequest("/docs", "notes.txt", "notes.txt", "text/plain", 12L));
|
||||
|
||||
assertThat(response.id()).isEqualTo(11L);
|
||||
verify(fileContentStorage).completeUpload(7L, "/docs", "notes.txt", "text/plain", 12L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRenameFileThroughConfiguredStorage() {
|
||||
User user = createUser(7L);
|
||||
StoredFile storedFile = createFile(10L, user, "/docs", "notes.txt");
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(storedFile));
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "renamed.txt")).thenReturn(false);
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
FileMetadataResponse response = fileService.rename(user, 10L, "renamed.txt");
|
||||
|
||||
assertThat(response.filename()).isEqualTo("renamed.txt");
|
||||
verify(fileContentStorage).renameFile(7L, "/docs", "notes.txt", "renamed.txt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRenameDirectoryAndUpdateDescendantPaths() {
|
||||
User user = createUser(7L);
|
||||
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
||||
StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt");
|
||||
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "renamed-archive")).thenReturn(false);
|
||||
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile));
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
FileMetadataResponse response = fileService.rename(user, 10L, "renamed-archive");
|
||||
|
||||
assertThat(response.filename()).isEqualTo("renamed-archive");
|
||||
assertThat(childFile.getPath()).isEqualTo("/docs/renamed-archive");
|
||||
verify(fileContentStorage).renameDirectory(7L, "/docs/archive", "/docs/renamed-archive", List.of(childFile));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -76,6 +141,22 @@ class FileServiceTest {
|
||||
.hasMessageContaining("没有权限");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeleteDirectoryWithNestedFilesViaStorage() {
|
||||
User user = createUser(7L);
|
||||
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
||||
StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt");
|
||||
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile));
|
||||
|
||||
fileService.delete(user, 10L);
|
||||
|
||||
verify(fileContentStorage).deleteDirectory(7L, "/docs/archive", List.of(childFile));
|
||||
verify(storedFileRepository).deleteAll(List.of(childFile));
|
||||
verify(storedFileRepository).delete(directory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldListFilesByPathWithPagination() {
|
||||
User user = createUser(7L);
|
||||
@@ -100,15 +181,39 @@ class FileServiceTest {
|
||||
|
||||
fileService.ensureDefaultDirectories(user);
|
||||
|
||||
assertThat(tempDir.resolve("7/下载")).exists();
|
||||
assertThat(tempDir.resolve("7/文档")).exists();
|
||||
assertThat(tempDir.resolve("7/图片")).exists();
|
||||
verify(storedFileRepository).existsByUserIdAndPathAndFilename(7L, "/", "下载");
|
||||
verify(storedFileRepository).existsByUserIdAndPathAndFilename(7L, "/", "文档");
|
||||
verify(storedFileRepository).existsByUserIdAndPathAndFilename(7L, "/", "图片");
|
||||
verify(fileContentStorage).ensureDirectory(7L, "/下载");
|
||||
verify(fileContentStorage).ensureDirectory(7L, "/文档");
|
||||
verify(fileContentStorage).ensureDirectory(7L, "/图片");
|
||||
verify(storedFileRepository, times(3)).save(any(StoredFile.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseSignedDownloadUrlWhenStorageSupportsDirectDownload() {
|
||||
User user = createUser(7L);
|
||||
StoredFile file = createFile(22L, user, "/docs", "notes.txt");
|
||||
when(storedFileRepository.findById(22L)).thenReturn(Optional.of(file));
|
||||
when(fileContentStorage.supportsDirectDownload()).thenReturn(true);
|
||||
when(fileContentStorage.createDownloadUrl(7L, "/docs", "notes.txt", "notes.txt"))
|
||||
.thenReturn("https://download.example.com/file");
|
||||
|
||||
DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L);
|
||||
|
||||
assertThat(response.url()).isEqualTo("https://download.example.com/file");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFallbackToBackendDownloadUrlWhenStorageIsLocal() {
|
||||
User user = createUser(7L);
|
||||
StoredFile file = createFile(22L, user, "/docs", "notes.txt");
|
||||
when(storedFileRepository.findById(22L)).thenReturn(Optional.of(file));
|
||||
when(fileContentStorage.supportsDirectDownload()).thenReturn(false);
|
||||
|
||||
DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L);
|
||||
|
||||
assertThat(response.url()).isEqualTo("/api/files/download/22");
|
||||
verify(fileContentStorage, never()).createDownloadUrl(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
private User createUser(Long id) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
@@ -131,4 +236,12 @@ class FileServiceTest {
|
||||
file.setCreatedAt(LocalDateTime.now());
|
||||
return file;
|
||||
}
|
||||
|
||||
private StoredFile createDirectory(Long id, User user, String path, String filename) {
|
||||
StoredFile directory = createFile(id, user, path, filename);
|
||||
directory.setDirectory(true);
|
||||
directory.setContentType("directory");
|
||||
directory.setSize(0L);
|
||||
return directory;
|
||||
}
|
||||
}
|
||||
|
||||
70
docs/superpowers/plans/2026-03-19-oss-direct-upload.md
Normal file
70
docs/superpowers/plans/2026-03-19-oss-direct-upload.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# OSS Direct Upload Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move file content storage to OSS and let the frontend upload/download file bytes directly against OSS while the backend keeps auth and metadata control.
|
||||
|
||||
**Architecture:** Introduce a backend storage abstraction with `local` and `oss` implementations, then add signed upload/download APIs so the browser can PUT file bytes to OSS and fetch signed download links. File list, mkdir, rename, and delete stay authenticated through the backend, with OSS-backed rename/delete implemented as object copy/delete plus metadata updates.
|
||||
|
||||
**Tech Stack:** Spring Boot 3, React/Vite, Aliyun OSS SDK, existing JWT auth, MySQL metadata tables.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Storage Abstraction
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/src/main/java/com/yoyuzh/files/storage/FileContentStorage.java`
|
||||
- Create: `backend/src/main/java/com/yoyuzh/files/storage/LocalFileContentStorage.java`
|
||||
- Create: `backend/src/main/java/com/yoyuzh/files/storage/OssFileContentStorage.java`
|
||||
- Modify: `backend/src/main/java/com/yoyuzh/config/FileStorageProperties.java`
|
||||
- Modify: `backend/pom.xml`
|
||||
|
||||
- [ ] Add nested storage configuration for `local` and `oss`
|
||||
- [ ] Add a content-storage interface for upload, signed upload URL, signed download URL, rename, and delete
|
||||
- [ ] Implement local storage compatibility for dev
|
||||
- [ ] Implement OSS object-key storage for prod
|
||||
|
||||
### Task 2: File Service Integration
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/main/java/com/yoyuzh/files/FileService.java`
|
||||
- Modify: `backend/src/main/java/com/yoyuzh/files/FileController.java`
|
||||
- Create: `backend/src/main/java/com/yoyuzh/files/InitiateUploadRequest.java`
|
||||
- Create: `backend/src/main/java/com/yoyuzh/files/InitiateUploadResponse.java`
|
||||
- Create: `backend/src/main/java/com/yoyuzh/files/CompleteUploadRequest.java`
|
||||
- Create: `backend/src/main/java/com/yoyuzh/files/DownloadUrlResponse.java`
|
||||
|
||||
- [ ] Add backend APIs for upload initiation, upload completion, and signed download URLs
|
||||
- [ ] Route rename/delete/mkdir through the active storage implementation
|
||||
- [ ] Keep metadata updates transactional
|
||||
|
||||
### Task 3: Frontend Direct Transfer
|
||||
|
||||
**Files:**
|
||||
- Modify: `front/src/pages/Files.tsx`
|
||||
- Modify: `front/src/lib/api.ts`
|
||||
- Modify: `front/src/lib/types.ts`
|
||||
|
||||
- [ ] Replace backend proxy upload with signed direct upload flow
|
||||
- [ ] Replace streamed backend download with signed URL download flow
|
||||
- [ ] Preserve current upload progress UI and retry behavior
|
||||
|
||||
### Task 4: Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/test/java/com/yoyuzh/files/FileServiceTest.java`
|
||||
- Create: `backend/src/test/java/com/yoyuzh/files/OssFileContentStorageTest.java`
|
||||
- Modify: `front/src/lib/api.test.ts`
|
||||
|
||||
- [ ] Cover service behavior with mocked storage
|
||||
- [ ] Cover OSS object-key rename/delete behavior
|
||||
- [ ] Cover frontend direct upload state changes and network retries
|
||||
|
||||
### Task 5: Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/README.md`
|
||||
|
||||
- [ ] Run targeted backend tests
|
||||
- [ ] Run frontend tests, lint, and build
|
||||
- [ ] Document OSS bucket CORS requirement and any one-time migration follow-up
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, beforeEach, test } from 'node:test';
|
||||
|
||||
import { apiRequest, shouldRetryRequest, toNetworkApiError } from './api';
|
||||
import { apiBinaryUploadRequest, apiRequest, apiUploadRequest, shouldRetryRequest, toNetworkApiError } from './api';
|
||||
import { clearStoredSession, saveStoredSession } from './session';
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
@@ -34,12 +34,88 @@ class MemoryStorage implements Storage {
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalStorage = globalThis.localStorage;
|
||||
const originalXMLHttpRequest = globalThis.XMLHttpRequest;
|
||||
|
||||
class FakeXMLHttpRequest {
|
||||
static latest: FakeXMLHttpRequest | null = null;
|
||||
|
||||
method = '';
|
||||
url = '';
|
||||
requestBody: Document | XMLHttpRequestBodyInit | null = null;
|
||||
responseText = '';
|
||||
status = 200;
|
||||
headers = new Map<string, string>();
|
||||
responseHeaders = new Map<string, string>();
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
|
||||
upload = {
|
||||
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
|
||||
if (type !== 'progress') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.progressListeners.push(listener);
|
||||
},
|
||||
};
|
||||
|
||||
private progressListeners: EventListenerOrEventListenerObject[] = [];
|
||||
|
||||
constructor() {
|
||||
FakeXMLHttpRequest.latest = this;
|
||||
}
|
||||
|
||||
open(method: string, url: string) {
|
||||
this.method = method;
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
setRequestHeader(name: string, value: string) {
|
||||
this.headers.set(name.toLowerCase(), value);
|
||||
}
|
||||
|
||||
getResponseHeader(name: string) {
|
||||
return this.responseHeaders.get(name) ?? null;
|
||||
}
|
||||
|
||||
send(body: Document | XMLHttpRequestBodyInit | null) {
|
||||
this.requestBody = body;
|
||||
}
|
||||
|
||||
triggerProgress(loaded: number, total: number) {
|
||||
const event = {
|
||||
lengthComputable: true,
|
||||
loaded,
|
||||
total,
|
||||
} as ProgressEvent<EventTarget>;
|
||||
|
||||
for (const listener of this.progressListeners) {
|
||||
if (typeof listener === 'function') {
|
||||
listener(event);
|
||||
} else {
|
||||
listener.handleEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
respond(body: unknown, status = 200, contentType = 'application/json') {
|
||||
this.status = status;
|
||||
this.responseText = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
this.responseHeaders.set('content-type', contentType);
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: new MemoryStorage(),
|
||||
});
|
||||
Object.defineProperty(globalThis, 'XMLHttpRequest', {
|
||||
configurable: true,
|
||||
value: FakeXMLHttpRequest,
|
||||
});
|
||||
FakeXMLHttpRequest.latest = null;
|
||||
clearStoredSession();
|
||||
});
|
||||
|
||||
@@ -49,6 +125,10 @@ afterEach(() => {
|
||||
configurable: true,
|
||||
value: originalStorage,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'XMLHttpRequest', {
|
||||
configurable: true,
|
||||
value: originalXMLHttpRequest,
|
||||
});
|
||||
});
|
||||
|
||||
test('apiRequest attaches bearer token and unwraps response payload', async () => {
|
||||
@@ -133,9 +213,99 @@ test('network get failures are retried up to two times after the first attempt',
|
||||
assert.equal(shouldRetryRequest('/files/list', {method: 'GET'}, error, 3), false);
|
||||
});
|
||||
|
||||
test('network rename failures are retried once for idempotent file rename requests', () => {
|
||||
const error = new TypeError('Failed to fetch');
|
||||
|
||||
assert.equal(shouldRetryRequest('/files/32/rename', {method: 'PATCH'}, error, 0), true);
|
||||
assert.equal(shouldRetryRequest('/files/32/rename', {method: 'PATCH'}, error, 1), false);
|
||||
});
|
||||
|
||||
test('network fetch failures are converted to readable api errors', () => {
|
||||
const apiError = toNetworkApiError(new TypeError('Failed to fetch'));
|
||||
|
||||
assert.equal(apiError.status, 0);
|
||||
assert.match(apiError.message, /网络连接异常|Failed to fetch/);
|
||||
});
|
||||
|
||||
test('apiUploadRequest attaches auth header and forwards upload progress', async () => {
|
||||
saveStoredSession({
|
||||
token: 'token-456',
|
||||
user: {
|
||||
id: 2,
|
||||
username: 'uploader',
|
||||
email: 'uploader@example.com',
|
||||
createdAt: '2026-03-18T10:00:00',
|
||||
},
|
||||
});
|
||||
|
||||
const progressCalls: Array<{loaded: number; total: number}> = [];
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['hello']), 'hello.txt');
|
||||
|
||||
const uploadPromise = apiUploadRequest<{id: number}>('/files/upload?path=%2F', {
|
||||
body: formData,
|
||||
onProgress: (progress) => {
|
||||
progressCalls.push(progress);
|
||||
},
|
||||
});
|
||||
|
||||
const request = FakeXMLHttpRequest.latest;
|
||||
assert.ok(request);
|
||||
assert.equal(request.method, 'POST');
|
||||
assert.equal(request.url, '/api/files/upload?path=%2F');
|
||||
assert.equal(request.headers.get('authorization'), 'Bearer token-456');
|
||||
assert.equal(request.headers.get('accept'), 'application/json');
|
||||
assert.equal(request.requestBody, formData);
|
||||
|
||||
request.triggerProgress(128, 512);
|
||||
request.triggerProgress(512, 512);
|
||||
request.respond({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
id: 7,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = await uploadPromise;
|
||||
assert.deepEqual(payload, {id: 7});
|
||||
assert.deepEqual(progressCalls, [
|
||||
{loaded: 128, total: 512},
|
||||
{loaded: 512, total: 512},
|
||||
]);
|
||||
});
|
||||
|
||||
test('apiBinaryUploadRequest sends raw file body to signed upload url', async () => {
|
||||
const progressCalls: Array<{loaded: number; total: number}> = [];
|
||||
const fileBody = new Blob(['hello-oss']);
|
||||
|
||||
const uploadPromise = apiBinaryUploadRequest('https://upload.example.com/object', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'x-oss-meta-test': '1',
|
||||
},
|
||||
body: fileBody,
|
||||
onProgress: (progress) => {
|
||||
progressCalls.push(progress);
|
||||
},
|
||||
});
|
||||
|
||||
const request = FakeXMLHttpRequest.latest;
|
||||
assert.ok(request);
|
||||
assert.equal(request.method, 'PUT');
|
||||
assert.equal(request.url, 'https://upload.example.com/object');
|
||||
assert.equal(request.headers.get('content-type'), 'text/plain');
|
||||
assert.equal(request.headers.get('x-oss-meta-test'), '1');
|
||||
assert.equal(request.requestBody, fileBody);
|
||||
|
||||
request.triggerProgress(64, 128);
|
||||
request.triggerProgress(128, 128);
|
||||
request.respond('', 200, 'text/plain');
|
||||
|
||||
await uploadPromise;
|
||||
assert.deepEqual(progressCalls, [
|
||||
{loaded: 64, total: 128},
|
||||
{loaded: 128, total: 128},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,20 @@ interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
interface ApiUploadRequestInit {
|
||||
body: FormData;
|
||||
headers?: HeadersInit;
|
||||
method?: 'POST' | 'PUT' | 'PATCH';
|
||||
onProgress?: (progress: {loaded: number; total: number}) => void;
|
||||
}
|
||||
|
||||
interface ApiBinaryUploadRequestInit {
|
||||
body: Blob;
|
||||
headers?: HeadersInit;
|
||||
method?: 'PUT' | 'POST';
|
||||
onProgress?: (progress: {loaded: number; total: number}) => void;
|
||||
}
|
||||
|
||||
const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, '');
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -48,6 +62,10 @@ function getMaxRetryAttempts(path: string, init: ApiRequestInit = {}) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (method === 'PATCH' && /^\/files\/\d+\/rename$/.test(path)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
|
||||
return 2;
|
||||
}
|
||||
@@ -191,6 +209,116 @@ export async function apiRequest<T>(path: string, init?: ApiRequestInit) {
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
export function apiUploadRequest<T>(path: string, init: ApiUploadRequestInit) {
|
||||
const session = readStoredSession();
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
if (session?.token) {
|
||||
headers.set('Authorization', `Bearer ${session.token}`);
|
||||
}
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json');
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(init.method || 'POST', resolveUrl(path));
|
||||
|
||||
headers.forEach((value, key) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
|
||||
if (init.onProgress) {
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (!event.lengthComputable) {
|
||||
return;
|
||||
}
|
||||
|
||||
init.onProgress?.({
|
||||
loaded: event.loaded,
|
||||
total: event.total,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(toNetworkApiError(new TypeError('Failed to fetch')));
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
const contentType = xhr.getResponseHeader('content-type') || '';
|
||||
|
||||
if (xhr.status === 401 || xhr.status === 403) {
|
||||
clearStoredSession();
|
||||
}
|
||||
|
||||
if (!contentType.includes('application/json')) {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(undefined as T);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new ApiError(`请求失败 (${xhr.status})`, xhr.status));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(xhr.responseText) as ApiEnvelope<T>;
|
||||
if (xhr.status < 200 || xhr.status >= 300 || payload.code !== 0) {
|
||||
if (xhr.status === 401 || payload.code === 401) {
|
||||
clearStoredSession();
|
||||
}
|
||||
reject(new ApiError(payload.msg || `请求失败 (${xhr.status})`, xhr.status, payload.code));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(payload.data);
|
||||
};
|
||||
|
||||
xhr.send(init.body);
|
||||
});
|
||||
}
|
||||
|
||||
export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadRequestInit) {
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(init.method || 'PUT', resolveUrl(path));
|
||||
|
||||
headers.forEach((value, key) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
|
||||
if (init.onProgress) {
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (!event.lengthComputable) {
|
||||
return;
|
||||
}
|
||||
|
||||
init.onProgress?.({
|
||||
loaded: event.loaded,
|
||||
total: event.total,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(toNetworkApiError(new TypeError('Failed to fetch')));
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new ApiError(`请求失败 (${xhr.status})`, xhr.status));
|
||||
};
|
||||
|
||||
xhr.send(init.body);
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiDownload(path: string) {
|
||||
const response = await performRequest(path, {
|
||||
headers: {
|
||||
|
||||
@@ -32,6 +32,18 @@ export interface FileMetadata {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface InitiateUploadResponse {
|
||||
direct: boolean;
|
||||
uploadUrl: string;
|
||||
method: 'POST' | 'PUT';
|
||||
headers: Record<string, string>;
|
||||
storageName: string;
|
||||
}
|
||||
|
||||
export interface DownloadUrlResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CourseResponse {
|
||||
courseName: string;
|
||||
teacher: string | null;
|
||||
|
||||
@@ -1,27 +1,55 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Download,
|
||||
Monitor,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
FileUp,
|
||||
Upload,
|
||||
UploadCloud,
|
||||
Plus,
|
||||
LayoutGrid,
|
||||
List,
|
||||
MoreVertical,
|
||||
TriangleAlert,
|
||||
X,
|
||||
Edit2,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
|
||||
import { apiDownload, apiRequest } from '@/src/lib/api';
|
||||
import { Input } from '@/src/components/ui/input';
|
||||
import { ApiError, apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
|
||||
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
|
||||
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
|
||||
import type { FileMetadata, PageResponse } from '@/src/lib/types';
|
||||
import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
|
||||
import {
|
||||
buildUploadProgressSnapshot,
|
||||
completeUploadTask,
|
||||
createUploadTask,
|
||||
failUploadTask,
|
||||
prepareUploadFile,
|
||||
type UploadMeasurement,
|
||||
type UploadTask,
|
||||
} from './files-upload';
|
||||
import {
|
||||
clearSelectionIfDeleted,
|
||||
getNextAvailableName,
|
||||
getActionErrorMessage,
|
||||
removeUiFile,
|
||||
replaceUiFile,
|
||||
syncSelectedFile,
|
||||
} from './files-state';
|
||||
|
||||
const QUICK_ACCESS = [
|
||||
{ name: '桌面', icon: Monitor, path: [] as string[] },
|
||||
{ name: '下载', icon: Download, path: ['下载'] },
|
||||
@@ -81,13 +109,28 @@ function toUiFile(file: FileMetadata) {
|
||||
};
|
||||
}
|
||||
|
||||
type UiFile = ReturnType<typeof toUiFile>;
|
||||
|
||||
export default function Files() {
|
||||
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
|
||||
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const uploadMeasurementsRef = useRef(new Map<string, UploadMeasurement>());
|
||||
const [currentPath, setCurrentPath] = useState<string[]>(initialPath);
|
||||
const [selectedFile, setSelectedFile] = useState<any | null>(null);
|
||||
const [currentFiles, setCurrentFiles] = useState<any[]>(initialCachedFiles.map(toUiFile));
|
||||
const currentPathRef = useRef(currentPath);
|
||||
const [selectedFile, setSelectedFile] = useState<UiFile | null>(null);
|
||||
const [currentFiles, setCurrentFiles] = useState<UiFile[]>(initialCachedFiles.map(toUiFile));
|
||||
const [uploads, setUploads] = useState<UploadTask[]>([]);
|
||||
const [isUploadPanelOpen, setIsUploadPanelOpen] = useState(true);
|
||||
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [fileToRename, setFileToRename] = useState<UiFile | null>(null);
|
||||
const [fileToDelete, setFileToDelete] = useState<UiFile | null>(null);
|
||||
const [newFileName, setNewFileName] = useState('');
|
||||
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
|
||||
const [renameError, setRenameError] = useState('');
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
|
||||
const loadCurrentPath = async (pathParts: string[]) => {
|
||||
const response = await apiRequest<PageResponse<FileMetadata>>(
|
||||
@@ -99,6 +142,7 @@ export default function Files() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
currentPathRef.current = currentPath;
|
||||
const cachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(currentPath)));
|
||||
writeCachedValue(getFilesLastPathCacheKey(), currentPath);
|
||||
|
||||
@@ -116,9 +160,10 @@ export default function Files() {
|
||||
const handleSidebarClick = (pathParts: string[]) => {
|
||||
setCurrentPath(pathParts);
|
||||
setSelectedFile(null);
|
||||
setActiveDropdown(null);
|
||||
};
|
||||
|
||||
const handleFolderDoubleClick = (file: any) => {
|
||||
const handleFolderDoubleClick = (file: UiFile) => {
|
||||
if (file.type === 'folder') {
|
||||
setCurrentPath([...currentPath, file.name]);
|
||||
setSelectedFile(null);
|
||||
@@ -128,6 +173,19 @@ export default function Files() {
|
||||
const handleBreadcrumbClick = (index: number) => {
|
||||
setCurrentPath(currentPath.slice(0, index + 1));
|
||||
setSelectedFile(null);
|
||||
setActiveDropdown(null);
|
||||
};
|
||||
|
||||
const openRenameModal = (file: UiFile) => {
|
||||
setFileToRename(file);
|
||||
setNewFileName(file.name);
|
||||
setRenameError('');
|
||||
setRenameModalOpen(true);
|
||||
};
|
||||
|
||||
const openDeleteModal = (file: UiFile) => {
|
||||
setFileToDelete(file);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
@@ -135,21 +193,134 @@ export default function Files() {
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
|
||||
event.target.value = '';
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const uploadPathParts = [...currentPath];
|
||||
const uploadPath = toBackendPath(uploadPathParts);
|
||||
const reservedNames = new Set<string>(currentFiles.map((file) => file.name));
|
||||
setIsUploadPanelOpen(true);
|
||||
|
||||
await apiRequest(`/files/upload?path=${encodeURIComponent(toBackendPath(currentPath))}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
const uploadJobs = files.map(async (file) => {
|
||||
const preparedUpload = prepareUploadFile(file, reservedNames);
|
||||
reservedNames.add(preparedUpload.file.name);
|
||||
const uploadFile = preparedUpload.file;
|
||||
const uploadTask = createUploadTask(uploadFile, uploadPathParts, undefined, preparedUpload.noticeMessage);
|
||||
setUploads((previous) => [...previous, uploadTask]);
|
||||
|
||||
try {
|
||||
const updateProgress = ({loaded, total}: {loaded: number; total: number}) => {
|
||||
const snapshot = buildUploadProgressSnapshot({
|
||||
loaded,
|
||||
total,
|
||||
now: Date.now(),
|
||||
previous: uploadMeasurementsRef.current.get(uploadTask.id),
|
||||
});
|
||||
|
||||
await loadCurrentPath(currentPath);
|
||||
event.target.value = '';
|
||||
uploadMeasurementsRef.current.set(uploadTask.id, snapshot.measurement);
|
||||
setUploads((previous) =>
|
||||
previous.map((task) =>
|
||||
task.id === uploadTask.id
|
||||
? {
|
||||
...task,
|
||||
progress: snapshot.progress,
|
||||
speed: snapshot.speed,
|
||||
}
|
||||
: task,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
let initiated: InitiateUploadResponse | null = null;
|
||||
try {
|
||||
initiated = await apiRequest<InitiateUploadResponse>('/files/upload/initiate', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
path: uploadPath,
|
||||
filename: uploadFile.name,
|
||||
contentType: uploadFile.type || null,
|
||||
size: uploadFile.size,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError && error.status === 404)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let uploadedFile: FileMetadata;
|
||||
if (initiated?.direct) {
|
||||
try {
|
||||
await apiBinaryUploadRequest(initiated.uploadUrl, {
|
||||
method: initiated.method,
|
||||
headers: initiated.headers,
|
||||
body: uploadFile,
|
||||
onProgress: updateProgress,
|
||||
});
|
||||
|
||||
uploadedFile = await apiRequest<FileMetadata>('/files/upload/complete', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
path: uploadPath,
|
||||
filename: uploadFile.name,
|
||||
storageName: initiated.storageName,
|
||||
contentType: uploadFile.type || null,
|
||||
size: uploadFile.size,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError && error.isNetworkError)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', uploadFile);
|
||||
uploadedFile = await apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(uploadPath)}`, {
|
||||
body: formData,
|
||||
onProgress: updateProgress,
|
||||
});
|
||||
}
|
||||
} else if (initiated) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', uploadFile);
|
||||
uploadedFile = await apiUploadRequest<FileMetadata>(initiated.uploadUrl, {
|
||||
body: formData,
|
||||
method: initiated.method,
|
||||
headers: initiated.headers,
|
||||
onProgress: updateProgress,
|
||||
});
|
||||
} else {
|
||||
const formData = new FormData();
|
||||
formData.append('file', uploadFile);
|
||||
uploadedFile = await apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(uploadPath)}`, {
|
||||
body: formData,
|
||||
onProgress: updateProgress,
|
||||
});
|
||||
}
|
||||
|
||||
uploadMeasurementsRef.current.delete(uploadTask.id);
|
||||
setUploads((previous) =>
|
||||
previous.map((task) => (task.id === uploadTask.id ? completeUploadTask(task) : task)),
|
||||
);
|
||||
return uploadedFile;
|
||||
} catch (error) {
|
||||
uploadMeasurementsRef.current.delete(uploadTask.id);
|
||||
const message = error instanceof Error && error.message ? error.message : '上传失败,请稍后重试';
|
||||
setUploads((previous) =>
|
||||
previous.map((task) => (task.id === uploadTask.id ? failUploadTask(task, message) : task)),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(uploadJobs);
|
||||
if (results.some(Boolean) && toBackendPath(currentPathRef.current) === uploadPath) {
|
||||
await loadCurrentPath(uploadPathParts).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
@@ -158,8 +329,17 @@ export default function Files() {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedFolderName = folderName.trim();
|
||||
const nextFolderName = getNextAvailableName(
|
||||
normalizedFolderName,
|
||||
new Set(currentFiles.filter((file) => file.type === 'folder').map((file) => file.name)),
|
||||
);
|
||||
if (nextFolderName !== normalizedFolderName) {
|
||||
window.alert(`检测到同名文件夹,已自动重命名为 ${nextFolderName}`);
|
||||
}
|
||||
|
||||
const basePath = toBackendPath(currentPath).replace(/\/$/, '');
|
||||
const fullPath = `${basePath}/${folderName.trim()}` || '/';
|
||||
const fullPath = `${basePath}/${nextFolderName}` || '/';
|
||||
|
||||
await apiRequest('/files/mkdir', {
|
||||
method: 'POST',
|
||||
@@ -174,11 +354,72 @@ export default function Files() {
|
||||
await loadCurrentPath(currentPath);
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!fileToRename || !newFileName.trim() || isRenaming) {
|
||||
return;
|
||||
}
|
||||
setIsRenaming(true);
|
||||
setRenameError('');
|
||||
|
||||
try {
|
||||
const renamedFile = await apiRequest<FileMetadata>(`/files/${fileToRename.id}/rename`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
filename: newFileName.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
const nextUiFile = toUiFile(renamedFile);
|
||||
setCurrentFiles((previous) => replaceUiFile(previous, nextUiFile));
|
||||
setSelectedFile((previous) => syncSelectedFile(previous, nextUiFile));
|
||||
setRenameModalOpen(false);
|
||||
setFileToRename(null);
|
||||
setNewFileName('');
|
||||
await loadCurrentPath(currentPath).catch(() => undefined);
|
||||
} catch (error) {
|
||||
setRenameError(getActionErrorMessage(error, '重命名失败,请稍后重试'));
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!fileToDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
await apiRequest(`/files/${fileToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
setCurrentFiles((previous) => removeUiFile(previous, fileToDelete.id));
|
||||
setSelectedFile((previous) => clearSelectionIfDeleted(previous, fileToDelete.id));
|
||||
setDeleteModalOpen(false);
|
||||
setFileToDelete(null);
|
||||
await loadCurrentPath(currentPath).catch(() => undefined);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!selectedFile || selectedFile.type === 'folder') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiRequest<DownloadUrlResponse>(`/files/download/${selectedFile.id}/url`);
|
||||
const url = response.url;
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = selectedFile.name;
|
||||
link.rel = 'noreferrer';
|
||||
link.target = '_blank';
|
||||
link.click();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError && error.status === 404)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiDownload(`/files/download/${selectedFile.id}`);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
@@ -189,6 +430,11 @@ export default function Files() {
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleClearUploads = () => {
|
||||
uploadMeasurementsRef.current.clear();
|
||||
setUploads([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]">
|
||||
{/* Left Sidebar */}
|
||||
@@ -255,13 +501,35 @@ export default function Files() {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-black/20 p-1 rounded-lg">
|
||||
<button className="p-1.5 rounded-md bg-white/10 text-white"><List className="w-4 h-4" /></button>
|
||||
<button className="p-1.5 rounded-md text-slate-400 hover:text-white"><LayoutGrid className="w-4 h-4" /></button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-md transition-colors',
|
||||
viewMode === 'list' ? 'bg-white/10 text-white' : 'text-slate-400 hover:text-white',
|
||||
)}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-md transition-colors',
|
||||
viewMode === 'grid' ? 'bg-white/10 text-white' : 'text-slate-400 hover:text-white',
|
||||
)}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{currentFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center space-y-3 py-12 text-slate-500">
|
||||
<Folder className="w-12 h-12 opacity-20" />
|
||||
<p className="text-sm">此文件夹为空</p>
|
||||
</div>
|
||||
) : viewMode === 'list' ? (
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="text-xs font-semibold text-slate-500 uppercase tracking-wider border-b border-white/5">
|
||||
@@ -273,15 +541,14 @@ export default function Files() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentFiles.length > 0 ? (
|
||||
currentFiles.map((file) => (
|
||||
{currentFiles.map((file) => (
|
||||
<tr
|
||||
key={file.id}
|
||||
onClick={() => setSelectedFile(file)}
|
||||
onDoubleClick={() => handleFolderDoubleClick(file)}
|
||||
className={cn(
|
||||
'group cursor-pointer transition-colors border-b border-white/5 last:border-0',
|
||||
selectedFile?.id === file.id ? 'bg-[#336EFF]/10' : 'hover:bg-white/[0.02]'
|
||||
selectedFile?.id === file.id ? 'bg-[#336EFF]/10' : 'hover:bg-white/[0.02]',
|
||||
)}
|
||||
>
|
||||
<td className="py-3 pl-4">
|
||||
@@ -302,24 +569,64 @@ export default function Files() {
|
||||
<td className="py-3 text-sm text-slate-400 hidden lg:table-cell uppercase">{file.type}</td>
|
||||
<td className="py-3 text-sm text-slate-400 font-mono">{file.size}</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
<button className="p-1.5 rounded-md text-slate-500 opacity-0 group-hover:opacity-100 hover:bg-white/10 hover:text-white transition-all">
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
<FileActionMenu
|
||||
file={file}
|
||||
activeDropdown={activeDropdown}
|
||||
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
|
||||
onRename={openRenameModal}
|
||||
onDelete={openDeleteModal}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-12 text-center text-slate-500">
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<Folder className="w-12 h-12 opacity-20" />
|
||||
<p className="text-sm">此文件夹为空</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{currentFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
onClick={() => setSelectedFile(file)}
|
||||
onDoubleClick={() => handleFolderDoubleClick(file)}
|
||||
className={cn(
|
||||
'group relative flex cursor-pointer flex-col items-center rounded-xl border p-4 transition-all',
|
||||
selectedFile?.id === file.id
|
||||
? 'border-[#336EFF]/30 bg-[#336EFF]/10'
|
||||
: 'border-white/5 bg-white/[0.02] hover:border-white/10 hover:bg-white/[0.04]',
|
||||
)}
|
||||
>
|
||||
<div className="absolute right-2 top-2">
|
||||
<FileActionMenu
|
||||
file={file}
|
||||
activeDropdown={activeDropdown}
|
||||
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
|
||||
onRename={openRenameModal}
|
||||
onDelete={openDeleteModal}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5 transition-colors group-hover:bg-white/10">
|
||||
{file.type === 'folder' ? (
|
||||
<Folder className="w-8 h-8 text-[#336EFF]" />
|
||||
) : file.type === 'image' ? (
|
||||
<ImageIcon className="w-8 h-8 text-purple-400" />
|
||||
) : (
|
||||
<FileText className="w-8 h-8 text-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={cn('w-full truncate px-2 text-center text-sm font-medium', selectedFile?.id === file.id ? 'text-[#336EFF]' : 'text-slate-200')}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-slate-500">
|
||||
{file.type === 'folder' ? file.modified : file.size}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
@@ -330,7 +637,7 @@ export default function Files() {
|
||||
<Button variant="outline" className="gap-2" onClick={handleCreateFolder}>
|
||||
<Plus className="w-4 h-4" /> 新建文件夹
|
||||
</Button>
|
||||
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileChange} />
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileChange} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -366,20 +673,255 @@ export default function Files() {
|
||||
<DetailItem label="类型" value={selectedFile.type.toUpperCase()} />
|
||||
</div>
|
||||
|
||||
<div className="pt-4 space-y-3 border-t border-white/10">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => openRenameModal(selectedFile)}>
|
||||
<Edit2 className="w-4 h-4" /> 重命名
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 border-red-500/20 bg-red-500/5 text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
onClick={() => openDeleteModal(selectedFile)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> 删除
|
||||
</Button>
|
||||
</div>
|
||||
{selectedFile.type !== 'folder' && (
|
||||
<Button variant="outline" className="w-full gap-2 mt-4" onClick={handleDownload}>
|
||||
<Button variant="default" className="w-full gap-2" onClick={handleDownload}>
|
||||
<Download className="w-4 h-4" /> 下载文件
|
||||
</Button>
|
||||
)}
|
||||
{selectedFile.type === 'folder' && (
|
||||
<Button variant="default" className="w-full gap-2 mt-4" onClick={() => handleFolderDoubleClick(selectedFile)}>
|
||||
<Button variant="default" className="w-full gap-2" onClick={() => handleFolderDoubleClick(selectedFile)}>
|
||||
打开文件夹
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{uploads.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||
className="fixed bottom-6 right-6 z-50 flex w-[min(24rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border border-white/10 bg-[#0f172a]/95 shadow-2xl backdrop-blur-xl"
|
||||
>
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between border-b border-white/10 bg-white/5 px-4 py-3 transition-colors hover:bg-white/10"
|
||||
onClick={() => setIsUploadPanelOpen((previous) => !previous)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<UploadCloud className="h-4 w-4 text-[#336EFF]" />
|
||||
<span className="text-sm font-medium text-white">
|
||||
上传进度 ({uploads.filter((task) => task.status === 'completed').length}/{uploads.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button className="rounded p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white">
|
||||
{isUploadPanelOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleClearUploads();
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isUploadPanelOpen && (
|
||||
<motion.div initial={{ height: 0 }} animate={{ height: 'auto' }} exit={{ height: 0 }} className="max-h-80 overflow-y-auto">
|
||||
<div className="space-y-1 p-2">
|
||||
{uploads.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className={cn(
|
||||
'group relative overflow-hidden rounded-lg p-3 transition-colors hover:bg-white/5',
|
||||
task.status === 'error' && 'bg-rose-500/5',
|
||||
)}
|
||||
>
|
||||
{task.status === 'uploading' && (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-[#336EFF]/10 transition-all duration-300 ease-out"
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
{task.status === 'completed' ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
) : task.status === 'error' ? (
|
||||
<TriangleAlert className="h-5 w-5 text-rose-400" />
|
||||
) : (
|
||||
<FileUp className="h-5 w-5 animate-pulse text-[#336EFF]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-200">{task.fileName}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-slate-500">上传至: {task.destination}</p>
|
||||
{task.noticeMessage && (
|
||||
<p className="mt-2 truncate text-xs text-amber-300">{task.noticeMessage}</p>
|
||||
)}
|
||||
|
||||
{task.status === 'uploading' && (
|
||||
<div className="mt-2 flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-[#336EFF]">{Math.round(task.progress)}%</span>
|
||||
<span className="font-mono text-slate-400">{task.speed}</span>
|
||||
</div>
|
||||
)}
|
||||
{task.status === 'completed' && (
|
||||
<p className="mt-2 text-xs text-emerald-400">上传完成</p>
|
||||
)}
|
||||
{task.status === 'error' && (
|
||||
<p className="mt-2 truncate text-xs text-rose-400">{task.errorMessage ?? '上传失败,请稍后重试'}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{renameModalOpen && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="w-full max-w-sm overflow-hidden rounded-xl border border-white/10 bg-[#0f172a] shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 p-4">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold text-white">
|
||||
<Edit2 className="w-5 h-5 text-[#336EFF]" />
|
||||
重命名
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setRenameModalOpen(false);
|
||||
setFileToRename(null);
|
||||
setRenameError('');
|
||||
}}
|
||||
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-5 p-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">新名称</label>
|
||||
<Input
|
||||
value={newFileName}
|
||||
onChange={(event) => setNewFileName(event.target.value)}
|
||||
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
|
||||
autoFocus
|
||||
disabled={isRenaming}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !isRenaming) {
|
||||
void handleRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{renameError && (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/10 p-3 text-sm text-red-400">
|
||||
{renameError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setRenameModalOpen(false);
|
||||
setFileToRename(null);
|
||||
setRenameError('');
|
||||
}}
|
||||
disabled={isRenaming}
|
||||
className="border-white/10 text-slate-300 hover:bg-white/10"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => void handleRename()} disabled={isRenaming}>
|
||||
{isRenaming ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/20 border-t-white" />
|
||||
重命名中...
|
||||
</span>
|
||||
) : (
|
||||
'确定'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteModalOpen && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="w-full max-w-sm overflow-hidden rounded-xl border border-white/10 bg-[#0f172a] shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 p-4">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold text-white">
|
||||
<Trash2 className="w-5 h-5 text-red-500" />
|
||||
确认删除
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeleteModalOpen(false);
|
||||
setFileToDelete(null);
|
||||
}}
|
||||
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-5 p-5">
|
||||
<p className="text-sm leading-relaxed text-slate-300">
|
||||
确定要删除 <span className="rounded bg-white/10 px-1 py-0.5 font-medium text-white">{fileToDelete?.name}</span> 吗?此操作无法撤销。
|
||||
</p>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteModalOpen(false);
|
||||
setFileToDelete(null);
|
||||
}}
|
||||
className="border-white/10 text-slate-300 hover:bg-white/10"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-500/30 bg-red-500 text-white hover:bg-red-600"
|
||||
onClick={() => void handleDelete()}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -392,3 +934,74 @@ function DetailItem({ label, value }: { label: string; value: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileActionMenu({
|
||||
file,
|
||||
activeDropdown,
|
||||
onToggle,
|
||||
onRename,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: {
|
||||
file: UiFile;
|
||||
activeDropdown: number | null;
|
||||
onToggle: (fileId: number) => void;
|
||||
onRename: (file: UiFile) => void;
|
||||
onDelete: (file: UiFile) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative inline-block text-left">
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggle(file.id);
|
||||
}}
|
||||
className="rounded-md p-1.5 text-slate-500 opacity-0 transition-all hover:bg-white/10 hover:text-white group-hover:opacity-100"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
{activeDropdown === file.id && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{activeDropdown === file.id && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute right-0 top-full z-50 mt-1 w-32 overflow-hidden rounded-lg border border-white/10 bg-[#1e293b] py-1 shadow-xl"
|
||||
>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRename(file);
|
||||
onClose();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" /> 重命名
|
||||
</button>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(file);
|
||||
onClose();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-red-400 transition-colors hover:bg-red-500/10 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> 删除
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
77
front/src/pages/files-state.test.ts
Normal file
77
front/src/pages/files-state.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
clearSelectionIfDeleted,
|
||||
getActionErrorMessage,
|
||||
getNextAvailableName,
|
||||
removeUiFile,
|
||||
replaceUiFile,
|
||||
syncSelectedFile,
|
||||
} from './files-state';
|
||||
|
||||
const files = [
|
||||
{id: 1, name: 'notes.txt', type: 'txt', size: '2 KB', modified: '2026/03/18 10:00'},
|
||||
{id: 2, name: 'photos', type: 'folder', size: '—', modified: '2026/03/18 09:00'},
|
||||
];
|
||||
|
||||
test('replaceUiFile updates the matching file only', () => {
|
||||
const nextFiles = replaceUiFile(files, {
|
||||
id: 2,
|
||||
name: 'photos-2026',
|
||||
type: 'folder',
|
||||
size: '—',
|
||||
modified: '2026/03/18 09:00',
|
||||
});
|
||||
|
||||
assert.deepEqual(nextFiles, [
|
||||
files[0],
|
||||
{
|
||||
id: 2,
|
||||
name: 'photos-2026',
|
||||
type: 'folder',
|
||||
size: '—',
|
||||
modified: '2026/03/18 09:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('removeUiFile drops the deleted file from the current list', () => {
|
||||
assert.deepEqual(removeUiFile(files, 1), [files[1]]);
|
||||
});
|
||||
|
||||
test('syncSelectedFile keeps details sidebar in sync after rename', () => {
|
||||
const selectedFile = files[1];
|
||||
const renamedFile = {
|
||||
...selectedFile,
|
||||
name: 'photos-2026',
|
||||
};
|
||||
|
||||
assert.deepEqual(syncSelectedFile(selectedFile, renamedFile), renamedFile);
|
||||
assert.equal(syncSelectedFile(files[0], renamedFile), files[0]);
|
||||
});
|
||||
|
||||
test('clearSelectionIfDeleted removes details selection for deleted file', () => {
|
||||
assert.equal(clearSelectionIfDeleted(files[0], 1), null);
|
||||
assert.equal(clearSelectionIfDeleted(files[1], 1), files[1]);
|
||||
});
|
||||
|
||||
test('getActionErrorMessage uses backend message when present', () => {
|
||||
assert.equal(getActionErrorMessage(new Error('重命名失败:同名文件已存在'), '重命名失败,请稍后重试'), '重命名失败:同名文件已存在');
|
||||
assert.equal(getActionErrorMessage(null, '重命名失败,请稍后重试'), '重命名失败,请稍后重试');
|
||||
});
|
||||
|
||||
test('getNextAvailableName appends an incrementing suffix for duplicate folder names', () => {
|
||||
assert.equal(
|
||||
getNextAvailableName('新建文件夹', new Set(['新建文件夹'])),
|
||||
'新建文件夹 (1)',
|
||||
);
|
||||
assert.equal(
|
||||
getNextAvailableName('新建文件夹', new Set(['新建文件夹', '新建文件夹 (1)', '新建文件夹 (2)'])),
|
||||
'新建文件夹 (3)',
|
||||
);
|
||||
});
|
||||
|
||||
test('getNextAvailableName keeps the original name when no duplicate exists', () => {
|
||||
assert.equal(getNextAvailableName('课程资料', new Set(['实验数据', '下载'])), '课程资料');
|
||||
});
|
||||
55
front/src/pages/files-state.ts
Normal file
55
front/src/pages/files-state.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export interface FilesUiItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
size: string;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
export function getNextAvailableName(name: string, existingNames: Set<string>) {
|
||||
if (!existingNames.has(name)) {
|
||||
return name;
|
||||
}
|
||||
|
||||
let index = 1;
|
||||
let nextName = `${name} (${index})`;
|
||||
|
||||
while (existingNames.has(nextName)) {
|
||||
index += 1;
|
||||
nextName = `${name} (${index})`;
|
||||
}
|
||||
|
||||
return nextName;
|
||||
}
|
||||
|
||||
export function replaceUiFile<T extends FilesUiItem>(files: T[], nextFile: T) {
|
||||
return files.map((file) => (file.id === nextFile.id ? nextFile : file));
|
||||
}
|
||||
|
||||
export function removeUiFile<T extends FilesUiItem>(files: T[], fileId: number) {
|
||||
return files.filter((file) => file.id !== fileId);
|
||||
}
|
||||
|
||||
export function syncSelectedFile<T extends FilesUiItem>(selectedFile: T | null, nextFile: T) {
|
||||
if (!selectedFile || selectedFile.id !== nextFile.id) {
|
||||
return selectedFile;
|
||||
}
|
||||
|
||||
return nextFile;
|
||||
}
|
||||
|
||||
export function clearSelectionIfDeleted<T extends FilesUiItem>(selectedFile: T | null, fileId: number) {
|
||||
if (!selectedFile || selectedFile.id !== fileId) {
|
||||
return selectedFile;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getActionErrorMessage(error: unknown, fallbackMessage: string) {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallbackMessage;
|
||||
}
|
||||
97
front/src/pages/files-upload.test.ts
Normal file
97
front/src/pages/files-upload.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildUploadProgressSnapshot,
|
||||
completeUploadTask,
|
||||
createUploadTask,
|
||||
formatTransferSpeed,
|
||||
prepareUploadFile,
|
||||
} from './files-upload';
|
||||
|
||||
test('createUploadTask uses current path as upload destination', () => {
|
||||
const task = createUploadTask(new File(['hello'], 'notes.md', {type: 'text/markdown'}), ['文档', '课程资料'], 'task-1');
|
||||
|
||||
assert.equal(task.id, 'task-1');
|
||||
assert.equal(task.fileName, 'notes.md');
|
||||
assert.equal(task.destination, '/文档/课程资料');
|
||||
assert.equal(task.progress, 0);
|
||||
assert.equal(task.status, 'uploading');
|
||||
assert.equal(task.speed, '等待上传...');
|
||||
});
|
||||
|
||||
test('formatTransferSpeed chooses a readable unit', () => {
|
||||
assert.equal(formatTransferSpeed(800), '800 B/s');
|
||||
assert.equal(formatTransferSpeed(2048), '2.0 KB/s');
|
||||
assert.equal(formatTransferSpeed(3.5 * 1024 * 1024), '3.5 MB/s');
|
||||
});
|
||||
|
||||
test('buildUploadProgressSnapshot derives progress and speed from bytes transferred', () => {
|
||||
const firstSnapshot = buildUploadProgressSnapshot({
|
||||
loaded: 1024,
|
||||
total: 4096,
|
||||
now: 1_000,
|
||||
});
|
||||
|
||||
assert.equal(firstSnapshot.progress, 25);
|
||||
assert.equal(firstSnapshot.speed, '1.0 KB/s');
|
||||
|
||||
const nextSnapshot = buildUploadProgressSnapshot({
|
||||
loaded: 3072,
|
||||
total: 4096,
|
||||
now: 2_000,
|
||||
previous: firstSnapshot.measurement,
|
||||
});
|
||||
|
||||
assert.equal(nextSnapshot.progress, 75);
|
||||
assert.equal(nextSnapshot.speed, '2.0 KB/s');
|
||||
});
|
||||
|
||||
test('buildUploadProgressSnapshot keeps progress below 100 until request completes', () => {
|
||||
const snapshot = buildUploadProgressSnapshot({
|
||||
loaded: 4096,
|
||||
total: 4096,
|
||||
now: 1_500,
|
||||
});
|
||||
|
||||
assert.equal(snapshot.progress, 99);
|
||||
});
|
||||
|
||||
test('completeUploadTask marks upload as completed', () => {
|
||||
const task = createUploadTask(new File(['hello'], 'photo.png', {type: 'image/png'}), [], 'task-2');
|
||||
|
||||
const nextTask = completeUploadTask(task);
|
||||
|
||||
assert.equal(nextTask.destination, '/');
|
||||
assert.equal(nextTask.progress, 100);
|
||||
assert.equal(nextTask.status, 'completed');
|
||||
assert.equal(nextTask.speed, '');
|
||||
});
|
||||
|
||||
test('prepareUploadFile appends an incrementing suffix when the same file name already exists', () => {
|
||||
const firstDuplicate = prepareUploadFile(
|
||||
new File(['hello'], 'notes.md', {type: 'text/markdown'}),
|
||||
new Set(['notes.md']),
|
||||
);
|
||||
|
||||
assert.equal(firstDuplicate.file.name, 'notes (1).md');
|
||||
assert.equal(firstDuplicate.noticeMessage, '检测到同名文件,已自动重命名为 notes (1).md');
|
||||
|
||||
const secondDuplicate = prepareUploadFile(
|
||||
new File(['hello'], 'notes.md', {type: 'text/markdown'}),
|
||||
new Set(['notes.md', 'notes (1).md']),
|
||||
);
|
||||
|
||||
assert.equal(secondDuplicate.file.name, 'notes (2).md');
|
||||
assert.equal(secondDuplicate.noticeMessage, '检测到同名文件,已自动重命名为 notes (2).md');
|
||||
});
|
||||
|
||||
test('prepareUploadFile keeps files without conflicts unchanged', () => {
|
||||
const prepared = prepareUploadFile(
|
||||
new File(['hello'], 'syllabus', {type: 'text/plain'}),
|
||||
new Set(['notes.md']),
|
||||
);
|
||||
|
||||
assert.equal(prepared.file.name, 'syllabus');
|
||||
assert.equal(prepared.noticeMessage, undefined);
|
||||
});
|
||||
185
front/src/pages/files-upload.ts
Normal file
185
front/src/pages/files-upload.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
export type UploadTaskStatus = 'uploading' | 'completed' | 'error';
|
||||
|
||||
export interface UploadTask {
|
||||
id: string;
|
||||
fileName: string;
|
||||
progress: number;
|
||||
speed: string;
|
||||
destination: string;
|
||||
status: UploadTaskStatus;
|
||||
type: string;
|
||||
errorMessage?: string;
|
||||
noticeMessage?: string;
|
||||
}
|
||||
|
||||
export interface UploadMeasurement {
|
||||
startedAt: number;
|
||||
lastLoaded: number;
|
||||
lastUpdatedAt: number;
|
||||
}
|
||||
|
||||
function getUploadType(file: File) {
|
||||
const extension = file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() : '';
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
return 'image';
|
||||
}
|
||||
if (file.type.includes('pdf') || extension === 'pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
if (extension === 'doc' || extension === 'docx') {
|
||||
return 'word';
|
||||
}
|
||||
if (extension === 'xls' || extension === 'xlsx' || extension === 'csv') {
|
||||
return 'excel';
|
||||
}
|
||||
|
||||
return extension || 'document';
|
||||
}
|
||||
|
||||
function createTaskId() {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function splitFileName(fileName: string) {
|
||||
const lastDotIndex = fileName.lastIndexOf('.');
|
||||
if (lastDotIndex <= 0) {
|
||||
return {
|
||||
stem: fileName,
|
||||
extension: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stem: fileName.slice(0, lastDotIndex),
|
||||
extension: fileName.slice(lastDotIndex),
|
||||
};
|
||||
}
|
||||
|
||||
export function getUploadDestination(pathParts: string[]) {
|
||||
return pathParts.length === 0 ? '/' : `/${pathParts.join('/')}`;
|
||||
}
|
||||
|
||||
export function prepareUploadFile(file: File, usedNames: Set<string>) {
|
||||
if (!usedNames.has(file.name)) {
|
||||
return {
|
||||
file,
|
||||
noticeMessage: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const {stem, extension} = splitFileName(file.name);
|
||||
let index = 1;
|
||||
let nextName = `${stem} (${index})${extension}`;
|
||||
|
||||
while (usedNames.has(nextName)) {
|
||||
index += 1;
|
||||
nextName = `${stem} (${index})${extension}`;
|
||||
}
|
||||
|
||||
return {
|
||||
file: new File([file], nextName, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
}),
|
||||
noticeMessage: `检测到同名文件,已自动重命名为 ${nextName}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function createUploadTask(
|
||||
file: File,
|
||||
pathParts: string[],
|
||||
taskId: string = createTaskId(),
|
||||
noticeMessage?: string,
|
||||
): UploadTask {
|
||||
return {
|
||||
id: taskId,
|
||||
fileName: file.name,
|
||||
progress: 0,
|
||||
speed: '等待上传...',
|
||||
destination: getUploadDestination(pathParts),
|
||||
status: 'uploading',
|
||||
type: getUploadType(file),
|
||||
noticeMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTransferSpeed(bytesPerSecond: number) {
|
||||
if (bytesPerSecond < 1024) {
|
||||
return `${Math.round(bytesPerSecond)} B/s`;
|
||||
}
|
||||
|
||||
const units = ['KB/s', 'MB/s', 'GB/s'];
|
||||
let value = bytesPerSecond / 1024;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function buildUploadProgressSnapshot({
|
||||
loaded,
|
||||
total,
|
||||
now,
|
||||
previous,
|
||||
}: {
|
||||
loaded: number;
|
||||
total: number;
|
||||
now: number;
|
||||
previous?: UploadMeasurement;
|
||||
}) {
|
||||
const safeTotal = total > 0 ? total : loaded;
|
||||
const rawProgress = safeTotal > 0 ? Math.round((loaded / safeTotal) * 100) : 0;
|
||||
const progress = Math.min(loaded >= safeTotal ? 99 : rawProgress, 99);
|
||||
|
||||
const measurement: UploadMeasurement = previous
|
||||
? {
|
||||
startedAt: previous.startedAt,
|
||||
lastLoaded: loaded,
|
||||
lastUpdatedAt: now,
|
||||
}
|
||||
: {
|
||||
startedAt: now,
|
||||
lastLoaded: loaded,
|
||||
lastUpdatedAt: now,
|
||||
};
|
||||
|
||||
let bytesPerSecond = 0;
|
||||
|
||||
if (previous) {
|
||||
const bytesDelta = Math.max(0, loaded - previous.lastLoaded);
|
||||
const timeDelta = Math.max(1, now - previous.lastUpdatedAt);
|
||||
bytesPerSecond = (bytesDelta * 1000) / timeDelta;
|
||||
} else if (loaded > 0) {
|
||||
bytesPerSecond = loaded;
|
||||
}
|
||||
|
||||
return {
|
||||
progress,
|
||||
speed: formatTransferSpeed(bytesPerSecond),
|
||||
measurement,
|
||||
};
|
||||
}
|
||||
|
||||
export function completeUploadTask(task: UploadTask): UploadTask {
|
||||
return {
|
||||
...task,
|
||||
progress: 100,
|
||||
speed: '',
|
||||
status: 'completed',
|
||||
errorMessage: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function failUploadTask(task: UploadTask, errorMessage: string): UploadTask {
|
||||
return {
|
||||
...task,
|
||||
speed: '',
|
||||
status: 'error',
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
456
scripts/migrate-file-storage-to-oss.mjs
Normal file
456
scripts/migrate-file-storage-to-oss.mjs
Normal file
@@ -0,0 +1,456 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import {constants as fsConstants} from 'node:fs';
|
||||
import {spawn} from 'node:child_process';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import {
|
||||
normalizeEndpoint,
|
||||
parseSimpleEnv,
|
||||
encodeObjectKey,
|
||||
} from './oss-deploy-lib.mjs';
|
||||
|
||||
const DEFAULTS = {
|
||||
appEnvPath: '/opt/yoyuzh/app.env',
|
||||
storageRoot: '/opt/yoyuzh/storage',
|
||||
database: 'yoyuzh_portal',
|
||||
bucket: 'yoyuzh-files',
|
||||
endpoint: 'https://oss-ap-northeast-1.aliyuncs.com',
|
||||
};
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
dryRun: false,
|
||||
cleanupLegacy: false,
|
||||
appEnvPath: DEFAULTS.appEnvPath,
|
||||
storageRoot: DEFAULTS.storageRoot,
|
||||
database: DEFAULTS.database,
|
||||
bucket: DEFAULTS.bucket,
|
||||
};
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg === '--dry-run') {
|
||||
options.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--cleanup-legacy') {
|
||||
options.cleanupLegacy = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--app-env=')) {
|
||||
options.appEnvPath = arg.slice('--app-env='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--storage-root=')) {
|
||||
options.storageRoot = arg.slice('--storage-root='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--database=')) {
|
||||
options.database = arg.slice('--database='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--bucket=')) {
|
||||
options.bucket = arg.slice('--bucket='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function preferredObjectKey(userId, filePath, storageName) {
|
||||
const cleanPath = filePath === '/' ? '' : filePath;
|
||||
return `users/${userId}${cleanPath}/${storageName}`;
|
||||
}
|
||||
|
||||
function legacyObjectKey(userId, filePath, storageName) {
|
||||
const cleanPath = filePath === '/' ? '' : filePath;
|
||||
return `${userId}${cleanPath}/${storageName}`;
|
||||
}
|
||||
|
||||
function localFilePath(storageRoot, userId, filePath, storageName) {
|
||||
const cleanPath = filePath === '/' ? '' : filePath.slice(1);
|
||||
return path.join(storageRoot, String(userId), cleanPath, storageName);
|
||||
}
|
||||
|
||||
function archivedObjectPrefix(userId) {
|
||||
return `files/${userId}/`;
|
||||
}
|
||||
|
||||
function archivedObjectSuffix(filename) {
|
||||
return `-${filename}`;
|
||||
}
|
||||
|
||||
function runCommand(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {stdio: ['ignore', 'pipe', 'pipe']});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr || `${command} exited with code ${code}`));
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createOssAuthorizationHeader({
|
||||
method,
|
||||
bucket,
|
||||
objectKey,
|
||||
contentType,
|
||||
date,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
headers = {},
|
||||
}) {
|
||||
const canonicalizedHeaders = Object.entries(headers)
|
||||
.map(([key, value]) => [key.toLowerCase().trim(), String(value).trim()])
|
||||
.filter(([key]) => key.startsWith('x-oss-'))
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}:${value}\n`)
|
||||
.join('');
|
||||
const canonicalizedResource = `/${bucket}/${objectKey}`;
|
||||
const stringToSign = [
|
||||
method.toUpperCase(),
|
||||
'',
|
||||
contentType,
|
||||
date,
|
||||
`${canonicalizedHeaders}${canonicalizedResource}`,
|
||||
].join('\n');
|
||||
const signature = crypto
|
||||
.createHmac('sha1', accessKeySecret)
|
||||
.update(stringToSign)
|
||||
.digest('base64');
|
||||
return `OSS ${accessKeyId}:${signature}`;
|
||||
}
|
||||
|
||||
async function readAppEnv(appEnvPath) {
|
||||
const raw = await fs.readFile(appEnvPath, 'utf8');
|
||||
return parseSimpleEnv(raw);
|
||||
}
|
||||
|
||||
async function queryFiles(database) {
|
||||
const sql = [
|
||||
'SELECT user_id, path, storage_name, filename',
|
||||
'FROM portal_file',
|
||||
'WHERE is_directory = 0',
|
||||
'ORDER BY user_id, id',
|
||||
].join(' ');
|
||||
|
||||
const raw = await runCommand('sudo', [
|
||||
'mysql',
|
||||
'--batch',
|
||||
'--raw',
|
||||
'--skip-column-names',
|
||||
database,
|
||||
'-e',
|
||||
sql,
|
||||
]);
|
||||
|
||||
return raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [userId, filePath, storageName, filename] = line.split('\t');
|
||||
return {userId, filePath, storageName, filename};
|
||||
});
|
||||
}
|
||||
|
||||
function ossRequest({method, endpoint, bucket, objectKey, accessKeyId, accessKeySecret, headers = {}, query = '', body}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const normalizedEndpoint = normalizeEndpoint(endpoint);
|
||||
const date = new Date().toUTCString();
|
||||
const contentType = headers['Content-Type'] || headers['content-type'] || '';
|
||||
const auth = createOssAuthorizationHeader({
|
||||
method,
|
||||
bucket,
|
||||
objectKey,
|
||||
contentType,
|
||||
date,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
headers,
|
||||
});
|
||||
|
||||
const request = https.request({
|
||||
hostname: `${bucket}.${normalizedEndpoint}`,
|
||||
path: `/${encodeObjectKey(objectKey)}${query ? `?${query}` : ''}`,
|
||||
method,
|
||||
headers: {
|
||||
Date: date,
|
||||
Authorization: auth,
|
||||
...headers,
|
||||
},
|
||||
}, (response) => {
|
||||
let data = '';
|
||||
response.on('data', (chunk) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
response.on('end', () => {
|
||||
resolve({
|
||||
statusCode: response.statusCode ?? 500,
|
||||
headers: response.headers,
|
||||
body: data,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
|
||||
if (body) {
|
||||
body.pipe(request);
|
||||
return;
|
||||
}
|
||||
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function objectExists(context, objectKey) {
|
||||
const response = await ossRequest({
|
||||
...context,
|
||||
method: 'HEAD',
|
||||
objectKey,
|
||||
});
|
||||
return response.statusCode >= 200 && response.statusCode < 300;
|
||||
}
|
||||
|
||||
async function uploadLocalFile(context, objectKey, absolutePath, contentType = 'application/octet-stream') {
|
||||
const fileHandle = await fs.open(absolutePath, 'r');
|
||||
const stream = fileHandle.createReadStream();
|
||||
const stat = await fileHandle.stat();
|
||||
|
||||
try {
|
||||
const response = await ossRequest({
|
||||
...context,
|
||||
method: 'PUT',
|
||||
objectKey,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': String(stat.size),
|
||||
},
|
||||
body: stream,
|
||||
});
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new Error(`Upload failed for ${objectKey}: ${response.statusCode} ${response.body}`);
|
||||
}
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function copyObject(context, sourceKey, targetKey) {
|
||||
const response = await ossRequest({
|
||||
...context,
|
||||
method: 'PUT',
|
||||
objectKey: targetKey,
|
||||
headers: {
|
||||
'x-oss-copy-source': `/${context.bucket}/${encodeObjectKey(sourceKey)}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new Error(`Copy failed ${sourceKey} -> ${targetKey}: ${response.statusCode} ${response.body}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteObject(context, objectKey) {
|
||||
const response = await ossRequest({
|
||||
...context,
|
||||
method: 'DELETE',
|
||||
objectKey,
|
||||
});
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new Error(`Delete failed for ${objectKey}: ${response.statusCode} ${response.body}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function localFileExists(absolutePath) {
|
||||
try {
|
||||
await fs.access(absolutePath, fsConstants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function extractXmlValues(xml, tagName) {
|
||||
const pattern = new RegExp(`<${tagName}>(.*?)</${tagName}>`, 'g');
|
||||
return [...xml.matchAll(pattern)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
async function listObjects(context, prefix) {
|
||||
const keys = [];
|
||||
let continuationToken = '';
|
||||
|
||||
while (true) {
|
||||
const query = new URLSearchParams({
|
||||
'list-type': '2',
|
||||
'max-keys': '1000',
|
||||
prefix,
|
||||
});
|
||||
|
||||
if (continuationToken) {
|
||||
query.set('continuation-token', continuationToken);
|
||||
}
|
||||
|
||||
const response = await ossRequest({
|
||||
...context,
|
||||
method: 'GET',
|
||||
objectKey: '',
|
||||
query: query.toString(),
|
||||
});
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new Error(`List failed for prefix ${prefix}: ${response.statusCode} ${response.body}`);
|
||||
}
|
||||
|
||||
keys.push(...extractXmlValues(response.body, 'Key'));
|
||||
|
||||
const truncated = extractXmlValues(response.body, 'IsTruncated')[0] === 'true';
|
||||
continuationToken = extractXmlValues(response.body, 'NextContinuationToken')[0] || '';
|
||||
if (!truncated || !continuationToken) {
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildArchivedObjectMap(context, files) {
|
||||
const userIds = [...new Set(files.map((file) => file.userId))];
|
||||
const archivedObjectsByKey = new Map();
|
||||
|
||||
for (const userId of userIds) {
|
||||
const objects = await listObjects(context, archivedObjectPrefix(userId));
|
||||
for (const objectKey of objects) {
|
||||
const filename = objectKey.split('/').pop() ?? '';
|
||||
const match = filename.match(/^[0-9a-f-]{36}-(.+)$/i);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalFilename = match[1];
|
||||
const recordKey = `${userId}\t${originalFilename}`;
|
||||
const matches = archivedObjectsByKey.get(recordKey) ?? [];
|
||||
matches.push(objectKey);
|
||||
archivedObjectsByKey.set(recordKey, matches);
|
||||
}
|
||||
}
|
||||
|
||||
for (const matches of archivedObjectsByKey.values()) {
|
||||
matches.sort();
|
||||
}
|
||||
|
||||
return archivedObjectsByKey;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const appEnv = await readAppEnv(options.appEnvPath);
|
||||
const endpoint = appEnv.YOYUZH_OSS_ENDPOINT || DEFAULTS.endpoint;
|
||||
const bucket = options.bucket;
|
||||
const accessKeyId = appEnv.YOYUZH_OSS_ACCESS_KEY_ID;
|
||||
const accessKeySecret = appEnv.YOYUZH_OSS_ACCESS_KEY_SECRET;
|
||||
|
||||
if (!accessKeyId || !accessKeySecret) {
|
||||
throw new Error('Missing OSS credentials in app env');
|
||||
}
|
||||
|
||||
const files = await queryFiles(options.database);
|
||||
const context = {
|
||||
endpoint,
|
||||
bucket,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
};
|
||||
const archivedObjectsByKey = await buildArchivedObjectMap(context, files);
|
||||
|
||||
const summary = {
|
||||
alreadyPreferred: 0,
|
||||
migratedFromLocal: 0,
|
||||
migratedFromLegacy: 0,
|
||||
migratedFromArchivedPrefix: 0,
|
||||
deletedLegacy: 0,
|
||||
missing: 0,
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
const preferredKey = preferredObjectKey(file.userId, file.filePath, file.storageName);
|
||||
const legacyKey = legacyObjectKey(file.userId, file.filePath, file.storageName);
|
||||
const absoluteLocalPath = localFilePath(options.storageRoot, file.userId, file.filePath, file.storageName);
|
||||
|
||||
if (await objectExists(context, preferredKey)) {
|
||||
summary.alreadyPreferred += 1;
|
||||
console.log(`[skip] preferred exists: ${preferredKey}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await localFileExists(absoluteLocalPath)) {
|
||||
summary.migratedFromLocal += 1;
|
||||
console.log(`${options.dryRun ? '[dry-run]' : '[upload]'} ${absoluteLocalPath} -> ${preferredKey}`);
|
||||
if (!options.dryRun) {
|
||||
await uploadLocalFile(context, preferredKey, absoluteLocalPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await objectExists(context, legacyKey)) {
|
||||
summary.migratedFromLegacy += 1;
|
||||
console.log(`${options.dryRun ? '[dry-run]' : '[copy]'} ${legacyKey} -> ${preferredKey}`);
|
||||
if (!options.dryRun) {
|
||||
await copyObject(context, legacyKey, preferredKey);
|
||||
if (options.cleanupLegacy) {
|
||||
await deleteObject(context, legacyKey);
|
||||
summary.deletedLegacy += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const archivedRecordKey = `${file.userId}\t${file.filename}`;
|
||||
const archivedMatches = archivedObjectsByKey.get(archivedRecordKey) ?? [];
|
||||
const archivedKey = archivedMatches.shift();
|
||||
if (archivedKey) {
|
||||
summary.migratedFromArchivedPrefix += 1;
|
||||
console.log(`${options.dryRun ? '[dry-run]' : '[copy]'} ${archivedKey} -> ${preferredKey}`);
|
||||
if (!options.dryRun) {
|
||||
await copyObject(context, archivedKey, preferredKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.missing += 1;
|
||||
console.warn(`[missing] user=${file.userId} path=${file.filePath} storage=${file.storageName} filename=${file.filename}`);
|
||||
}
|
||||
|
||||
console.log('\nSummary');
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user