暂存提交
This commit is contained in:
@@ -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, "企业资质文件存储暂不可用");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user