diff --git a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java index 72185c614..ef6371296 100644 --- a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java +++ b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java @@ -27,31 +27,25 @@ public class LocalQualificationStorage { private static final long MAX_SIZE = 5L * 1024 * 1024; private static final Set TYPES = Set.of("image/jpeg", "image/png"); private final Path root; + private final Path qualificationRoot; public LocalQualificationStorage(@Value("${baiyue.file.storage-root}") String root) { this.root = Path.of(root).toAbsolutePath().normalize(); + this.qualificationRoot = this.root.resolve("qualifications").normalize(); } /** 服务启动时校验存储根目录,避免健康检查正常但首次上传才暴露挂载或权限问题。 */ @PostConstruct public void initialize() { - Path probe = null; try { Files.createDirectories(root); - if (!Files.isDirectory(root) || !Files.isWritable(root)) { + Files.createDirectories(qualificationRoot); + if (!qualificationRoot.startsWith(root) + || !Files.isDirectory(qualificationRoot) || !Files.isWritable(qualificationRoot)) { throw new IOException("存储根目录不可写"); } - probe = root.resolve(".qualification-storage-probe-" + UUID.randomUUID()); - Files.write(probe, new byte[] {0x42}, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); - Files.delete(probe); + verifyWriteAccess(); } catch (IOException exception) { - if (probe != null) { - try { - Files.deleteIfExists(probe); - } catch (IOException cleanupException) { - exception.addSuppressed(cleanupException); - } - } LOGGER.error("企业资质存储根目录不可用:{}", root, exception); throw new IllegalStateException("企业资质存储根目录不可用", exception); } @@ -67,7 +61,7 @@ public class LocalQualificationStorage { } String extension = "image/png".equals(contentType) ? ".png" : ".jpg"; String fileName = UUID.randomUUID() + extension; - Path directory = root.resolve("qualifications").resolve(Long.toString(tenantId)).normalize(); + Path directory = tenantDirectory(tenantId); Path target = directory.resolve(fileName).normalize(); if (!target.startsWith(directory)) throw new BizException("Invalid upload path"); try { @@ -106,7 +100,7 @@ public class LocalQualificationStorage { public Resource load(long tenantId, String fileName) { if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png)")) throw new BizException("Invalid file name"); - Path directory = root.resolve("qualifications").resolve(Long.toString(tenantId)).normalize(); + Path directory = tenantDirectory(tenantId); Path target = directory.resolve(fileName).normalize(); if (!target.startsWith(directory) || !Files.isRegularFile(target)) throw new BizException("File not found"); try { return new UrlResource(target.toUri()); } @@ -117,5 +111,26 @@ public class LocalQualificationStorage { try { Files.deleteIfExists(file.path()); } catch (IOException ignored) { } } + private Path tenantDirectory(long tenantId) { + Path directory = qualificationRoot.resolve(Long.toString(tenantId)).normalize(); + if (!directory.startsWith(qualificationRoot)) { + throw new BizException("Invalid upload path"); + } + return directory; + } + + /** 验证实际挂载目录可创建和删除文件,避免仅凭 isWritable 掩盖 Docker bind mount 权限或只读问题。 */ + private void verifyWriteAccess() throws IOException { + Path probe = Files.createTempFile(qualificationRoot, ".file-service-probe-", ".tmp"); + try { + Files.delete(probe); + } catch (IOException exception) { + try { Files.deleteIfExists(probe); } catch (IOException cleanupException) { + exception.addSuppressed(cleanupException); + } + throw exception; + } + } + public record StoredFile(Path path, String fileName, String documentUrl) { } } diff --git a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java index 5a3eb2c4e..522355169 100644 --- a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java +++ b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java @@ -20,6 +20,19 @@ class LocalQualificationStorageTest { @TempDir Path storageRoot; + @Test + void initializeVerifiesTheActualQualificationMountAndCleansUpItsProbe() throws Exception { + var storage = new LocalQualificationStorage(storageRoot.toString()); + + storage.initialize(); + + Path qualificationRoot = storageRoot.resolve("qualifications"); + assertTrue(Files.isDirectory(qualificationRoot)); + try (var files = Files.list(qualificationRoot)) { + assertEquals(0, files.count()); + } + } + @Test void storeReturnsFileServiceUrlBackedByARealTenantFile() throws Exception { var storage = new LocalQualificationStorage(storageRoot.toString()); @@ -60,7 +73,7 @@ class LocalQualificationStorageTest { void writeFailureReturnsServiceUnavailableInsteadOfBusinessError() throws Exception { var storage = new LocalQualificationStorage(storageRoot.toString()); storage.initialize(); - Files.writeString(storageRoot.resolve("qualifications"), "阻止创建租户目录"); + Files.writeString(storageRoot.resolve("qualifications").resolve("10001"), "阻止创建租户目录"); byte[] png = {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01}; var upload = new MockMultipartFile("file", "license.png", "image/png", png);