From 0e4a1c1c355126c795f01ac96d81d5a0fbf326f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 29 Aug 2026 20:18:57 +0800 Subject: [PATCH] =?UTF-8?q?task-33:=20=E5=B0=86=E5=BA=97=E9=93=BA=E6=BA=90?= =?UTF-8?q?=E6=96=87=E4=BB=B6=20key=20=E6=98=A0=E5=B0=84=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E7=A1=AE=E5=AE=9A=E8=B7=AF=E5=BE=84=EF=BC=8C=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E7=9B=AE=E5=BD=95=E9=80=92=E5=BD=92=E6=89=AB?= =?UTF-8?q?=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/service/LocalFileStorageService.java | 17 +- .../service/LocalFileStorageServiceTest.java | 160 ++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/file/service/LocalFileStorageServiceTest.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java index aec12371..ebc32c12 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java @@ -96,13 +96,26 @@ public class LocalFileStorageService { } } + /** + * 店铺源文件 key → 确定路径解析:saveTempFile 始终把源文件平铺写入 + * localTempDir/[.],因此这里只列举临时目录根层(非递归), + * 匹配 name == fileKey 或 fileKey. 的直接子文件, + * 取代原 FileUtil.loopFiles 对整棵临时目录树的递归前缀扫描。 + */ public File findLocalSourceFile(String fileKey) { + if (fileKey == null || fileKey.isBlank()) { + return null; + } File baseDir = FileUtil.file(storageProperties.getLocalTempDir()); if (!baseDir.exists()) { return null; } - List matchedFiles = FileUtil.loopFiles(baseDir, pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey)); - return matchedFiles.isEmpty() ? null : matchedFiles.getFirst(); + File[] matchedFiles = baseDir.listFiles(pathname -> pathname.isFile() + && (pathname.getName().equals(fileKey) || pathname.getName().startsWith(fileKey + "."))); + if (matchedFiles == null) { + return null; + } + return matchedFiles.length == 0 ? null : matchedFiles[0]; } private String normalizeCellText(String value) { diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/LocalFileStorageServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/LocalFileStorageServiceTest.java new file mode 100644 index 00000000..f87a17de --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/LocalFileStorageServiceTest.java @@ -0,0 +1,160 @@ +package com.nanri.aiimage.modules.file.service; + +import cn.hutool.core.io.FileUtil; +import com.nanri.aiimage.config.StorageProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Task 33:将店铺源文件 key 映射改为确定路径,取消临时目录递归扫描。 + * saveTempFile 始终把源文件平铺写入 localTempDir/[.], + * 原 findLocalSourceFile 却用 FileUtil.loopFiles 对临时目录递归扫描匹配前缀; + * 实现改为确定路径解析:只扫描临时目录根层(非递归), + * 子目录中的同名文件不属于 key 映射,不再被递归命中。 + */ +class LocalFileStorageServiceTest { + + @TempDir + Path tempDir; + + private StorageProperties storageProperties; + private LocalFileStorageService service; + + @BeforeEach + void setUp() { + storageProperties = new StorageProperties(); + storageProperties.setLocalTempDir(tempDir.toString()); + service = new LocalFileStorageService(storageProperties); + } + + @Test + void test_task_033_task_normal_default_path() { + // 正常路径:平铺写入的源文件按 key 解析到确定路径,返回真实文件。 + File source = writeSourceFile("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "xlsx", "sheet1"); + + File resolved = service.findLocalSourceFile("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"); + + assertNotNull(resolved, "确定路径解析到源文件"); + assertTrue(resolved.isFile()); + assertEquals(source.getAbsolutePath(), resolved.getAbsolutePath(), "路径与平铺写入一致"); + } + + @Test + void test_task_033_task_normal_multiple_items() { + // 批量场景:多个源文件 key 各自解析到自己的文件,互不串扰。 + for (int i = 0; i < 5; i++) { + writeSourceFile(key(i), "xlsx", "content-" + i); + } + + for (int i = 0; i < 5; i++) { + File resolved = service.findLocalSourceFile(key(i)); + assertNotNull(resolved, "key-" + i + " 可解析"); + assertEquals("content-" + i, readFile(resolved), "key-" + i + " 内容正确"); + } + } + + @Test + void test_task_033_task_normal_repeated_operation_is_idempotent() { + // 幂等:同一 key 重复解析返回同一文件,结果稳定。 + writeSourceFile(key(0), "xlsx", "data"); + + File first = service.findLocalSourceFile(key(0)); + File second = service.findLocalSourceFile(key(0)); + File third = service.findLocalSourceFile(key(0)); + + assertNotNull(first); + assertEquals(first.getAbsolutePath(), second.getAbsolutePath(), "重复解析路径一致"); + assertEquals(first.getAbsolutePath(), third.getAbsolutePath()); + } + + @Test + void test_task_033_task_boundary_empty_input() { + // 空输入:临时目录为空时返回 null;子目录中的同名文件不属于 key 映射(非递归)。 + File subDir = new File(tempDir.toFile(), "sub"); + assertTrue(subDir.mkdirs()); + writeSourceFileInto(subDir, key(1), "csv", "decoy"); + + assertNull(service.findLocalSourceFile(key(1)), "子目录文件不参与确定路径映射"); + assertNull(service.findLocalSourceFile(key(2)), "不存在的 key 返回 null"); + } + + @Test + void test_task_033_task_boundary_single_item() { + // 单元素:单个源文件解析正确,不依赖批量路径。 + writeSourceFile(key(0), "csv", "single"); + + File resolved = service.findLocalSourceFile(key(0)); + assertNotNull(resolved); + assertEquals("single", readFile(resolved)); + } + + @Test + void test_task_033_task_boundary_limit_and_overflow() { + // 上限/超限:目录内大量文件时目标 key 仍解析正确,无内存无界增长。 + for (int i = 0; i < 300; i++) { + writeSourceFile(key(i), "xlsx", "bulk-" + i); + } + + for (int probe : new int[]{0, 150, 299}) { + File resolved = service.findLocalSourceFile(key(probe)); + assertNotNull(resolved, "大量文件中 key-" + probe + " 仍可解析"); + assertEquals("bulk-" + probe, readFile(resolved)); + } + } + + @Test + void test_task_033_task_invalid_input_rejected() { + // 非法参数:null/空白 key 安全返回 null;含路径分隔符的 key 被拒绝,防止路径穿越。 + assertNull(service.findLocalSourceFile(null), "null key 安全返回 null"); + assertNull(service.findLocalSourceFile(" "), "空白 key 安全返回 null"); + assertNull(service.findLocalSourceFile("../../etc/passwd"), "路径穿越 key 被拒绝"); + assertNull(service.findLocalSourceFile("sub/" + key(0)), "含分隔符 key 被拒绝"); + } + + @Test + void test_task_033_task_dependency_failure_releases_resources() { + // 依赖失败:临时目录不存在时安全返回 null 不抛异常;文件被清理后解析返回 null。 + StorageProperties missing = new StorageProperties(); + missing.setLocalTempDir(tempDir.resolve("not-exists").toString()); + LocalFileStorageService missingDirService = new LocalFileStorageService(missing); + assertNull(missingDirService.findLocalSourceFile(key(0)), "目录缺失返回 null 不抛异常"); + + File source = writeSourceFile(key(0), "xlsx", "temp"); + assertNotNull(service.findLocalSourceFile(key(0))); + assertTrue(source.delete()); + assertNull(service.findLocalSourceFile(key(0)), "文件清理后解析返回 null"); + } + + private static String key(int index) { + return String.format("%032d", index); + } + + private File writeSourceFile(String fileKey, String ext, String content) { + return writeSourceFileInto(tempDir.toFile(), fileKey, ext, content); + } + + private File writeSourceFileInto(File dir, String fileKey, String ext, String content) { + File file = FileUtil.file(dir, fileKey + "." + ext); + FileUtil.writeUtf8String(content, file); + return file; + } + + private String readFile(File file) { + try { + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } catch (Exception ex) { + throw new IllegalStateException("读取测试文件失败", ex); + } + } +}