添加上传和下载文件夹
This commit is contained in:
32
backend/AGENTS.md
Normal file
32
backend/AGENTS.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Backend AGENTS
|
||||
|
||||
This directory is the Spring Boot backend for `yoyuzh.xyz`. Keep changes aligned with the current package layout instead of introducing a new architecture.
|
||||
|
||||
## Backend layout
|
||||
|
||||
- `src/main/java/com/yoyuzh/auth`: authentication, JWT, login/register/profile DTOs and services.
|
||||
- `src/main/java/com/yoyuzh/files`: file APIs and storage flows, including `files/storage`.
|
||||
- `src/main/java/com/yoyuzh/cqu`: CQU schedule/grade aggregation.
|
||||
- `src/main/java/com/yoyuzh/config`: Spring and security configuration.
|
||||
- `src/main/java/com/yoyuzh/common`: shared exceptions and common utilities.
|
||||
- `src/main/resources`: runtime config and logging.
|
||||
- `src/test/java/com/yoyuzh/...`: matching package-level tests.
|
||||
|
||||
## Real backend commands
|
||||
|
||||
Run these from `backend/`:
|
||||
|
||||
- `mvn spring-boot:run`
|
||||
- `mvn spring-boot:run -Dspring-boot.run.profiles=dev`
|
||||
- `mvn test`
|
||||
- `mvn package`
|
||||
|
||||
There is no dedicated backend lint command and no dedicated backend typecheck command in the checked-in Maven config or README. If a task asks for lint/typecheck, say that the backend currently does not define those commands.
|
||||
|
||||
## Backend rules
|
||||
|
||||
- Keep controller, service, DTO, config, and storage responsibilities separated along the current package boundaries.
|
||||
- When changing `auth`, `files`, or `cqu`, check whether an existing test package already covers that area before adding new files elsewhere.
|
||||
- Respect the existing `dev` profile in `application-dev.yml`; do not hardcode assumptions that bypass H2 or mock CQU behavior.
|
||||
- If a change affects file storage behavior, note that the repo currently supports local storage and OSS-related migration/deploy scripts.
|
||||
- Prefer Maven-based verification from this directory instead of ad hoc shell pipelines.
|
||||
@@ -17,10 +17,17 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@Service
|
||||
public class FileService {
|
||||
@@ -43,6 +50,7 @@ public class FileService {
|
||||
String normalizedPath = normalizeDirectoryPath(path);
|
||||
String filename = normalizeUploadFilename(multipartFile.getOriginalFilename());
|
||||
validateUpload(user.getId(), normalizedPath, filename, multipartFile.getSize());
|
||||
ensureDirectoryHierarchy(user, normalizedPath);
|
||||
|
||||
fileContentStorage.upload(user.getId(), normalizedPath, filename, multipartFile);
|
||||
return saveFileMetadata(user, normalizedPath, filename, filename, multipartFile.getContentType(), multipartFile.getSize());
|
||||
@@ -76,6 +84,7 @@ public class FileService {
|
||||
String filename = normalizeLeafName(request.filename());
|
||||
String storageName = normalizeLeafName(request.storageName());
|
||||
validateUpload(user.getId(), normalizedPath, filename, request.size());
|
||||
ensureDirectoryHierarchy(user, normalizedPath);
|
||||
|
||||
fileContentStorage.completeUpload(user.getId(), normalizedPath, storageName, request.contentType(), request.size());
|
||||
return saveFileMetadata(user, normalizedPath, filename, storageName, request.contentType(), request.size());
|
||||
@@ -201,7 +210,7 @@ public class FileService {
|
||||
public ResponseEntity<?> download(User user, Long fileId) {
|
||||
StoredFile storedFile = getOwnedFile(user, fileId, "下载");
|
||||
if (storedFile.isDirectory()) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目录不支持下载");
|
||||
return downloadDirectory(user, storedFile);
|
||||
}
|
||||
|
||||
if (fileContentStorage.supportsDirectDownload()) {
|
||||
@@ -240,6 +249,44 @@ public class FileService {
|
||||
return new DownloadUrlResponse("/api/files/download/" + storedFile.getId());
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> downloadDirectory(User user, StoredFile directory) {
|
||||
String logicalPath = buildLogicalPath(directory);
|
||||
String archiveName = directory.getFilename() + ".zip";
|
||||
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), logicalPath)
|
||||
.stream()
|
||||
.sorted(Comparator.comparing(StoredFile::getPath).thenComparing(StoredFile::getFilename))
|
||||
.toList();
|
||||
|
||||
byte[] archiveBytes;
|
||||
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8)) {
|
||||
Set<String> createdEntries = new LinkedHashSet<>();
|
||||
writeDirectoryEntry(zipOutputStream, createdEntries, directory.getFilename() + "/");
|
||||
|
||||
for (StoredFile descendant : descendants) {
|
||||
String entryName = buildZipEntryName(directory.getFilename(), logicalPath, descendant);
|
||||
if (descendant.isDirectory()) {
|
||||
writeDirectoryEntry(zipOutputStream, createdEntries, entryName + "/");
|
||||
continue;
|
||||
}
|
||||
|
||||
ensureParentDirectoryEntries(zipOutputStream, createdEntries, entryName);
|
||||
writeFileEntry(zipOutputStream, createdEntries, entryName,
|
||||
fileContentStorage.readFile(user.getId(), descendant.getPath(), descendant.getStorageName()));
|
||||
}
|
||||
zipOutputStream.finish();
|
||||
archiveBytes = outputStream.toByteArray();
|
||||
} catch (IOException ex) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目录压缩失败");
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename*=UTF-8''" + URLEncoder.encode(archiveName, StandardCharsets.UTF_8))
|
||||
.contentType(MediaType.parseMediaType("application/zip"))
|
||||
.body(archiveBytes);
|
||||
}
|
||||
|
||||
private FileMetadataResponse saveFileMetadata(User user,
|
||||
String normalizedPath,
|
||||
String filename,
|
||||
@@ -275,6 +322,37 @@ public class FileService {
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureDirectoryHierarchy(User user, String normalizedPath) {
|
||||
if ("/".equals(normalizedPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String[] segments = normalizedPath.substring(1).split("/");
|
||||
String currentPath = "/";
|
||||
|
||||
for (String segment : segments) {
|
||||
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), currentPath, segment)) {
|
||||
currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment;
|
||||
continue;
|
||||
}
|
||||
|
||||
String logicalPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment;
|
||||
fileContentStorage.ensureDirectory(user.getId(), logicalPath);
|
||||
|
||||
StoredFile storedFile = new StoredFile();
|
||||
storedFile.setUser(user);
|
||||
storedFile.setFilename(segment);
|
||||
storedFile.setPath(currentPath);
|
||||
storedFile.setStorageName(segment);
|
||||
storedFile.setContentType("directory");
|
||||
storedFile.setSize(0L);
|
||||
storedFile.setDirectory(true);
|
||||
storedFileRepository.save(storedFile);
|
||||
|
||||
currentPath = logicalPath;
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeUploadFilename(String originalFilename) {
|
||||
String filename = StringUtils.cleanPath(originalFilename);
|
||||
if (!StringUtils.hasText(filename)) {
|
||||
@@ -328,6 +406,43 @@ public class FileService {
|
||||
: storedFile.getPath() + "/" + storedFile.getFilename();
|
||||
}
|
||||
|
||||
private String buildZipEntryName(String rootDirectoryName, String rootLogicalPath, StoredFile storedFile) {
|
||||
StringBuilder entryName = new StringBuilder(rootDirectoryName).append('/');
|
||||
if (!storedFile.getPath().equals(rootLogicalPath)) {
|
||||
entryName.append(storedFile.getPath().substring(rootLogicalPath.length() + 1)).append('/');
|
||||
}
|
||||
entryName.append(storedFile.getFilename());
|
||||
return entryName.toString();
|
||||
}
|
||||
|
||||
private void ensureParentDirectoryEntries(ZipOutputStream zipOutputStream, Set<String> createdEntries, String entryName) throws IOException {
|
||||
int slashIndex = entryName.indexOf('/');
|
||||
while (slashIndex >= 0) {
|
||||
writeDirectoryEntry(zipOutputStream, createdEntries, entryName.substring(0, slashIndex + 1));
|
||||
slashIndex = entryName.indexOf('/', slashIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeDirectoryEntry(ZipOutputStream zipOutputStream, Set<String> createdEntries, String entryName) throws IOException {
|
||||
if (!createdEntries.add(entryName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
zipOutputStream.putNextEntry(new ZipEntry(entryName));
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
|
||||
private void writeFileEntry(ZipOutputStream zipOutputStream, Set<String> createdEntries, String entryName, byte[] content)
|
||||
throws IOException {
|
||||
if (!createdEntries.add(entryName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
zipOutputStream.putNextEntry(new ZipEntry(entryName));
|
||||
zipOutputStream.write(content);
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
|
||||
private String normalizeLeafName(String filename) {
|
||||
String cleaned = StringUtils.cleanPath(filename == null ? "" : filename).trim();
|
||||
if (!StringUtils.hasText(cleaned)) {
|
||||
|
||||
@@ -12,12 +12,18 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -112,6 +118,23 @@ class FileServiceTest {
|
||||
verify(fileContentStorage).completeUpload(7L, "/docs", "notes.txt", "text/plain", 12L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateMissingDirectoriesBeforeCompletingNestedUpload() {
|
||||
User user = createUser(7L);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects/site", "logo.png")).thenReturn(false);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "projects")).thenReturn(false);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects", "site")).thenReturn(false);
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
fileService.completeUpload(user,
|
||||
new CompleteUploadRequest("/projects/site", "logo.png", "logo.png", "image/png", 12L));
|
||||
|
||||
verify(fileContentStorage).ensureDirectory(7L, "/projects");
|
||||
verify(fileContentStorage).ensureDirectory(7L, "/projects/site");
|
||||
verify(fileContentStorage).completeUpload(7L, "/projects/site", "logo.png", "image/png", 12L);
|
||||
verify(storedFileRepository, times(3)).save(any(StoredFile.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRenameFileThroughConfiguredStorage() {
|
||||
User user = createUser(7L);
|
||||
@@ -229,6 +252,47 @@ class FileServiceTest {
|
||||
verify(fileContentStorage, never()).createDownloadUrl(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDownloadDirectoryAsZipArchive() throws Exception {
|
||||
User user = createUser(7L);
|
||||
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
||||
StoredFile childDirectory = createDirectory(11L, user, "/docs/archive", "nested");
|
||||
StoredFile childFile = createFile(12L, user, "/docs/archive", "notes.txt");
|
||||
StoredFile nestedFile = createFile(13L, user, "/docs/archive/nested", "todo.txt");
|
||||
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive"))
|
||||
.thenReturn(List.of(childDirectory, childFile, nestedFile));
|
||||
when(fileContentStorage.readFile(7L, "/docs/archive", "notes.txt"))
|
||||
.thenReturn("hello".getBytes(StandardCharsets.UTF_8));
|
||||
when(fileContentStorage.readFile(7L, "/docs/archive/nested", "todo.txt"))
|
||||
.thenReturn("world".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
var response = fileService.download(user, 10L);
|
||||
|
||||
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
|
||||
.contains("archive.zip");
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.parseMediaType("application/zip"));
|
||||
|
||||
Map<String, String> entries = new LinkedHashMap<>();
|
||||
try (ZipInputStream zipInputStream = new ZipInputStream(
|
||||
new ByteArrayInputStream((byte[]) response.getBody()), StandardCharsets.UTF_8)) {
|
||||
var entry = zipInputStream.getNextEntry();
|
||||
while (entry != null) {
|
||||
entries.put(entry.getName(), entry.isDirectory() ? "" : new String(zipInputStream.readAllBytes(), StandardCharsets.UTF_8));
|
||||
entry = zipInputStream.getNextEntry();
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(entries).containsEntry("archive/", "");
|
||||
assertThat(entries).containsEntry("archive/nested/", "");
|
||||
assertThat(entries).containsEntry("archive/notes.txt", "hello");
|
||||
assertThat(entries).containsEntry("archive/nested/todo.txt", "world");
|
||||
verify(fileContentStorage).readFile(7L, "/docs/archive", "notes.txt");
|
||||
verify(fileContentStorage).readFile(7L, "/docs/archive/nested", "todo.txt");
|
||||
}
|
||||
|
||||
private User createUser(Long id) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
|
||||
Reference in New Issue
Block a user