diff --git a/.env.oss.example b/.env.oss.example index 1ee86c3..e11054b 100644 --- a/.env.oss.example +++ b/.env.oss.example @@ -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" diff --git a/backend/README.md b/backend/README.md index 7efbb91..a4972e9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 模式下运行,但本地磁盘里没有对应文件”的历史数据,需要额外做一次对象迁移或元数据修复;否则旧记录在重命名/删除时仍可能失败。 diff --git a/backend/pom.xml b/backend/pom.xml index 636993a..590ddb5 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -65,6 +65,11 @@ mysql-connector-j runtime + + com.aliyun.oss + aliyun-sdk-oss + 3.17.4 + org.postgresql postgresql diff --git a/backend/src/main/java/com/yoyuzh/config/FileStorageConfiguration.java b/backend/src/main/java/com/yoyuzh/config/FileStorageConfiguration.java new file mode 100644 index 0000000..d792031 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/config/FileStorageConfiguration.java @@ -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); + } +} diff --git a/backend/src/main/java/com/yoyuzh/config/FileStorageProperties.java b/backend/src/main/java/com/yoyuzh/config/FileStorageProperties.java index 1350d78..9953079 100644 --- a/backend/src/main/java/com/yoyuzh/config/FileStorageProperties.java +++ b/backend/src/main/java/com/yoyuzh/config/FileStorageProperties.java @@ -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; + } + } } diff --git a/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java b/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java index 2a1e07b..5189daa 100644 --- a/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java +++ b/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java @@ -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); diff --git a/backend/src/main/java/com/yoyuzh/files/CompleteUploadRequest.java b/backend/src/main/java/com/yoyuzh/files/CompleteUploadRequest.java new file mode 100644 index 0000000..5a53bed --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/CompleteUploadRequest.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/DownloadUrlResponse.java b/backend/src/main/java/com/yoyuzh/files/DownloadUrlResponse.java new file mode 100644 index 0000000..8a86a7a --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/DownloadUrlResponse.java @@ -0,0 +1,4 @@ +package com.yoyuzh.files; + +public record DownloadUrlResponse(String url) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/FileController.java b/backend/src/main/java/com/yoyuzh/files/FileController.java index 21b0d1b..d7a45f4 100644 --- a/backend/src/main/java/com/yoyuzh/files/FileController.java +++ b/backend/src/main/java/com/yoyuzh/files/FileController.java @@ -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 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 completeUpload(@AuthenticationPrincipal UserDetails userDetails, + @Valid @RequestBody CompleteUploadRequest request) { + return ApiResponse.success(fileService.completeUpload( + userDetailsService.loadDomainUser(userDetails.getUsername()), + request + )); + } + @Operation(summary = "创建目录") @PostMapping("/mkdir") public ApiResponse mkdir(@AuthenticationPrincipal UserDetails userDetails, @@ -62,11 +84,30 @@ public class FileController { @Operation(summary = "下载文件") @GetMapping("/download/{fileId}") - public ResponseEntity download(@AuthenticationPrincipal UserDetails userDetails, - @PathVariable Long fileId) { + public ResponseEntity download(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable Long fileId) { return fileService.download(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId); } + @Operation(summary = "获取下载链接") + @GetMapping("/download/{fileId}/url") + public ApiResponse downloadUrl(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable Long fileId) { + return ApiResponse.success(fileService.getDownloadUrl( + userDetailsService.loadDomainUser(userDetails.getUsername()), + fileId + )); + } + + @Operation(summary = "重命名文件") + @PatchMapping("/{fileId}/rename") + public ApiResponse 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 delete(@AuthenticationPrincipal UserDetails userDetails, diff --git a/backend/src/main/java/com/yoyuzh/files/FileService.java b/backend/src/main/java/com/yoyuzh/files/FileService.java index 70c384c..f9abfeb 100644 --- a/backend/src/main/java/com/yoyuzh/files/FileService.java +++ b/backend/src/main/java/com/yoyuzh/files/FileService.java @@ -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 DEFAULT_DIRECTORIES = List.of("下载", "文档", "图片"); private final StoredFileRepository storedFileRepository; - private final Path rootPath; + private final FileContentStorage fileContentStorage; private final long maxFileSize; - public FileService(StoredFileRepository storedFileRepository, FileStorageProperties properties) { + public FileService(StoredFileRepository storedFileRepository, + FileContentStorage fileContentStorage, + FileStorageProperties properties) { this.storedFileRepository = storedFileRepository; - this.rootPath = Path.of(properties.getRootDir()).toAbsolutePath().normalize(); + this.fileContentStorage = fileContentStorage; this.maxFileSize = properties.getMaxFileSize(); - try { - Files.createDirectories(rootPath); - } catch (IOException ex) { - throw new IllegalStateException("无法初始化存储目录", ex); - } } @Transactional public FileMetadataResponse upload(User user, String path, MultipartFile multipartFile) { String normalizedPath = normalizeDirectoryPath(path); - String filename = StringUtils.cleanPath(multipartFile.getOriginalFilename()); - if (!StringUtils.hasText(filename)) { - throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空"); - } - if (multipartFile.getSize() > maxFileSize) { - throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制"); - } - if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedPath, filename)) { - throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在"); - } + String filename = normalizeUploadFilename(multipartFile.getOriginalFilename()); + validateUpload(user.getId(), normalizedPath, filename, multipartFile.getSize()); - Path targetDir = resolveUserPath(user.getId(), normalizedPath); - Path targetFile = targetDir.resolve(filename).normalize(); - try { - Files.createDirectories(targetDir); - Files.copy(multipartFile.getInputStream(), targetFile, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException ex) { - throw new BusinessException(ErrorCode.UNKNOWN, "文件上传失败"); - } + fileContentStorage.upload(user.getId(), normalizedPath, filename, multipartFile); + return saveFileMetadata(user, normalizedPath, filename, filename, multipartFile.getContentType(), multipartFile.getSize()); + } - StoredFile storedFile = new StoredFile(); - storedFile.setUser(user); - storedFile.setFilename(filename); - storedFile.setPath(normalizedPath); - storedFile.setStorageName(filename); - storedFile.setContentType(multipartFile.getContentType()); - storedFile.setSize(multipartFile.getSize()); - storedFile.setDirectory(false); - return toResponse(storedFileRepository.save(storedFile)); + public InitiateUploadResponse initiateUpload(User user, InitiateUploadRequest request) { + String normalizedPath = normalizeDirectoryPath(request.path()); + String filename = normalizeLeafName(request.filename()); + validateUpload(user.getId(), normalizedPath, filename, request.size()); + + PreparedUpload preparedUpload = fileContentStorage.prepareUpload( + user.getId(), + normalizedPath, + filename, + request.contentType(), + request.size() + ); + + return new InitiateUploadResponse( + preparedUpload.direct(), + preparedUpload.uploadUrl(), + preparedUpload.method(), + preparedUpload.headers(), + preparedUpload.storageName() + ); + } + + @Transactional + public FileMetadataResponse completeUpload(User user, CompleteUploadRequest request) { + String normalizedPath = normalizeDirectoryPath(request.path()); + String filename = normalizeLeafName(request.filename()); + String storageName = normalizeLeafName(request.storageName()); + validateUpload(user.getId(), normalizedPath, filename, request.size()); + + fileContentStorage.completeUpload(user.getId(), normalizedPath, storageName, request.contentType(), request.size()); + return saveFileMetadata(user, normalizedPath, filename, storageName, request.contentType(), request.size()); } @Transactional @@ -87,11 +92,8 @@ public class FileService { if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), parentPath, directoryName)) { throw new BusinessException(ErrorCode.UNKNOWN, "目录已存在"); } - try { - Files.createDirectories(resolveUserPath(user.getId(), normalizedPath)); - } catch (IOException ex) { - throw new BusinessException(ErrorCode.UNKNOWN, "目录创建失败"); - } + + fileContentStorage.createDirectory(user.getId(), normalizedPath); StoredFile storedFile = new StoredFile(); storedFile.setUser(user); @@ -126,11 +128,8 @@ public class FileService { continue; } - try { - Files.createDirectories(resolveUserPath(user.getId(), "/").resolve(directoryName)); - } catch (IOException ex) { - throw new BusinessException(ErrorCode.UNKNOWN, "默认目录初始化失败"); - } + String logicalPath = "/" + directoryName; + fileContentStorage.ensureDirectory(user.getId(), logicalPath); StoredFile storedFile = new StoredFile(); storedFile.setUser(user); @@ -146,53 +145,146 @@ public class FileService { @Transactional public void delete(User user, Long fileId) { - StoredFile storedFile = storedFileRepository.findById(fileId) - .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在")); - if (!storedFile.getUser().getId().equals(user.getId())) { - throw new BusinessException(ErrorCode.PERMISSION_DENIED, "没有权限删除该文件"); - } - try { - Path basePath = resolveUserPath(user.getId(), storedFile.getPath()); - Path target = storedFile.isDirectory() - ? basePath.resolve(storedFile.getFilename()).normalize() - : basePath.resolve(storedFile.getStorageName()).normalize(); - Files.deleteIfExists(target); - } catch (IOException ex) { - throw new BusinessException(ErrorCode.UNKNOWN, "删除文件失败"); + StoredFile storedFile = getOwnedFile(user, fileId, "删除"); + if (storedFile.isDirectory()) { + String logicalPath = buildLogicalPath(storedFile); + List descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), logicalPath); + fileContentStorage.deleteDirectory(user.getId(), logicalPath, descendants); + if (!descendants.isEmpty()) { + storedFileRepository.deleteAll(descendants); + } + } else { + fileContentStorage.deleteFile(user.getId(), storedFile.getPath(), storedFile.getStorageName()); } storedFileRepository.delete(storedFile); } - public ResponseEntity 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 descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath); + fileContentStorage.renameDirectory(user.getId(), oldLogicalPath, newLogicalPath, descendants); + for (StoredFile descendant : descendants) { + if (descendant.getPath().equals(oldLogicalPath)) { + descendant.setPath(newLogicalPath); + continue; + } + + descendant.setPath(newLogicalPath + descendant.getPath().substring(oldLogicalPath.length())); + } + if (!descendants.isEmpty()) { + storedFileRepository.saveAll(descendants); + } + } else { + fileContentStorage.renameFile(user.getId(), storedFile.getPath(), storedFile.getStorageName(), sanitizedFilename); + } + + storedFile.setFilename(sanitizedFilename); + storedFile.setStorageName(sanitizedFilename); + return toResponse(storedFileRepository.save(storedFile)); + } + + public ResponseEntity download(User user, Long fileId) { + StoredFile storedFile = getOwnedFile(user, fileId, "下载"); if (storedFile.isDirectory()) { throw new BusinessException(ErrorCode.UNKNOWN, "目录不支持下载"); } - try { - Path filePath = resolveUserPath(user.getId(), storedFile.getPath()).resolve(storedFile.getStorageName()).normalize(); - byte[] body = Files.readAllBytes(filePath); - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename*=UTF-8''" + URLEncoder.encode(storedFile.getFilename(), StandardCharsets.UTF_8)) - .contentType(MediaType.parseMediaType( - storedFile.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : storedFile.getContentType())) - .body(body); - } catch (IOException ex) { - throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"); + + if (fileContentStorage.supportsDirectDownload()) { + return ResponseEntity.status(302) + .location(URI.create(fileContentStorage.createDownloadUrl( + user.getId(), + storedFile.getPath(), + storedFile.getStorageName(), + storedFile.getFilename()))) + .build(); + } + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename*=UTF-8''" + URLEncoder.encode(storedFile.getFilename(), StandardCharsets.UTF_8)) + .contentType(MediaType.parseMediaType( + storedFile.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : storedFile.getContentType())) + .body(fileContentStorage.readFile(user.getId(), storedFile.getPath(), storedFile.getStorageName())); + } + + public DownloadUrlResponse getDownloadUrl(User user, Long fileId) { + StoredFile storedFile = getOwnedFile(user, fileId, "下载"); + if (storedFile.isDirectory()) { + throw new BusinessException(ErrorCode.UNKNOWN, "目录不支持下载"); + } + + if (fileContentStorage.supportsDirectDownload()) { + return new DownloadUrlResponse(fileContentStorage.createDownloadUrl( + user.getId(), + storedFile.getPath(), + storedFile.getStorageName(), + storedFile.getFilename() + )); + } + + return new DownloadUrlResponse("/api/files/download/" + storedFile.getId()); + } + + private FileMetadataResponse saveFileMetadata(User user, + String normalizedPath, + String filename, + String storageName, + String contentType, + long size) { + StoredFile storedFile = new StoredFile(); + storedFile.setUser(user); + storedFile.setFilename(filename); + storedFile.setPath(normalizedPath); + storedFile.setStorageName(storageName); + storedFile.setContentType(contentType); + storedFile.setSize(size); + storedFile.setDirectory(false); + return toResponse(storedFileRepository.save(storedFile)); + } + + private StoredFile getOwnedFile(User user, Long fileId, String action) { + StoredFile storedFile = storedFileRepository.findById(fileId) + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在")); + if (!storedFile.getUser().getId().equals(user.getId())) { + throw new BusinessException(ErrorCode.PERMISSION_DENIED, "没有权限" + action + "该文件"); + } + return storedFile; + } + + private void validateUpload(Long userId, String normalizedPath, String filename, long size) { + if (size > maxFileSize) { + throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制"); + } + if (storedFileRepository.existsByUserIdAndPathAndFilename(userId, normalizedPath, filename)) { + throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在"); } } - private FileMetadataResponse toResponse(StoredFile storedFile) { - String logicalPath = storedFile.getPath(); - if (storedFile.isDirectory()) { - logicalPath = "/".equals(storedFile.getPath()) - ? "/" + storedFile.getFilename() - : storedFile.getPath() + "/" + storedFile.getFilename(); + private String normalizeUploadFilename(String originalFilename) { + String filename = StringUtils.cleanPath(originalFilename); + if (!StringUtils.hasText(filename)) { + throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空"); } + return normalizeLeafName(filename); + } + + private FileMetadataResponse toResponse(StoredFile storedFile) { + String logicalPath = storedFile.isDirectory() ? buildLogicalPath(storedFile) : storedFile.getPath(); return new FileMetadataResponse( storedFile.getId(), storedFile.getFilename(), @@ -221,16 +313,6 @@ public class FileService { return normalized; } - private Path resolveUserPath(Long userId, String normalizedPath) { - Path userRoot = rootPath.resolve(userId.toString()).normalize(); - Path relative = "/".equals(normalizedPath) ? Path.of("") : Path.of(normalizedPath.substring(1)); - Path resolved = userRoot.resolve(relative).normalize(); - if (!resolved.startsWith(userRoot)) { - throw new BusinessException(ErrorCode.UNKNOWN, "路径不合法"); - } - return resolved; - } - private String extractParentPath(String normalizedPath) { int lastSlash = normalizedPath.lastIndexOf('/'); return lastSlash <= 0 ? "/" : normalizedPath.substring(0, lastSlash); @@ -239,4 +321,21 @@ public class FileService { private String extractLeafName(String normalizedPath) { return normalizedPath.substring(normalizedPath.lastIndexOf('/') + 1); } + + private String buildLogicalPath(StoredFile storedFile) { + return "/".equals(storedFile.getPath()) + ? "/" + storedFile.getFilename() + : storedFile.getPath() + "/" + storedFile.getFilename(); + } + + private String normalizeLeafName(String filename) { + String cleaned = StringUtils.cleanPath(filename == null ? "" : filename).trim(); + if (!StringUtils.hasText(cleaned)) { + throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空"); + } + if (cleaned.contains("/") || cleaned.contains("\\") || cleaned.contains("..")) { + throw new BusinessException(ErrorCode.UNKNOWN, "文件名不合法"); + } + return cleaned; + } } diff --git a/backend/src/main/java/com/yoyuzh/files/InitiateUploadRequest.java b/backend/src/main/java/com/yoyuzh/files/InitiateUploadRequest.java new file mode 100644 index 0000000..10b0bf3 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/InitiateUploadRequest.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/InitiateUploadResponse.java b/backend/src/main/java/com/yoyuzh/files/InitiateUploadResponse.java new file mode 100644 index 0000000..eb8af44 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/InitiateUploadResponse.java @@ -0,0 +1,12 @@ +package com.yoyuzh.files; + +import java.util.Map; + +public record InitiateUploadResponse( + boolean direct, + String uploadUrl, + String method, + Map headers, + String storageName +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/RenameFileRequest.java b/backend/src/main/java/com/yoyuzh/files/RenameFileRequest.java new file mode 100644 index 0000000..0b61fa1 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/RenameFileRequest.java @@ -0,0 +1,9 @@ +package com.yoyuzh.files; + +import jakarta.validation.constraints.NotBlank; + +public record RenameFileRequest( + @NotBlank(message = "文件名不能为空") + String filename +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java b/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java index dc19b4c..c43fb55 100644 --- a/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java +++ b/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java @@ -28,5 +28,13 @@ public interface StoredFileRepository extends JpaRepository { @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 findByUserIdAndPathEqualsOrDescendant(@Param("userId") Long userId, + @Param("path") String path); + List findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(Long userId); } diff --git a/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java b/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java new file mode 100644 index 0000000..0c3b931 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java @@ -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"); + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java b/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java index bee106e..0c2ab24 100644 --- a/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java @@ -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; + } } diff --git a/docs/superpowers/plans/2026-03-19-oss-direct-upload.md b/docs/superpowers/plans/2026-03-19-oss-direct-upload.md new file mode 100644 index 0000000..b6d3fd2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-oss-direct-upload.md @@ -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 diff --git a/front/src/lib/api.test.ts b/front/src/lib/api.test.ts index 978ff38..23291e4 100644 --- a/front/src/lib/api.test.ts +++ b/front/src/lib/api.test.ts @@ -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(); + responseHeaders = new Map(); + 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; + + 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}, + ]); +}); diff --git a/front/src/lib/api.ts b/front/src/lib/api.ts index 817f638..02e3d86 100644 --- a/front/src/lib/api.ts +++ b/front/src/lib/api.ts @@ -10,6 +10,20 @@ interface ApiRequestInit extends Omit { 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(path: string, init?: ApiRequestInit) { return payload.data; } +export function apiUploadRequest(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((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; + 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((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: { diff --git a/front/src/lib/types.ts b/front/src/lib/types.ts index a035870..85cea09 100644 --- a/front/src/lib/types.ts +++ b/front/src/lib/types.ts @@ -32,6 +32,18 @@ export interface FileMetadata { createdAt: string; } +export interface InitiateUploadResponse { + direct: boolean; + uploadUrl: string; + method: 'POST' | 'PUT'; + headers: Record; + storageName: string; +} + +export interface DownloadUrlResponse { + url: string; +} + export interface CourseResponse { courseName: string; teacher: string | null; diff --git a/front/src/pages/Files.tsx b/front/src/pages/Files.tsx index ac1b746..25986fe 100644 --- a/front/src/pages/Files.tsx +++ b/front/src/pages/Files.tsx @@ -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; + export default function Files() { const initialPath = readCachedValue(getFilesLastPathCacheKey()) ?? []; const initialCachedFiles = readCachedValue(getFilesListCacheKey(toBackendPath(initialPath))) ?? []; const fileInputRef = useRef(null); + const uploadMeasurementsRef = useRef(new Map()); const [currentPath, setCurrentPath] = useState(initialPath); - const [selectedFile, setSelectedFile] = useState(null); - const [currentFiles, setCurrentFiles] = useState(initialCachedFiles.map(toUiFile)); + const currentPathRef = useRef(currentPath); + const [selectedFile, setSelectedFile] = useState(null); + const [currentFiles, setCurrentFiles] = useState(initialCachedFiles.map(toUiFile)); + const [uploads, setUploads] = useState([]); + const [isUploadPanelOpen, setIsUploadPanelOpen] = useState(true); + const [renameModalOpen, setRenameModalOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [fileToRename, setFileToRename] = useState(null); + const [fileToDelete, setFileToDelete] = useState(null); + const [newFileName, setNewFileName] = useState(''); + const [activeDropdown, setActiveDropdown] = useState(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>( @@ -99,6 +142,7 @@ export default function Files() { }; useEffect(() => { + currentPathRef.current = currentPath; const cachedFiles = readCachedValue(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) => { - 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(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), + }); + + 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('/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('/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(`/files/upload?path=${encodeURIComponent(uploadPath)}`, { + body: formData, + onProgress: updateProgress, + }); + } + } else if (initiated) { + const formData = new FormData(); + formData.append('file', uploadFile); + uploadedFile = await apiUploadRequest(initiated.uploadUrl, { + body: formData, + method: initiated.method, + headers: initiated.headers, + onProgress: updateProgress, + }); + } else { + const formData = new FormData(); + formData.append('file', uploadFile); + uploadedFile = await apiUploadRequest(`/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; + } }); - await loadCurrentPath(currentPath); - event.target.value = ''; + 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(`/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(`/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 (
{/* Left Sidebar */} @@ -255,33 +501,54 @@ export default function Files() { ))}
- - + +
{/* File List */}
- - - - - - - - - - - - {currentFiles.length > 0 ? ( - currentFiles.map((file) => ( + {currentFiles.length === 0 ? ( +
+ +

此文件夹为空

+
+ ) : viewMode === 'list' ? ( +
名称修改日期类型大小
+ + + + + + + + + + + {currentFiles.map((file) => ( 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]', )} > - )) - ) : ( - - - - )} - -
名称修改日期类型大小
@@ -302,24 +569,64 @@ export default function Files() { {file.type} {file.size} - + setActiveDropdown((previous) => (previous === fileId ? null : fileId))} + onRename={openRenameModal} + onDelete={openDeleteModal} + onClose={() => setActiveDropdown(null)} + />
-
- -

此文件夹为空

-
-
+ ))} + + + ) : ( +
+ {currentFiles.map((file) => ( +
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]', + )} + > +
+ setActiveDropdown((previous) => (previous === fileId ? null : fileId))} + onRename={openRenameModal} + onDelete={openDeleteModal} + onClose={() => setActiveDropdown(null)} + /> +
+ +
+ {file.type === 'folder' ? ( + + ) : file.type === 'image' ? ( + + ) : ( + + )} +
+ + + {file.name} + + + {file.type === 'folder' ? file.modified : file.size} + +
+ ))} +
+ )}
{/* Bottom Actions */} @@ -330,7 +637,7 @@ export default function Files() { - + @@ -366,20 +673,255 @@ export default function Files() { - {selectedFile.type !== 'folder' && ( - - )} - {selectedFile.type === 'folder' && ( - - )} +
+
+ + +
+ {selectedFile.type !== 'folder' && ( + + )} + {selectedFile.type === 'folder' && ( + + )} +
)} + + + {uploads.length > 0 && ( + +
setIsUploadPanelOpen((previous) => !previous)} + > +
+ + + 上传进度 ({uploads.filter((task) => task.status === 'completed').length}/{uploads.length}) + +
+
+ + +
+
+ + + {isUploadPanelOpen && ( + +
+ {uploads.map((task) => ( +
+ {task.status === 'uploading' && ( +
+ )} + +
+
+ {task.status === 'completed' ? ( + + ) : task.status === 'error' ? ( + + ) : ( + + )} +
+
+

{task.fileName}

+

上传至: {task.destination}

+ {task.noticeMessage && ( +

{task.noticeMessage}

+ )} + + {task.status === 'uploading' && ( +
+ {Math.round(task.progress)}% + {task.speed} +
+ )} + {task.status === 'completed' && ( +

上传完成

+ )} + {task.status === 'error' && ( +

{task.errorMessage ?? '上传失败,请稍后重试'}

+ )} +
+
+
+ ))} +
+ + )} + + + )} + + + + {renameModalOpen && ( +
+ +
+

+ + 重命名 +

+ +
+
+
+ + 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(); + } + }} + /> +
+ {renameError && ( +
+ {renameError} +
+ )} +
+ + +
+
+
+
+ )} + + {deleteModalOpen && ( +
+ +
+

+ + 确认删除 +

+ +
+
+

+ 确定要删除 {fileToDelete?.name} 吗?此操作无法撤销。 +

+
+ + +
+
+
+
+ )} +
); } @@ -392,3 +934,74 @@ function DetailItem({ label, value }: { label: string; value: string }) { ); } + +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 ( +
+ + {activeDropdown === file.id && ( +
{ + event.stopPropagation(); + onClose(); + }} + /> + )} + + {activeDropdown === file.id && ( + + + + + )} + +
+ ); +} diff --git a/front/src/pages/files-state.test.ts b/front/src/pages/files-state.test.ts new file mode 100644 index 0000000..dfec3ad --- /dev/null +++ b/front/src/pages/files-state.test.ts @@ -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(['实验数据', '下载'])), '课程资料'); +}); diff --git a/front/src/pages/files-state.ts b/front/src/pages/files-state.ts new file mode 100644 index 0000000..b94b36d --- /dev/null +++ b/front/src/pages/files-state.ts @@ -0,0 +1,55 @@ +export interface FilesUiItem { + id: number; + name: string; + type: string; + size: string; + modified: string; +} + +export function getNextAvailableName(name: string, existingNames: Set) { + 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(files: T[], nextFile: T) { + return files.map((file) => (file.id === nextFile.id ? nextFile : file)); +} + +export function removeUiFile(files: T[], fileId: number) { + return files.filter((file) => file.id !== fileId); +} + +export function syncSelectedFile(selectedFile: T | null, nextFile: T) { + if (!selectedFile || selectedFile.id !== nextFile.id) { + return selectedFile; + } + + return nextFile; +} + +export function clearSelectionIfDeleted(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; +} diff --git a/front/src/pages/files-upload.test.ts b/front/src/pages/files-upload.test.ts new file mode 100644 index 0000000..0225b91 --- /dev/null +++ b/front/src/pages/files-upload.test.ts @@ -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); +}); diff --git a/front/src/pages/files-upload.ts b/front/src/pages/files-upload.ts new file mode 100644 index 0000000..3221b4b --- /dev/null +++ b/front/src/pages/files-upload.ts @@ -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) { + 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, + }; +} diff --git a/scripts/migrate-file-storage-to-oss.mjs b/scripts/migrate-file-storage-to-oss.mjs new file mode 100644 index 0000000..d8f547c --- /dev/null +++ b/scripts/migrate-file-storage-to-oss.mjs @@ -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}>(.*?)`, '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; +});