暂存提交

This commit is contained in:
2026-07-31 16:53:07 +08:00
parent 45358ef40f
commit 704cb15ccf
98 changed files with 1944 additions and 134 deletions
+4
View File
@@ -19,6 +19,7 @@
| 未被当前 Controller 使用的旧实现 | `service/QualificationFileStorage.java` |
| IAM 权限协作 | `client/IamQualificationClient.java` |
| 目标表结构(尚未接入) | `src/main/resources/db/file.sql` |
| 历史资质文件迁移 | `../scripts/migrate-local-qualification-file.ps1`、`../docs/企业资质文件迁移说明.md`;只适用于当前本地/共享目录存储实现 |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/file-service.yml` |
## 修改与验证
@@ -27,3 +28,6 @@
- 生产存储应使用对象存储或共享文件存储;容器本地目录仅适合当前本地实现,不得硬编码环境路径。
- 接入元数据持久化时需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller。
- 当前没有专属自动化测试;从父目录运行 `mvn -pl file -am test`,并按接口影响验证上传、预览、无权限和非法文件场景。
- 非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;服务启动时会校验目录,存储不可用时直接启动失败,避免健康检查正常而上传返回 500。
- 文件写入失败属于基础设施故障,统一返回 `S0503`,服务日志保留实际目录和底层异常;禁止将磁盘故障包装成 `B0001`。
- 迁移历史 `/phase1-seed/...` 路径时必须先写入并校验真实脱敏图片,再按 `id + tenant_id + 旧 URL` 更新 IAM 元数据;禁止只改 URL。对象存储环境必须改走 File 上传接口。
@@ -1,6 +1,10 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
@@ -11,12 +15,15 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.StandardOpenOption;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
@Service
public class LocalQualificationStorage {
private static final Logger LOGGER = LoggerFactory.getLogger(LocalQualificationStorage.class);
private static final long MAX_SIZE = 5L * 1024 * 1024;
private static final Set<String> TYPES = Set.of("image/jpeg", "image/png");
private final Path root;
@@ -25,6 +32,20 @@ public class LocalQualificationStorage {
this.root = Path.of(root).toAbsolutePath().normalize();
}
/** 服务启动时校验存储根目录,避免健康检查正常但首次上传才暴露挂载或权限问题。 */
@PostConstruct
public void initialize() {
try {
Files.createDirectories(root);
if (!Files.isDirectory(root) || !Files.isWritable(root)) {
throw new IOException("存储根目录不可写");
}
} catch (IOException exception) {
LOGGER.error("企业资质存储根目录不可用:{}", root, exception);
throw new IllegalStateException("企业资质存储根目录不可用", exception);
}
}
public StoredFile store(long tenantId, MultipartFile file) {
if (file == null || file.isEmpty()) throw new BizException("One business-license image is required");
if (file.getSize() > MAX_SIZE) throw new BizException("Business-license image must not exceed 5 MB");
@@ -40,11 +61,19 @@ public class LocalQualificationStorage {
if (!target.startsWith(directory)) throw new BizException("Invalid upload path");
try {
Files.createDirectories(directory);
file.transferTo(target);
try (InputStream input = file.getInputStream();
OutputStream output = Files.newOutputStream(target,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
input.transferTo(output);
}
return new StoredFile(target, fileName,
"/api/merchant/v1/files/qualifications/" + fileName);
} catch (IOException exception) {
throw new BizException("Unable to store enterprise qualification");
try { Files.deleteIfExists(target); } catch (IOException cleanupException) {
exception.addSuppressed(cleanupException);
}
LOGGER.error("企业资质文件写入失败,tenantId={},目录={}", tenantId, directory, exception);
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "企业资质文件存储暂不可用");
}
}
+1 -1
View File
@@ -32,5 +32,5 @@ baiyue:
iam:
url: ${IAM_SERVICE_URL:}
file:
storage-root: ${FILE_STORAGE_ROOT:D:/baiyue-firstleg-runtime/uploads}
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
management.endpoints.web.exposure.include: health,info,prometheus
@@ -1,5 +1,6 @@
package com.baiyue.file.controller;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
@@ -9,6 +10,7 @@ import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertTrue;
@DisplayName("企业资质文件接口契约功能")
class QualificationFileContractTest {
@Test
@@ -1,6 +1,8 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
@@ -12,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@DisplayName("本地企业资质文件存储功能")
class LocalQualificationStorageTest {
@TempDir
@@ -20,6 +23,7 @@ class LocalQualificationStorageTest {
@Test
void storeReturnsFileServiceUrlBackedByARealTenantFile() throws Exception {
var storage = new LocalQualificationStorage(storageRoot.toString());
storage.initialize();
byte[] png = {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01};
var upload = new MockMultipartFile("file", "license.png", "image/png", png);
@@ -39,4 +43,29 @@ class LocalQualificationStorageTest {
assertThrows(BizException.class,
() -> storage.load(10001L, "demo-business-license.jpg"));
}
@Test
void invalidStorageFailsFastDuringStartup() throws Exception {
Path invalidRoot = storageRoot.resolve("root-is-file");
Files.writeString(invalidRoot, "not-a-directory");
var storage = new LocalQualificationStorage(invalidRoot.toString());
IllegalStateException exception = assertThrows(IllegalStateException.class, storage::initialize);
assertTrue(exception.getMessage().contains("存储根目录不可用"));
}
@Test
void writeFailureReturnsServiceUnavailableInsteadOfBusinessError() throws Exception {
var storage = new LocalQualificationStorage(storageRoot.toString());
storage.initialize();
Files.writeString(storageRoot.resolve("qualifications"), "阻止创建租户目录");
byte[] png = {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01};
var upload = new MockMultipartFile("file", "license.png", "image/png", png);
BizException exception = assertThrows(BizException.class,
() -> storage.store(10001L, upload));
assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
}
}